Skip to content

Commit 1c9db21

Browse files
committed
auto approve UX improvements
1 parent a81fade commit 1c9db21

24 files changed

Lines changed: 133 additions & 2 deletions

File tree

packages/types/src/message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export const clineMessageSchema = z.object({
272272
isProtected: z.boolean().optional(),
273273
apiProtocol: z.union([z.literal("openai"), z.literal("anthropic")]).optional(),
274274
isAnswered: z.boolean().optional(),
275+
autoApprovalDecision: z.union([z.literal("approve"), z.literal("deny")]).optional(),
275276
})
276277

277278
export type ClineMessage = z.infer<typeof clineMessageSchema>

src/core/task/Task.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11851185
const state = provider ? await provider.getState() : undefined
11861186
const approval = await checkAutoApproval({ state, ask: type, text, isProtected })
11871187
const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny"
1188+
const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined
11881189

11891190
if (partial !== undefined) {
11901191
const lastMessage = this.clineMessages.at(-1)
@@ -1248,6 +1249,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12481249
lastMessage.isProtected = isProtected
12491250
if (isAutoAnswered) {
12501251
lastMessage.isAnswered = true
1252+
lastMessage.autoApprovalDecision = autoApprovalDecision
12511253
}
12521254
await this.saveClineMessages()
12531255
// Fire-and-forget: see updateClineMessage call above for the
@@ -1269,6 +1271,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12691271
text,
12701272
isProtected,
12711273
isAnswered: isAutoAnswered || undefined,
1274+
autoApprovalDecision,
12721275
})
12731276
}
12741277
}
@@ -1286,6 +1289,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12861289
text,
12871290
isProtected,
12881291
isAnswered: isAutoAnswered || undefined,
1292+
autoApprovalDecision,
12891293
})
12901294
}
12911295

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
Split,
7373
ArrowRight,
7474
Check,
75+
OctagonX,
7576
} from "lucide-react"
7677
import { cn } from "@/lib/utils"
7778
import { PathTooltip } from "../ui/PathTooltip"
@@ -290,6 +291,14 @@ export const ChatRowContent = ({
290291
case "mistake_limit_reached":
291292
return [null, null] // These will be handled by ErrorRow component
292293
case "command":
294+
if (message.autoApprovalDecision === "deny") {
295+
return [
296+
<OctagonX className="size-4 text-vscode-errorForeground" aria-label="Denied command icon" />,
297+
<span className="font-bold text-vscode-errorForeground">
298+
{t("chat:commandExecution.denied")}
299+
</span>,
300+
]
301+
}
293302
return [
294303
isCommandExecuting ? (
295304
<ProgressIndicator />
@@ -1603,6 +1612,7 @@ export const ChatRowContent = ({
16031612
text={message.text}
16041613
icon={icon}
16051614
title={title}
1615+
isDenied={message.autoApprovalDecision === "deny"}
16061616
/>
16071617
)
16081618
case "use_mcp_server":

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ interface CommandExecutionProps {
3636
text?: string
3737
icon?: JSX.Element | null
3838
title?: JSX.Element | null
39+
isDenied?: boolean
3940
}
4041

41-
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
42+
export const CommandExecution = ({ executionId, text, icon, title, isDenied = false }: CommandExecutionProps) => {
4243
const {
4344
terminalShellIntegrationDisabled = false,
45+
alwaysAllowCommandsExceptDenied = false,
4446
allowedCommands = [],
4547
deniedCommands = [],
4648
setAllowedCommands,
@@ -245,7 +247,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
245247
<CodeBlock source={command} language="shell" />
246248
<OutputContainer isExpanded={isExpanded} output={output} />
247249
</div>
248-
{command && command.trim() && (
250+
{command && command.trim() && !alwaysAllowCommandsExceptDenied && !isDenied && (
249251
<CommandPatternSelector
250252
patterns={commandPatterns}
251253
allowedCommands={allowedCommands}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from "react"
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
3+
4+
import { render, screen } from "@/utils/test-utils"
5+
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
6+
7+
import { ChatRowContent } from "../ChatRow"
8+
9+
vi.mock("react-i18next", () => ({
10+
useTranslation: () => ({ t: (key: string) => key }),
11+
Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => <>{children || i18nKey}</>,
12+
initReactI18next: { type: "3rdParty", init: () => {} },
13+
}))
14+
15+
vi.mock("../CommandExecution", () => ({
16+
CommandExecution: ({ text, title, isDenied }: { text?: string; title?: React.ReactNode; isDenied?: boolean }) => (
17+
<div data-testid="command-execution" data-denied={isDenied ? "true" : "false"}>
18+
{title}
19+
{text}
20+
</div>
21+
),
22+
}))
23+
24+
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
25+
VSCodeBadge: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
26+
}))
27+
28+
const renderCommand = (autoApprovalDecision?: "approve" | "deny") => {
29+
const queryClient = new QueryClient()
30+
31+
return render(
32+
<ExtensionStateContextProvider>
33+
<QueryClientProvider client={queryClient}>
34+
<ChatRowContent
35+
message={{
36+
type: "ask",
37+
ask: "command",
38+
ts: 1,
39+
text: "rm -rf /tmp/example",
40+
autoApprovalDecision,
41+
}}
42+
isExpanded={false}
43+
isLast={false}
44+
isStreaming={false}
45+
onToggleExpand={vi.fn()}
46+
onSuggestionClick={vi.fn()}
47+
onBatchFileResponse={vi.fn()}
48+
onFollowUpUnmount={vi.fn()}
49+
isFollowUpAnswered={false}
50+
/>
51+
</QueryClientProvider>
52+
</ExtensionStateContextProvider>,
53+
)
54+
}
55+
56+
describe("ChatRow - denied commands", () => {
57+
it("shows a denied status for a command rejected by auto-approval", () => {
58+
renderCommand("deny")
59+
60+
expect(screen.getByText("chat:commandExecution.denied")).toBeInTheDocument()
61+
expect(screen.getByTestId("command-execution")).toHaveAttribute("data-denied", "true")
62+
})
63+
64+
it("does not show a denied status for an approved command", () => {
65+
renderCommand("approve")
66+
67+
expect(screen.queryByText("chat:commandExecution.denied")).not.toBeInTheDocument()
68+
expect(screen.getByTestId("command-execution")).toHaveAttribute("data-denied", "false")
69+
})
70+
})

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,32 @@ describe("CommandExecution", () => {
110110
expect(selector).toHaveTextContent("npm install express")
111111
})
112112

113+
it("should hide the command pattern selector when all commands are auto-approved except denied commands", () => {
114+
const state = {
115+
...mockExtensionState,
116+
alwaysAllowCommandsExceptDenied: true,
117+
}
118+
119+
render(
120+
<ExtensionStateContext.Provider value={state as any}>
121+
<CommandExecution executionId="test-1" text="npm install express" />
122+
</ExtensionStateContext.Provider>,
123+
)
124+
125+
expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument()
126+
})
127+
128+
it("should hide the command pattern selector for a denied command", () => {
129+
render(
130+
<ExtensionStateWrapper>
131+
<CommandExecution executionId="test-1" text="rm -rf /tmp/example" isDenied />
132+
</ExtensionStateWrapper>,
133+
)
134+
135+
expect(screen.getByTestId("code-block")).toHaveTextContent("rm -rf /tmp/example")
136+
expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument()
137+
})
138+
113139
it("should handle allow command change", () => {
114140
render(
115141
<ExtensionStateWrapper>

webview-ui/src/i18n/locales/ca/chat.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/de/chat.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/en/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@
276276
"exitStatus": "Exited with status {{exitCode}}",
277277
"malformedCommand": "Malformed command: shell syntax error",
278278
"manageCommands": "Auto-approved commands",
279+
"denied": "Command denied",
279280
"addToAllowed": "Add to allowed list",
280281
"removeFromAllowed": "Remove from allowed list",
281282
"addToDenied": "Add to denied list",

webview-ui/src/i18n/locales/es/chat.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)