diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a31067c5e..64bedfff1 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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" @@ -680,6 +681,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + 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) @@ -1565,7 +1588,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) : (
- {primaryButtonText && !isStreaming && ( - + {primaryButtonText && !isStreaming && ( + - handlePrimaryButtonClick(inputValue, selectedImages)}> - {primaryButtonText} - - - )} - {(secondaryButtonText || isStreaming) && ( - - handleSecondaryButtonClick(inputValue, selectedImages)}> - {isStreaming ? t("chat:cancel.title") : secondaryButtonText} - - - )} + t("chat:proceedAnyways.title") + ? t("chat:proceedAnyways.tooltip") + : primaryButtonText === + t("chat:proceedWhileRunning.title") + ? t("chat:proceedWhileRunning.tooltip") + : undefined + }> + handlePrimaryButtonClick(inputValue, selectedImages)}> + {primaryButtonText} + + + )} + {(secondaryButtonText || isStreaming) && ( + + handleSecondaryButtonClick(inputValue, selectedImages)}> + {isStreaming ? t("chat:cancel.title") : secondaryButtonText} + + + )} +
+ + )} + {/* Auto-approve section - below buttons, full width with horizontal layout */} + {clineAsk === "command" && enableButtons && !isStreaming && lastMessage?.text && ( +
+
+ +
+
+ {t("chat:alwaysAllowCommand.pattern")} +
+ + {formatCommandPatternForDisplay(extractCommandPattern(lastMessage.text.trim()))} + +
+ +
)} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 5a21b6c14..012a026d3 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -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", () => { diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index ef2c5aca3..4bc351a8f 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -73,6 +73,11 @@ "title": "Run Command", "tooltip": "Execute this command" }, + "alwaysAllowCommand": { + "title": "Always allow", + "tooltip": "Add this command pattern to auto-approved commands", + "pattern": "Auto-approve pattern:" + }, "proceedWhileRunning": { "title": "Proceed While Running", "tooltip": "Continue despite warnings" diff --git a/webview-ui/src/utils/__tests__/commandPattern.spec.ts b/webview-ui/src/utils/__tests__/commandPattern.spec.ts new file mode 100644 index 000000000..47f93ce2b --- /dev/null +++ b/webview-ui/src/utils/__tests__/commandPattern.spec.ts @@ -0,0 +1,35 @@ +import { extractCommandPattern, formatCommandPatternForDisplay } from "../commandPattern" + +describe("commandPattern", () => { + describe("extractCommandPattern", () => { + it("should extract base command patterns correctly", () => { + expect(extractCommandPattern("wc -l foo.txt")).toBe("wc -l") + expect(extractCommandPattern("cd /path/to/project && npm install")).toBe("cd && npm install") + expect(extractCommandPattern("git status")).toBe("git status") + expect(extractCommandPattern("ls -la /some/path")).toBe("ls -la") + expect(extractCommandPattern("npm test -- --coverage")).toBe("npm test") + }) + + it("should handle complex chained commands", () => { + expect(extractCommandPattern("cd /project && npm install && npm test")).toBe( + "cd && npm install && npm test", + ) + expect(extractCommandPattern('git add . && git commit -m "message" && git push')).toBe( + "git add && git commit -m && git push", + ) + }) + + it("should preserve important flags but remove file paths", () => { + expect(extractCommandPattern("docker run -it --rm ubuntu:latest")).toBe("docker run -it --rm") + expect(extractCommandPattern('find /path -name "*.js" -type f')).toBe("find -name -type f") + }) + }) + + describe("formatCommandPatternForDisplay", () => { + it("should format patterns for display correctly", () => { + expect(formatCommandPatternForDisplay("wc -l")).toBe('"wc -l"') + expect(formatCommandPatternForDisplay("cd && npm install")).toBe('"cd && npm install"') + expect(formatCommandPatternForDisplay("git status")).toBe('"git status"') + }) + }) +}) diff --git a/webview-ui/src/utils/commandPattern.ts b/webview-ui/src/utils/commandPattern.ts new file mode 100644 index 000000000..b7c12c8b3 --- /dev/null +++ b/webview-ui/src/utils/commandPattern.ts @@ -0,0 +1,142 @@ +/** + * Extracts a base command pattern from a full command string. + * This removes specific file paths, arguments, and other variable parts + * to create a more general pattern that can be auto-approved. + * + * Examples: + * - "wc -l foo.txt" -> "wc -l" + * - "git add src/file.js" -> "git add" + * - "npm install --save lodash" -> "npm install" + * - "ls -la /some/path" -> "ls -la" + */ +export function extractCommandPattern(command: string): string { + if (!command || typeof command !== "string") { + return command + } + + const trimmed = command.trim() + if (!trimmed) { + return trimmed + } + + // Split by pipes, semicolons, and && to handle chained commands + const commandParts = trimmed.split(/\s*(?:\||\|\||;|&&)\s*/) + + const processedParts = commandParts.map((part) => { + const tokens = part.trim().split(/\s+/) + if (tokens.length === 0) return part + + const baseCommand = tokens[0] + const args: string[] = [] + + // Handle npm/yarn/pnpm special case: stop at "--" separator + let stopIndex = tokens.length + if (baseCommand === "npm" || baseCommand === "yarn" || baseCommand === "pnpm") { + const separatorIndex = tokens.indexOf("--") + if (separatorIndex !== -1) { + stopIndex = separatorIndex + } + } + + // Process arguments, keeping flags but removing file paths and specific values + for (let i = 1; i < stopIndex; i++) { + const token = tokens[i] + const nextToken = i + 1 < stopIndex ? tokens[i + 1] : null + + // Keep short flags (-l, -a, etc.) + if (/^-[a-zA-Z]$/.test(token)) { + args.push(token) + continue + } + + // Keep multi-character flags like -type, -name, etc. + if (/^-[a-zA-Z]+$/.test(token)) { + args.push(token) + + // For certain flags, keep their single-letter values (like -type f) + if ( + nextToken && + /^[a-zA-Z]$/.test(nextToken) && + (token === "-type" || token === "-o" || token === "-e") + ) { + args.push(nextToken) + i++ // Skip the next token since we processed it + } + continue + } + + // Keep long flags (--save, --verbose, etc.) but not their values + if (/^--[a-zA-Z][a-zA-Z0-9-]*$/.test(token)) { + args.push(token) + continue + } + + // Keep combined short flags (-la, -rf, etc.) + if (/^-[a-zA-Z]{2,}$/.test(token)) { + args.push(token) + continue + } + + // Skip file paths, URLs, and other specific arguments + // This includes: + // - Paths (./file, /path/to/file, ../file) + // - Files with extensions (.txt, .js, etc.) + // - URLs (http://, https://) + // - Numbers and specific values + // - Quoted strings (likely file patterns) + if ( + /^\.{0,2}\//.test(token) || // relative paths + /^\//.test(token) || // absolute paths + /\.[a-zA-Z0-9]+$/.test(token) || // files with extensions + /^https?:\/\//.test(token) || // URLs + /^\d+$/.test(token) || // pure numbers + /^[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+/.test(token) || // files or domains + /^["'].*["']$/.test(token) // quoted strings (likely patterns) + ) { + // Skip this argument + continue + } + + // For common commands, keep certain patterns + if (baseCommand === "git") { + // Keep git subcommands but not file arguments + if (i === 1 && /^[a-zA-Z]+$/.test(token)) { + args.push(token) + } + } else if (baseCommand === "npm" || baseCommand === "yarn" || baseCommand === "pnpm") { + // Keep npm/yarn/pnpm subcommands but not package names + if (i === 1 && /^[a-zA-Z]+$/.test(token)) { + args.push(token) + } + } else if (baseCommand === "docker") { + // Keep docker subcommands + if (i === 1 && /^[a-zA-Z]+$/.test(token)) { + args.push(token) + } + } + // Note: Multi-character flags like -type, -name are handled above in the general flag processing + } + + return args.length > 0 ? `${baseCommand} ${args.join(" ")}` : baseCommand + }) + + return processedParts.join(" && ") +} + +/** + * Formats a command pattern for display to the user. + * This makes it clear what pattern will be auto-approved. + */ +export function formatCommandPatternForDisplay(pattern: string): string { + if (!pattern || pattern.trim() === "") { + return pattern + } + + // If the pattern is the same as a full command, show it as-is + // Otherwise, indicate it's a pattern + if (pattern.includes("&&") || pattern.includes("|")) { + return `"${pattern}"` + } + + return `"${pattern}"` +}