Skip to content

add option to auto approve command from now on #940

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 104 additions & 53 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
import { StandardTooltip } from "@src/components/ui"
import { extractCommandPattern, formatCommandPatternForDisplay } from "@src/utils/commandPattern"

import { useTaskSearch } from "../history/useTaskSearch"
import HistoryPreview from "../history/HistoryPreview"
Expand Down Expand Up @@ -680,6 +681,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[clineAsk, startNewTask, isStreaming],
)

const handleAlwaysAllowCommand = useCallback(() => {
if (lastMessage?.ask === "command" && lastMessage.text) {
const command = lastMessage.text.trim()
if (command) {
// Extract the command pattern (less specific version)
const commandPattern = extractCommandPattern(command)

// Add command pattern to allowed commands list
const updatedCommands = [...(allowedCommands || []), commandPattern]
vscode.postMessage({
type: "allowedCommands",
commands: updatedCommands,
})
// Approve the current command
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
}
}
}, [lastMessage, allowedCommands])

const handleTaskCloseButtonClick = useCallback(() => startNewTask(), [startNewTask])

const { info: model } = useSelectedModel(apiConfiguration)
Expand Down Expand Up @@ -1565,7 +1588,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
</div>
) : (
<div
className={`flex ${
className={`${
primaryButtonText || secondaryButtonText || isStreaming ? "px-[15px] pt-[10px]" : "p-0"
} ${
primaryButtonText || secondaryButtonText || isStreaming
Expand All @@ -1574,59 +1597,87 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
: "opacity-50"
: "opacity-0"
}`}>
{primaryButtonText && !isStreaming && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText === t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
{/* Main action buttons */}
<div className="flex gap-[6px]">
{primaryButtonText && !isStreaming && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
}>
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
{(secondaryButtonText || isStreaming) && (
<StandardTooltip
content={
isStreaming
? t("chat:cancel.tooltip")
: secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: undefined
}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
}>
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
className="flex-1"
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
{(secondaryButtonText || isStreaming) && (
<StandardTooltip
content={
isStreaming
? t("chat:cancel.tooltip")
: secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: undefined
}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
className="flex-1"
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
</div>
</div>
)}
{/* Auto-approve section - below buttons, full width with horizontal layout */}
{clineAsk === "command" && enableButtons && !isStreaming && lastMessage?.text && (
<div className="px-[15px] pb-[10px]">
<div className="bg-vscode-input-background border border-vscode-input-border rounded-md px-3 py-2 w-full flex items-center gap-3">
<span className="codicon codicon-terminal text-vscode-descriptionForeground text-sm flex-shrink-0"></span>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="text-xs text-vscode-descriptionForeground font-medium flex-shrink-0">
{t("chat:alwaysAllowCommand.pattern")}
</div>
<code className="text-xs text-vscode-editor-foreground bg-vscode-textCodeBlock-background px-2 py-1 rounded font-mono truncate">
{formatCommandPatternForDisplay(extractCommandPattern(lastMessage.text.trim()))}
</code>
</div>
<button
className="flex items-center gap-1.5 text-vscode-textLink hover:text-vscode-textLinkActiveForeground text-sm font-medium cursor-pointer bg-transparent border-none transition-colors duration-150 hover:bg-vscode-toolbar-hoverBackground px-2 py-1 rounded flex-shrink-0"
onClick={handleAlwaysAllowCommand}
disabled={!enableButtons}
title={t("chat:alwaysAllowCommand.tooltip")}>
<span className="codicon codicon-check text-xs"></span>
{t("chat:alwaysAllowCommand.title")}
</button>
</div>
</div>
)}
</>
Expand Down
197 changes: 197 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,203 @@ describe("ChatView - Auto Approval Tests", () => {
}
})
})

describe("Always Allow Command Tests", () => {
it("adds command pattern to allowed list when 'Always allow' is clicked", async () => {
const { getByText } = renderChatView()

// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: false,
allowedCommands: [],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})

// Then send a command ask message with specific file
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: false,
allowedCommands: [],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "wc -l foo.txt",
partial: false,
},
],
})

// Wait for the "Always allow" link to appear (using translation key)
await waitFor(() => {
expect(getByText("chat:alwaysAllowCommand.title")).toBeInTheDocument()
// Should show the pattern placeholder (translation key)
expect(getByText("chat:alwaysAllowCommand.pattern")).toBeInTheDocument()
})

// Click the "Always allow" link
const alwaysAllowLink = getByText("chat:alwaysAllowCommand.title")
alwaysAllowLink.click()

// Verify that the allowedCommands message was sent with pattern (not full command)
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "allowedCommands",
commands: ["wc -l"], // Pattern, not full command
})
})

// Verify that the command was approved
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})

it("handles complex commands with chained operations", async () => {
const { getByText } = renderChatView()

// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: false,
allowedCommands: [],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})

// Then send a command ask message with a complex chained command
const commandText = "cd /path/to/project && npm install"

mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: false,
allowedCommands: [],
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: commandText,
partial: false,
},
],
})

// Wait for the "Always allow" link to appear (using translation key)
await waitFor(() => {
expect(getByText("chat:alwaysAllowCommand.title")).toBeInTheDocument()
// Should show the pattern placeholder (translation key)
expect(getByText("chat:alwaysAllowCommand.pattern")).toBeInTheDocument()
})

// Click the "Always allow" link
const alwaysAllowLink = getByText("chat:alwaysAllowCommand.title")
alwaysAllowLink.click()

// Verify that the allowedCommands message was sent with the simplified pattern
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "allowedCommands",
commands: ["cd && npm install"],
})
})
})

it("only shows 'Always allow' link for command asks", async () => {
const { queryByText } = renderChatView()

// Test with non-command ask types
const nonCommandAskTypes = [
{ ask: "tool", text: JSON.stringify({ tool: "readFile", path: "test.txt" }) },
{ ask: "browser_action_launch", text: JSON.stringify({ action: "launch", url: "http://example.com" }) },
{ ask: "completion_result", text: "Task completed successfully" },
]

for (const { ask: askType, text } of nonCommandAskTypes) {
vi.clearAllMocks()

mockPostMessage({
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: askType,
ts: Date.now(),
text: text,
partial: false,
},
],
})

// Verify "Always allow" link is not present for non-command asks (using translation key)
await waitFor(() => {
expect(queryByText("chat:alwaysAllowCommand.title")).not.toBeInTheDocument()
})
}

// Test with command ask - should show both link and pattern
vi.clearAllMocks()
mockPostMessage({
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "ls -la",
partial: false,
},
],
})

await waitFor(() => {
expect(queryByText("chat:alwaysAllowCommand.title")).toBeInTheDocument()
expect(queryByText("chat:alwaysAllowCommand.pattern")).toBeInTheDocument()
})
})
})
})

describe("ChatView - Sound Playing Tests", () => {
Expand Down
Loading
Loading