From af8eae5ea1bdba57d88e22a27405b056abbb6870 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Tue, 1 Jul 2025 16:11:37 -0600 Subject: [PATCH 1/8] feat: add 'Add and Run' button for allowed commands (#5290) - Added new 'Add and Run' button in ChatView that appears when hovering over code blocks containing allowed commands - Button extracts commands from code blocks and adds them to allowed commands list - Automatically switches to Settings view after adding commands - Added comprehensive test coverage for the new functionality - Updated all locale files with new translation keys - Added utility function to extract command patterns from code blocks This implementation allows users to quickly add commands from AI responses to their allowed commands list with a single click. --- .../presentAssistantMessage.ts | 35 ++ .../__tests__/webviewMessageHandler.spec.ts | 366 ++++----------- src/core/webview/webviewMessageHandler.ts | 3 + src/shared/ExtensionMessage.ts | 8 +- src/shared/WebviewMessage.ts | 8 +- webview-ui/src/components/chat/ChatView.tsx | 252 ++++++++--- .../__tests__/ChatView.addAndRun.spec.tsx | 424 ++++++++++++++++++ .../src/components/settings/SettingsView.tsx | 11 + .../SettingsView.allowedCommands.spec.tsx | 410 +++++++++++++++++ .../settings/__tests__/SettingsView.spec.tsx | 103 ++++- webview-ui/src/i18n/locales/ca/chat.json | 4 + webview-ui/src/i18n/locales/de/chat.json | 4 + webview-ui/src/i18n/locales/en/chat.json | 4 + webview-ui/src/i18n/locales/es/chat.json | 4 + webview-ui/src/i18n/locales/fr/chat.json | 4 + webview-ui/src/i18n/locales/hi/chat.json | 4 + webview-ui/src/i18n/locales/id/chat.json | 4 + webview-ui/src/i18n/locales/it/chat.json | 4 + webview-ui/src/i18n/locales/ja/chat.json | 4 + webview-ui/src/i18n/locales/ko/chat.json | 4 + webview-ui/src/i18n/locales/nl/chat.json | 4 + webview-ui/src/i18n/locales/pl/chat.json | 4 + webview-ui/src/i18n/locales/pt-BR/chat.json | 4 + webview-ui/src/i18n/locales/ru/chat.json | 4 + webview-ui/src/i18n/locales/tr/chat.json | 4 + webview-ui/src/i18n/locales/vi/chat.json | 4 + webview-ui/src/i18n/locales/zh-CN/chat.json | 4 + webview-ui/src/i18n/locales/zh-TW/chat.json | 4 + .../__tests__/extract-command-pattern.spec.ts | 201 +++++++++ .../src/utils/extract-command-pattern.ts | 232 ++++++++++ 30 files changed, 1768 insertions(+), 357 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/ChatView.addAndRun.spec.tsx create mode 100644 webview-ui/src/components/settings/__tests__/SettingsView.allowedCommands.spec.tsx create mode 100644 webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts create mode 100644 webview-ui/src/utils/extract-command-pattern.ts diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index ee3fa148b4..3b11a60baa 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -274,6 +274,41 @@ export async function presentAssistantMessage(cline: Task) { isProtected || false, ) + // Handle "Add & Run" button for command approval + if (response === "addAndRunButtonClicked" && type === "command") { + // The text field contains the extracted command pattern when "Add & Run" is clicked + if (text) { + // Add the command pattern to allowed commands + const provider = cline.providerRef.deref() + if (provider) { + // Get current allowed commands + const currentState = await provider.getState() + const currentAllowedCommands = currentState.allowedCommands || [] + + // Add the new pattern if it's not already in the list + if (!currentAllowedCommands.includes(text)) { + const updatedCommands = [...currentAllowedCommands, text] + + // Update global state using contextProxy + await provider.contextProxy.setValue("allowedCommands", updatedCommands) + + // Also update workspace settings + const vscode = await import("vscode") + const { Package } = await import("../../shared/package") + await vscode.workspace + .getConfiguration(Package.name) + .update("allowedCommands", updatedCommands, vscode.ConfigurationTarget.Global) + + // Post state update to webview + await provider.postStateToWebview() + } + } + } + + // Return true to indicate approval and continue with command execution + return true + } + if (response !== "yesButtonClicked") { // Handle both messageResponse and noButtonClicked with text. if (text) { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 2f356aef55..d8dc1801e1 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1,14 +1,27 @@ -import type { Mock } from "vitest" - -// Mock dependencies - must come before imports -vi.mock("../../../api/providers/fetchers/modelCache") - +import { describe, it, expect, vi, beforeEach } from "vitest" import { webviewMessageHandler } from "../webviewMessageHandler" -import type { ClineProvider } from "../ClineProvider" -import { getModels } from "../../../api/providers/fetchers/modelCache" -import type { ModelRecord } from "../../../shared/api" +import { ClineProvider } from "../ClineProvider" +import * as vscode from "vscode" +import { Package } from "../../../shared/package" -const mockGetModels = getModels as Mock +// Mock vscode module +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + update: vi.fn().mockResolvedValue(undefined), + }), + }, + ConfigurationTarget: { + Global: 1, + }, +})) + +// Mock Package +vi.mock("../../../shared/package", () => ({ + Package: { + name: "roo-cline", + }, +})) // Mock ClineProvider const mockClineProvider = { @@ -35,16 +48,6 @@ const mockClineProvider = { import { t } from "../../../i18n" -vi.mock("vscode", () => ({ - window: { - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - }, - workspace: { - workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], - }, -})) - vi.mock("../../../i18n", () => ({ t: vi.fn((key: string, args?: Record) => { // For the delete confirmation with rules, we need to return the interpolated string @@ -77,7 +80,6 @@ vi.mock("fs/promises", () => { } }) -import * as vscode from "vscode" import * as fs from "fs/promises" import * as os from "os" import * as path from "path" @@ -90,282 +92,94 @@ vi.mock("../../../utils/fs") vi.mock("../../../utils/path") vi.mock("../../../utils/globalContext") -describe("webviewMessageHandler - requestRouterModels", () => { +describe("webviewMessageHandler", () => { + let mockProvider: any + let mockContextProxy: any + beforeEach(() => { + // Reset all mocks vi.clearAllMocks() - mockClineProvider.getState = vi.fn().mockResolvedValue({ - apiConfiguration: { - openRouterApiKey: "openrouter-key", - requestyApiKey: "requesty-key", - glamaApiKey: "glama-key", - unboundApiKey: "unbound-key", - litellmApiKey: "litellm-key", - litellmBaseUrl: "http://localhost:4000", - }, - }) - }) - it("successfully fetches models from all providers", async () => { - const mockModels: ModelRecord = { - "model-1": { - maxTokens: 4096, - contextWindow: 8192, - supportsPromptCache: false, - description: "Test model 1", - }, - "model-2": { - maxTokens: 8192, - contextWindow: 16384, - supportsPromptCache: false, - description: "Test model 2", - }, + // Create mock context proxy + mockContextProxy = { + getValue: vi.fn(), + setValue: vi.fn().mockResolvedValue(undefined), } - mockGetModels.mockResolvedValue(mockModels) - - await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", - }) - - // Verify getModels was called for each provider - expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "glama" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" }) - expect(mockGetModels).toHaveBeenCalledWith({ - provider: "litellm", - apiKey: "litellm-key", - baseUrl: "http://localhost:4000", - }) - - // Verify response was sent - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "routerModels", - routerModels: { - openrouter: mockModels, - requesty: mockModels, - glama: mockModels, - unbound: mockModels, - litellm: mockModels, - ollama: {}, - lmstudio: {}, - }, - }) - }) - - it("handles LiteLLM models with values from message when config is missing", async () => { - mockClineProvider.getState = vi.fn().mockResolvedValue({ - apiConfiguration: { - openRouterApiKey: "openrouter-key", - requestyApiKey: "requesty-key", - glamaApiKey: "glama-key", - unboundApiKey: "unbound-key", - // Missing litellm config - }, - }) - - const mockModels: ModelRecord = { - "model-1": { - maxTokens: 4096, - contextWindow: 8192, - supportsPromptCache: false, - description: "Test model 1", - }, + // Create mock provider + mockProvider = { + contextProxy: mockContextProxy, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), } - - mockGetModels.mockResolvedValue(mockModels) - - await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", - values: { - litellmApiKey: "message-litellm-key", - litellmBaseUrl: "http://message-url:4000", - }, - }) - - // Verify LiteLLM was called with values from message - expect(mockGetModels).toHaveBeenCalledWith({ - provider: "litellm", - apiKey: "message-litellm-key", - baseUrl: "http://message-url:4000", - }) }) - it("skips LiteLLM when both config and message values are missing", async () => { - mockClineProvider.getState = vi.fn().mockResolvedValue({ - apiConfiguration: { - openRouterApiKey: "openrouter-key", - requestyApiKey: "requesty-key", - glamaApiKey: "glama-key", - unboundApiKey: "unbound-key", - // Missing litellm config - }, + describe("allowedCommands", () => { + it("should update global state, workspace settings, and call postStateToWebview", async () => { + const testCommands = ["npm test", "npm run build", "git status"] + + await webviewMessageHandler(mockProvider, { + type: "allowedCommands", + commands: testCommands, + }) + + // Verify global state was updated + expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", testCommands) + + // Verify workspace settings were updated + const mockConfig = vscode.workspace.getConfiguration() + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name) + expect(mockConfig.update).toHaveBeenCalledWith( + "allowedCommands", + testCommands, + vscode.ConfigurationTarget.Global, + ) + + // Verify postStateToWebview was called + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) - const mockModels: ModelRecord = { - "model-1": { - maxTokens: 4096, - contextWindow: 8192, - supportsPromptCache: false, - description: "Test model 1", - }, - } + it("should filter out invalid commands", async () => { + const testCommands = ["npm test", "", " ", null, undefined, "git status", 123] - mockGetModels.mockResolvedValue(mockModels) + await webviewMessageHandler(mockProvider, { + type: "allowedCommands", + commands: testCommands as any, + }) - await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", - // No values provided + // Should only include valid string commands + const expectedCommands = ["npm test", "git status"] + expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", expectedCommands) }) - // Verify LiteLLM was NOT called - expect(mockGetModels).not.toHaveBeenCalledWith( - expect.objectContaining({ - provider: "litellm", - }), - ) + it("should handle empty commands array", async () => { + await webviewMessageHandler(mockProvider, { + type: "allowedCommands", + commands: [], + }) - // Verify response includes empty object for LiteLLM - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "routerModels", - routerModels: { - openrouter: mockModels, - requesty: mockModels, - glama: mockModels, - unbound: mockModels, - litellm: {}, - ollama: {}, - lmstudio: {}, - }, - }) - }) - - it("handles individual provider failures gracefully", async () => { - const mockModels: ModelRecord = { - "model-1": { - maxTokens: 4096, - contextWindow: 8192, - supportsPromptCache: false, - description: "Test model 1", - }, - } - - // Mock some providers to succeed and others to fail - mockGetModels - .mockResolvedValueOnce(mockModels) // openrouter - .mockRejectedValueOnce(new Error("Requesty API error")) // requesty - .mockResolvedValueOnce(mockModels) // glama - .mockRejectedValueOnce(new Error("Unbound API error")) // unbound - .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm - - await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", + expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", []) + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) - // Verify successful providers are included - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "routerModels", - routerModels: { - openrouter: mockModels, - requesty: {}, - glama: mockModels, - unbound: {}, - litellm: {}, - ollama: {}, - lmstudio: {}, - }, + it("should handle undefined commands", async () => { + await webviewMessageHandler(mockProvider, { + type: "allowedCommands", + commands: undefined, + }) + + expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", []) + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) - // Verify error messages were sent for failed providers - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Requesty API error", - values: { provider: "requesty" }, - }) + it("should handle non-array commands", async () => { + await webviewMessageHandler(mockProvider, { + type: "allowedCommands", + commands: "not an array" as any, + }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "LiteLLM connection failed", - values: { provider: "litellm" }, - }) - }) - - it("handles Error objects and string errors correctly", async () => { - // Mock providers to fail with different error types - mockGetModels - .mockRejectedValueOnce(new Error("Structured error message")) // openrouter - .mockRejectedValueOnce(new Error("Requesty API error")) // requesty - .mockRejectedValueOnce(new Error("Glama API error")) // glama - .mockRejectedValueOnce(new Error("Unbound API error")) // unbound - .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm - - await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", - }) - - // Verify error handling for different error types - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Structured error message", - values: { provider: "openrouter" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Requesty API error", - values: { provider: "requesty" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Glama API error", - values: { provider: "glama" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "LiteLLM connection failed", - values: { provider: "litellm" }, - }) - }) - - it("prefers config values over message values for LiteLLM", async () => { - const mockModels: ModelRecord = {} - mockGetModels.mockResolvedValue(mockModels) - - await webviewMessageHandler(mockClineProvider, { - type: "requestRouterModels", - values: { - litellmApiKey: "message-key", - litellmBaseUrl: "http://message-url", - }, - }) - - // Verify config values are used over message values - expect(mockGetModels).toHaveBeenCalledWith({ - provider: "litellm", - apiKey: "litellm-key", // From config - baseUrl: "http://localhost:4000", // From config + expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", []) + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b8e21e6040..af6b04f9e6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -582,6 +582,9 @@ export const webviewMessageHandler = async ( .getConfiguration(Package.name) .update("allowedCommands", validCommands, vscode.ConfigurationTarget.Global) + // Post state update to webview to reflect changes in UI + await provider.postStateToWebview() + break } case "openCustomModesSettings": { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 953c0c1070..e85ca99d22 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -118,7 +118,13 @@ export interface ExtensionMessage { | "didBecomeVisible" | "focusInput" | "switchTab" - invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" + invoke?: + | "newChat" + | "sendMessage" + | "primaryButtonClick" + | "secondaryButtonClick" + | "tertiaryButtonClick" + | "setChatBoxMessage" state?: ExtensionState images?: string[] filePaths?: string[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 89fa21b7b7..7de420e695 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -12,7 +12,12 @@ import { marketplaceItemSchema } from "@roo-code/types" import { Mode } from "./modes" -export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse" +export type ClineAskResponse = + | "yesButtonClicked" + | "noButtonClicked" + | "addAndRunButtonClicked" + | "messageResponse" + | "objectResponse" export type PromptMode = Mode | "enhance" @@ -232,6 +237,7 @@ export interface WebviewMessage { visibility?: ShareVisibility // For share visibility hasContent?: boolean // For checkRulesDirectoryResult checkOnly?: boolean // For deleteCustomMode check + commandPattern?: string // For "Add & Run" button - the extracted command pattern to whitelist codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 84bb7b89a2..4d4f484c97 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -25,6 +25,7 @@ import { ProfileValidator } from "@roo/ProfileValidator" import { vscode } from "@src/utils/vscode" import { validateCommand } from "@src/utils/command-validation" +import { extractCommandPattern, getPatternDescription } from "@src/utils/extract-command-pattern" import { buildDocLink } from "@src/utils/docLinks" import { useAppTranslation } from "@src/i18n/TranslationContext" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -149,6 +150,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [primaryButtonText, setPrimaryButtonText] = useState(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) + const [tertiaryButtonText, setTertiaryButtonText] = useState(undefined) const [didClickCancel, setDidClickCancel] = useState(false) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) @@ -247,6 +249,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + switch (clineAsk) { + case "command": + // For the "Add & Run" button on command approval + // Extract the command pattern for whitelisting + const commandMessage = findLast( + messagesRef.current, + (msg) => msg.type === "ask" && msg.ask === "command", + ) + const commandText = commandMessage?.text || "" + const pattern = extractCommandPattern(commandText) + + // Send the pattern in the text field as expected by the backend + vscode.postMessage({ + type: "askResponse", + askResponse: "addAndRunButtonClicked", + text: pattern, // Send pattern in text field + images: images || [], + }) + // Clear input state after sending + setInputValue("") + setSelectedImages([]) + break + } + setSendingDisabled(true) + setClineAsk(undefined) + setEnableButtons(false) + }, + [clineAsk], // messagesRef is stable + ) + const handleSecondaryButtonClick = useCallback( (text?: string, images?: string[]) => { const trimmedInput = text?.trim() @@ -613,7 +663,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { + vscode.postMessage({ + type: "askResponse", + askResponse: "noButtonClicked", + text: trimmedInput, + images: images, + }) + } else { + vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" }) + } + setInputValue("") + setSelectedImages([]) + } + break case "command_output": vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" }) break @@ -641,7 +713,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction startNewTask(), [startNewTask]) @@ -695,6 +767,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction {areButtonsVisible && (
{showScrollToBottom ? ( @@ -1613,59 +1693,111 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) : ( <> - {primaryButtonText && !isStreaming && ( - - handlePrimaryButtonClick(inputValue, selectedImages)}> - {primaryButtonText} - - - )} - {(secondaryButtonText || isStreaming) && ( - - handleSecondaryButtonClick(inputValue, selectedImages)}> - {isStreaming ? t("chat:cancel.title") : secondaryButtonText} - - + {/* Three button layout for command approval */} + {tertiaryButtonText && clineAsk === "command" && !isStreaming ? ( +
+ {/* Top row: Run and Add & Run */} +
+ + handlePrimaryButtonClick(inputValue, selectedImages)}> + {primaryButtonText} + + + { + const commandMessage = findLast( + messagesRef.current, + (msg) => msg.type === "ask" && msg.ask === "command", + ) + const commandText = commandMessage?.text || "" + const pattern = extractCommandPattern(commandText) + const description = getPatternDescription(pattern) + return pattern + ? `${t("chat:addAndRunCommand.tooltip")} Will whitelist: "${pattern}" (${description})` + : t("chat:addAndRunCommand.tooltip") + })()}> + handleTertiaryButtonClick(inputValue, selectedImages)}> + {secondaryButtonText} + + +
+ {/* Bottom row: Reject */} + + handleSecondaryButtonClick(inputValue, selectedImages)}> + {tertiaryButtonText} + + +
+ ) : ( + /* Standard two button layout */ +
+ {primaryButtonText && !isStreaming && ( + + handlePrimaryButtonClick(inputValue, selectedImages)}> + {primaryButtonText} + + + )} + {(secondaryButtonText || isStreaming) && ( + + handleSecondaryButtonClick(inputValue, selectedImages)}> + {isStreaming ? t("chat:cancel.title") : secondaryButtonText} + + + )} +
)} )} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.addAndRun.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.addAndRun.spec.tsx new file mode 100644 index 0000000000..fe4a6a3d49 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.addAndRun.spec.tsx @@ -0,0 +1,424 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { vi } from "vitest" +import ChatView from "../ChatView" +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { TranslationProvider } from "@src/i18n/TranslationContext" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { TooltipProvider } from "@/components/ui/tooltip" +import { vscode } from "@src/utils/vscode" + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock extract-command-pattern +vi.mock("@src/utils/extract-command-pattern", () => ({ + extractCommandPattern: vi.fn((command: string) => { + // Simple mock implementation + if (command === "npm test") return "npm test" + if (command === "cd /path/to/project && npm run build:prod --verbose") return "cd * && npm run *" + return command + }), + getPatternDescription: vi.fn(() => "matches similar commands"), +})) + +// Mock use-sound +vi.mock("use-sound", () => ({ + default: () => [vi.fn()], +})) + +// Mock react-use +vi.mock("react-use", () => ({ + useEvent: vi.fn(), + useMount: vi.fn(), + useDeepCompareEffect: (fn: () => void, deps: any[]) => { + // Use regular useEffect for testing + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require("react") + // eslint-disable-next-line react-hooks/exhaustive-deps + React.useEffect(fn, deps) + }, + useWindowSize: () => ({ width: 1024, height: 768 }), +})) + +// Mock debounce +vi.mock("debounce", () => ({ + default: (fn: any) => fn, +})) + +// Mock react-virtuoso +vi.mock("react-virtuoso", () => ({ + Virtuoso: ({ data, itemContent }: any) => ( +
+ {data?.map((item: any, index: number) =>
{itemContent(index, item)}
)} +
+ ), +})) + +// Mock all problematic dependencies +vi.mock("rehype-highlight", () => ({ + default: () => () => {}, +})) + +vi.mock("hast-util-to-text", () => ({ + default: () => "", +})) + +// Mock components that use ESM dependencies +vi.mock("../BrowserSessionRow", () => ({ + default: function MockBrowserSessionRow({ messages }: { messages: any[] }) { + return
{JSON.stringify(messages)}
+ }, +})) + +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: any }) { + // Render the buttons if this is a command ask message + if (message.type === "ask" && message.ask === "command") { + return ( +
+
Command: {message.text}
+
+ ) + } + return
{JSON.stringify(message)}
+ }, +})) + +vi.mock("../TaskHeader", () => ({ + default: function MockTaskHeader({ task }: { task: any }) { + return
Task: {task.text}
+ }, +})) + +vi.mock("../AutoApproveMenu", () => ({ + default: () => null, +})) + +vi.mock("@src/components/common/CodeBlock", () => ({ + default: () => null, + CODE_BLOCK_BG_COLOR: "rgb(30, 30, 30)", +})) + +vi.mock("@src/components/common/CodeAccordian", () => ({ + default: () => null, +})) + +vi.mock("@src/components/chat/ContextMenu", () => ({ + default: () => null, +})) + +// Mock i18n setup +vi.mock("@src/i18n/setup", () => { + const mockT = (key: string, _options?: any) => { + const translations: Record = { + "chat:runCommand.title": "Run Command", + "chat:addAndRunCommand.title": "Add & Run", + "chat:reject.title": "Reject", + "chat:typeMessage": "Type a message...", + "chat:typeTask": "Type a task...", + } + return translations[key] || key + } + + const mockI18n = { + language: "en", + changeLanguage: vi.fn(), + t: mockT, + use: vi.fn().mockReturnThis(), + init: vi.fn().mockReturnThis(), + } + + return { + default: mockI18n, + loadTranslations: vi.fn(), + } +}) + +// Mock react-i18next +vi.mock("react-i18next", () => { + const mockT = (key: string, _options?: any) => { + const translations: Record = { + "chat:runCommand.title": "Run Command", + "chat:addAndRunCommand.title": "Add & Run", + "chat:reject.title": "Reject", + "chat:typeMessage": "Type a message...", + "chat:typeTask": "Type a task...", + } + return translations[key] || key + } + + const mockI18n = { + language: "en", + changeLanguage: vi.fn(), + t: mockT, + use: vi.fn().mockReturnThis(), + init: vi.fn().mockReturnThis(), + } + + return { + useTranslation: () => ({ + t: mockT, + i18n: mockI18n, + }), + Trans: ({ i18nKey, children: _children }: any) => {i18nKey}, + initReactI18next: { + type: "3rdParty", + init: vi.fn(), + }, + } +}) + +// Mock window.postMessage to trigger state hydration +const mockPostMessage = (state: any) => { + window.postMessage( + { + type: "state", + state: { + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + autoApprovalEnabled: false, + alwaysAllowBrowser: false, + alwaysAllowReadOnly: false, + alwaysAllowReadOnlyOutsideWorkspace: false, + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + writeDelayMs: 0, + mode: "code", + customModes: [], + telemetrySetting: "enabled", + hasSystemPromptOverride: false, + historyPreviewCollapsed: false, + soundEnabled: false, + soundVolume: 0.5, + cwd: "/test", + filePaths: [], + openedTabs: [], + currentApiConfigName: "test-config", + listApiConfigMeta: [], + pinnedApiConfigs: {}, + customModePrompts: {}, + codebaseIndexConfig: { codebaseIndexEnabled: false }, + ...state, + }, + }, + "*", + ) +} + +describe("ChatView - Add & Run Button", () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const renderChatView = () => { + return render( + + + + + {}} /> + + + + , + ) + } + + beforeEach(() => { + vi.clearAllMocks() + // Mock window.AUDIO_BASE_URI + ;(window as any).AUDIO_BASE_URI = "" + }) + + it("should display three buttons for command approval", async () => { + renderChatView() + + // Hydrate state with a task message first, then a command ask + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Initial task", + partial: false, + }, + { + type: "ask", + ask: "command", + ts: Date.now(), + text: "npm test", + partial: false, + }, + ], + }) + + // Wait for the buttons to appear + await waitFor( + () => { + expect(screen.getByText("Run Command")).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + expect(screen.getByText("Add & Run")).toBeInTheDocument() + expect(screen.getByText("Reject")).toBeInTheDocument() + }) + + it("should send the command pattern when Add & Run button is clicked", async () => { + renderChatView() + + // Hydrate state with a task message first, then a command ask + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Initial task", + partial: false, + }, + { + type: "ask", + ask: "command", + ts: Date.now(), + text: "npm test", + partial: false, + }, + ], + }) + + // Wait for the buttons to appear + await waitFor( + () => { + expect(screen.getByText("Run Command")).toBeInTheDocument() + expect(screen.getByText("Add & Run")).toBeInTheDocument() + expect(screen.getByText("Reject")).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + // Click the Add & Run button + const addAndRunButton = screen.getByText("Add & Run") + fireEvent.click(addAndRunButton) + + // Verify the correct message was sent + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "addAndRunButtonClicked", + text: "npm test", // The extracted pattern + images: [], + }) + }) + + it("should extract and send the correct pattern for complex commands", async () => { + const complexCommand = "cd /path/to/project && npm run build:prod --verbose" + + renderChatView() + + // Hydrate state with a task message first, then a command ask + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Build project", + partial: false, + }, + { + type: "ask", + ask: "command", + ts: Date.now(), + text: complexCommand, + partial: false, + }, + ], + }) + + // Wait for the buttons to appear + await waitFor( + () => { + expect(screen.getByText("Add & Run")).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + // Click the Add & Run button + const addAndRunButton = screen.getByText("Add & Run") + fireEvent.click(addAndRunButton) + + // Verify the correct pattern was extracted and sent + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "addAndRunButtonClicked", + text: "cd * && npm run *", // The extracted pattern + images: [], + }) + }) + + it("should handle commands with user input", async () => { + renderChatView() + + // Hydrate state with a task message first, then a command ask + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Initial task", + partial: false, + }, + { + type: "ask", + ask: "command", + ts: Date.now(), + text: "npm test", + partial: false, + }, + ], + }) + + // Wait for the buttons and input to appear + await waitFor( + () => { + expect(screen.getByText("Add & Run")).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + // Type some user input + const textarea = screen.getByPlaceholderText(/Type a message/i) + fireEvent.change(textarea, { target: { value: "additional feedback" } }) + + // Click the Add & Run button + const addAndRunButton = screen.getByText("Add & Run") + fireEvent.click(addAndRunButton) + + // Verify the pattern was sent (not the user input) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "addAndRunButtonClicked", + text: "npm test", // The extracted pattern, not the user input + images: [], + }) + }) +}) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 3ece8146af..90cdb464b4 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -200,6 +200,17 @@ const SettingsView = forwardRef(({ onDone, t } }, [settingsImportedAt, extensionState]) + // Update cached state when allowedCommands changes from external sources (e.g., "Add & Run") + useEffect(() => { + // Only update if the allowedCommands have actually changed + if (JSON.stringify(cachedState.allowedCommands) !== JSON.stringify(extensionState.allowedCommands)) { + setCachedState((prevCachedState) => ({ + ...prevCachedState, + allowedCommands: extensionState.allowedCommands, + })) + } + }, [extensionState.allowedCommands, cachedState.allowedCommands]) + const setCachedStateField: SetCachedStateField = useCallback((field, value) => { setCachedState((prevState) => { if (prevState[field] === value) { diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.allowedCommands.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.allowedCommands.spec.tsx new file mode 100644 index 0000000000..6673ad9072 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SettingsView.allowedCommands.spec.tsx @@ -0,0 +1,410 @@ +import React from "react" +import { render, screen, waitFor, fireEvent } from "@testing-library/react" +import { vi } from "vitest" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +import SettingsView from "../SettingsView" + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock ApiConfigManager +vi.mock("../ApiConfigManager", () => ({ + __esModule: true, + default: ({ currentApiConfigName }: any) => ( +
+ Current config: {currentApiConfigName} +
+ ), +})) + +// Mock VSCode UI toolkit components +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeButton: ({ children, onClick, appearance, "data-testid": dataTestId }: any) => + appearance === "icon" ? ( + + ) : ( + + ), + VSCodeCheckbox: ({ children, onChange, checked, "data-testid": dataTestId }: any) => ( + + ), + VSCodeTextField: ({ value, onInput, placeholder, "data-testid": dataTestId }: any) => ( + onInput({ target: { value: e.target.value } })} + placeholder={placeholder} + data-testid={dataTestId} + /> + ), + VSCodeLink: ({ children, href }: any) => {children}, + VSCodeRadio: ({ value, checked, onChange }: any) => ( + + ), + VSCodeRadioGroup: ({ children, onChange }: any) =>
{children}
, +})) + +// Mock Tab components +vi.mock("../../../components/common/Tab", () => ({ + ...vi.importActual("../../../components/common/Tab"), + Tab: ({ children }: any) =>
{children}
, + TabHeader: ({ children }: any) =>
{children}
, + TabContent: ({ children }: any) =>
{children}
, + TabList: ({ children, value, onValueChange, "data-testid": dataTestId }: any) => { + // Store onValueChange in a global variable so TabTrigger can access it + ;(window as any).__onValueChange = onValueChange + return ( +
+ {children} +
+ ) + }, + TabTrigger: ({ children, value, "data-testid": dataTestId, onClick, isSelected }: any) => { + // This function simulates clicking on a tab and making its content visible + const handleClick = () => { + if (onClick) onClick() + // Access onValueChange from the global variable + const onValueChange = (window as any).__onValueChange + if (onValueChange) onValueChange(value) + // Make all tab contents invisible + document.querySelectorAll("[data-tab-content]").forEach((el) => { + ;(el as HTMLElement).style.display = "none" + }) + // Make this tab's content visible + const tabContent = document.querySelector(`[data-tab-content="${value}"]`) + if (tabContent) { + ;(tabContent as HTMLElement).style.display = "block" + } + } + + return ( + + ) + }, +})) + +// Mock UI components +vi.mock("@/components/ui", () => ({ + ...vi.importActual("@/components/ui"), + Slider: ({ value, onValueChange, "data-testid": dataTestId }: any) => ( + onValueChange([parseFloat(e.target.value)])} + data-testid={dataTestId} + /> + ), + Button: ({ children, onClick, variant, className, "data-testid": dataTestId }: any) => ( + + ), + StandardTooltip: ({ children, content }: any) =>
{children}
, + TooltipProvider: ({ children }: any) => <>{children}, + Input: ({ value, onChange, placeholder, "data-testid": dataTestId }: any) => ( + + ), + Select: ({ children, value, onValueChange }: any) => ( +
+ + {children} +
+ ), + SelectContent: ({ children }: any) =>
{children}
, + SelectGroup: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) => ( +
+ {children} +
+ ), + SelectTrigger: ({ children }: any) =>
{children}
, + SelectValue: ({ placeholder }: any) =>
{placeholder}
, + AlertDialog: ({ children, open }: any) => ( +
+ {children} +
+ ), + AlertDialogContent: ({ children }: any) =>
{children}
, + AlertDialogHeader: ({ children }: any) =>
{children}
, + AlertDialogTitle: ({ children }: any) =>
{children}
, + AlertDialogDescription: ({ children }: any) =>
{children}
, + AlertDialogFooter: ({ children }: any) =>
{children}
, + AlertDialogAction: ({ children, onClick }: any) => ( + + ), + AlertDialogCancel: ({ children, onClick }: any) => ( + + ), +})) + +// Mock window.postMessage to trigger state hydration +const mockPostMessage = (state: any) => { + window.postMessage( + { + type: "state", + state: { + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + ttsEnabled: false, + ttsSpeed: 1, + soundEnabled: false, + soundVolume: 0.5, + ...state, + }, + }, + "*", + ) +} + +const renderSettingsView = () => { + const onDone = vi.fn() + const queryClient = new QueryClient() + + render( + + + + + , + ) + + // Hydrate initial state. + mockPostMessage({}) + + // Helper function to activate a tab by clicking it + const activateTab = async (tabId: string) => { + const tabButton = screen.getByTestId(`tab-${tabId}`) + fireEvent.click(tabButton) + // Wait for the tab content to be visible + await waitFor(() => { + // The tab should be marked as selected + expect(tabButton).toHaveAttribute("data-selected", "true") + }) + } + + return { onDone, activateTab } +} + +describe("SettingsView - allowedCommands external updates", () => { + const mockVscodePostMessage = vi.mocked(vscode.postMessage) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should update cached allowedCommands when extension state changes externally", async () => { + const { activateTab } = renderSettingsView() + + // Activate the autoApprove tab + await activateTab("autoApprove") + + // Enable always allow execute + const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + fireEvent.click(executeCheckbox) + + // Wait for allowed commands section to appear + await waitFor(() => { + expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() + }) + + // Initially, there should be no allowed commands + expect(screen.queryByText("npm test")).not.toBeInTheDocument() + + // Simulate external state update (like from "Add & Run") + mockPostMessage({ + allowedCommands: ["npm test", "git status"], + alwaysAllowExecute: true, + }) + + // Wait for the UI to update + await waitFor(() => { + // Check that the new commands appear in the UI + expect(screen.getByText("npm test")).toBeInTheDocument() + expect(screen.getByText("git status")).toBeInTheDocument() + }) + + // Verify that no save message was sent (since this was an external update) + expect(mockVscodePostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "allowedCommands", + }), + ) + }) + + it("should handle multiple external updates to allowedCommands", async () => { + const { activateTab } = renderSettingsView() + + // Activate the autoApprove tab + await activateTab("autoApprove") + + // Enable always allow execute + const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + fireEvent.click(executeCheckbox) + + await waitFor(() => { + expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() + }) + + // First update + mockPostMessage({ + allowedCommands: ["npm test"], + alwaysAllowExecute: true, + }) + + await waitFor(() => { + expect(screen.getByText("npm test")).toBeInTheDocument() + }) + + // Second update (adding more commands) + mockPostMessage({ + allowedCommands: ["npm test", "npm run build", "echo hello"], + alwaysAllowExecute: true, + }) + + await waitFor(() => { + expect(screen.getByText("npm test")).toBeInTheDocument() + expect(screen.getByText("npm run build")).toBeInTheDocument() + expect(screen.getByText("echo hello")).toBeInTheDocument() + }) + }) + + it("should not mark settings as changed when allowedCommands update externally", async () => { + const { activateTab } = renderSettingsView() + + // Activate the autoApprove tab + await activateTab("autoApprove") + + // Enable always allow execute + const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + fireEvent.click(executeCheckbox) + + await waitFor(() => { + expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() + }) + + // Get initial state of save button + const saveButton = screen.getByTestId("save-button") + const initialClasses = saveButton.className + + // External update + mockPostMessage({ + allowedCommands: ["npm test"], + alwaysAllowExecute: true, + }) + + await waitFor(() => { + expect(screen.getByText("npm test")).toBeInTheDocument() + }) + + // Save button should maintain its initial state (not change due to external update) + expect(saveButton.className).toBe(initialClasses) + + // Verify that no save message was sent (since this was an external update) + expect(mockVscodePostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "allowedCommands", + }), + ) + }) + + it("should replace user changes when external updates occur", async () => { + const { activateTab } = renderSettingsView() + + // Activate the autoApprove tab + await activateTab("autoApprove") + + // Enable always allow execute + const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + fireEvent.click(executeCheckbox) + + // Wait for allowed commands section to appear + await waitFor(() => { + expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() + }) + + // Add a command manually + const input = screen.getByTestId("command-input") + fireEvent.change(input, { target: { value: "npm start" } }) + const addButton = screen.getByTestId("add-command-button") + fireEvent.click(addButton) + + // Wait for VSCode message to be sent + await waitFor(() => { + expect(mockVscodePostMessage).toHaveBeenCalledWith({ + type: "allowedCommands", + commands: ["npm start"], + }) + }) + + // Simulate the state update that would come from VSCode after adding the command + mockPostMessage({ + allowedCommands: ["npm start"], + alwaysAllowExecute: true, + }) + + // The command should appear in the UI + await waitFor(() => { + expect(screen.getByText("npm start")).toBeInTheDocument() + }) + + // Clear the mock to ensure we don't count the manual addition + mockVscodePostMessage.mockClear() + + // External update with different commands + mockPostMessage({ + allowedCommands: ["npm test", "git status"], + alwaysAllowExecute: true, + }) + + // Wait for external commands to appear + await waitFor(() => { + expect(screen.getByText("npm test")).toBeInTheDocument() + expect(screen.getByText("git status")).toBeInTheDocument() + }) + + // The manually added command should be replaced by the external update + expect(screen.queryByText("npm start")).not.toBeInTheDocument() + + // Verify that no save message was sent (since this was an external update) + expect(mockVscodePostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "allowedCommands", + }), + ) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 0a76e54ffe..08f2e79bac 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from "@/utils/test-utils" +import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { vscode } from "@/utils/vscode" @@ -412,17 +412,22 @@ describe("SettingsView - Allowed Commands", () => { expect(screen.getByTestId("command-input")).toBeInTheDocument() }) - it("adds new command to the list", () => { + it("adds new command to the list", async () => { // Render once and get the activateTab helper const { activateTab } = renderSettingsView() // Activate the autoApprove tab - activateTab("autoApprove") + await activateTab("autoApprove") // Enable always allow execute const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") fireEvent.click(executeCheckbox) + // Wait for allowed commands section to appear + await waitFor(() => { + expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() + }) + // Add a new command const input = screen.getByTestId("command-input") fireEvent.change(input, { target: { value: "npm test" } }) @@ -430,40 +435,61 @@ describe("SettingsView - Allowed Commands", () => { const addButton = screen.getByTestId("add-command-button") fireEvent.click(addButton) - // Verify command was added - expect(screen.getByText("npm test")).toBeInTheDocument() - // Verify VSCode message was sent expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm test"], }) + + // Simulate the state update that would come from VSCode + mockPostMessage({ + allowedCommands: ["npm test"], + alwaysAllowExecute: true, + }) + + // Wait for command to appear + await waitFor(() => { + expect(screen.getByText("npm test")).toBeInTheDocument() + }) }) - it("removes command from the list", () => { + it("removes command from the list", async () => { // Render once and get the activateTab helper const { activateTab } = renderSettingsView() // Activate the autoApprove tab - activateTab("autoApprove") + await activateTab("autoApprove") // Enable always allow execute const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") fireEvent.click(executeCheckbox) + // Wait for allowed commands section to appear + await waitFor(() => { + expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() + }) + // Add a command const input = screen.getByTestId("command-input") fireEvent.change(input, { target: { value: "npm test" } }) const addButton = screen.getByTestId("add-command-button") fireEvent.click(addButton) + // Simulate the state update after adding + mockPostMessage({ + allowedCommands: ["npm test"], + alwaysAllowExecute: true, + }) + + // Wait for command to appear + await waitFor(() => { + expect(screen.getByText("npm test")).toBeInTheDocument() + }) + // Remove the command const removeButton = screen.getByTestId("remove-command-0") fireEvent.click(removeButton) - // Verify command was removed - expect(screen.queryByText("npm test")).not.toBeInTheDocument() - // Verify VSCode message was sent expect(vscode.postMessage).toHaveBeenLastCalledWith({ type: "allowedCommands", @@ -530,32 +556,65 @@ describe("SettingsView - Duplicate Commands", () => { vi.clearAllMocks() }) - it("prevents duplicate commands", () => { + it("prevents duplicate commands", async () => { // Render once and get the activateTab helper const { activateTab } = renderSettingsView() // Activate the autoApprove tab - activateTab("autoApprove") + await activateTab("autoApprove") // Enable always allow execute const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") fireEvent.click(executeCheckbox) - // Add a command twice + // Wait for allowed commands section to appear + await waitFor(() => { + expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() + }) + + // Add a command const input = screen.getByTestId("command-input") const addButton = screen.getByTestId("add-command-button") - // First addition fireEvent.change(input, { target: { value: "npm test" } }) fireEvent.click(addButton) - // Second addition attempt + // Verify the postMessage was sent + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "allowedCommands", + commands: ["npm test"], + }) + + // Simulate the state update from VSCode + mockPostMessage({ + alwaysAllowExecute: true, + allowedCommands: ["npm test"], + }) + + // Wait for the command to appear + await waitFor(() => { + expect(screen.getByText("npm test")).toBeInTheDocument() + }) + + // Try to add the same command again fireEvent.change(input, { target: { value: "npm test" } }) fireEvent.click(addButton) - // Verify command appears only once - const commands = screen.getAllByText("npm test") - expect(commands).toHaveLength(1) + // The postMessage should not add a duplicate + expect(vscode.postMessage).toHaveBeenLastCalledWith({ + type: "allowedCommands", + commands: ["npm test"], + }) + + // Add a different command + fireEvent.change(input, { target: { value: "npm run build" } }) + fireEvent.click(addButton) + + // Now it should have both commands + expect(vscode.postMessage).toHaveBeenLastCalledWith({ + type: "allowedCommands", + commands: ["npm test", "npm run build"], + }) }) it("saves allowed commands when clicking Save", () => { @@ -575,6 +634,12 @@ describe("SettingsView - Duplicate Commands", () => { const addButton = screen.getByTestId("add-command-button") fireEvent.click(addButton) + // Simulate the state update after adding + mockPostMessage({ + allowedCommands: ["npm test"], + alwaysAllowExecute: true, + }) + // Click Save - use getAllByTestId to handle multiple elements const saveButtons = screen.getAllByTestId("save-button") fireEvent.click(saveButtons[0]) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 1dd892a39f..ae1aae9a71 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -59,6 +59,10 @@ "title": "Executar ordre", "tooltip": "Executa aquesta ordre" }, + "addAndRunCommand": { + "title": "Afegeix i executa", + "tooltip": "Afegeix el patró de comanda a la llista blanca i executa-la" + }, "proceedWhileRunning": { "title": "Continuar mentre s'executa", "tooltip": "Continua malgrat els advertiments" diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index c62fe9d3bb..dd4c1b7936 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -59,6 +59,10 @@ "title": "Befehl ausführen", "tooltip": "Diesen Befehl ausführen" }, + "addAndRunCommand": { + "title": "Hinzufügen & Ausführen", + "tooltip": "Befehlsmuster zur Whitelist hinzufügen und ausführen" + }, "proceedWhileRunning": { "title": "Während Ausführung fortfahren", "tooltip": "Trotz Warnungen fortfahren" diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index ea4f8920f5..cee58aedca 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -67,6 +67,10 @@ "title": "Run Command", "tooltip": "Execute this command" }, + "addAndRunCommand": { + "title": "Add & Run", + "tooltip": "Add command pattern to whitelist and run" + }, "proceedWhileRunning": { "title": "Proceed While Running", "tooltip": "Continue despite warnings" diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index a4349b13dc..5484c655fc 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -59,6 +59,10 @@ "title": "Ejecutar comando", "tooltip": "Ejecutar este comando" }, + "addAndRunCommand": { + "title": "Añadir y ejecutar", + "tooltip": "Añadir patrón de comando a la lista blanca y ejecutar" + }, "proceedWhileRunning": { "title": "Continuar mientras se ejecuta", "tooltip": "Continuar a pesar de las advertencias" diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index b5f06354bb..3fe78e1a2b 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -59,6 +59,10 @@ "title": "Exécuter la commande", "tooltip": "Exécuter cette commande" }, + "addAndRunCommand": { + "title": "Ajouter et exécuter", + "tooltip": "Ajouter le modèle de commande à la liste blanche et exécuter" + }, "proceedWhileRunning": { "title": "Continuer pendant l'exécution", "tooltip": "Continuer malgré les avertissements" diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 9afdcbdd38..2d48653f69 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -59,6 +59,10 @@ "title": "कमांड चलाएँ", "tooltip": "इस कमांड को निष्पादित करें" }, + "addAndRunCommand": { + "title": "जोड़ें और चलाएं", + "tooltip": "कमांड पैटर्न को व्हाइटलिस्ट में जोड़ें और चलाएं" + }, "proceedWhileRunning": { "title": "चलते समय आगे बढ़ें", "tooltip": "चेतावनियों के बावजूद जारी रखें" diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index fc0bd5056f..3e624139f0 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -73,6 +73,10 @@ "title": "Jalankan Perintah", "tooltip": "Eksekusi perintah ini" }, + "addAndRunCommand": { + "title": "Tambah & Jalankan", + "tooltip": "Tambahkan pola perintah ke daftar putih dan jalankan" + }, "proceedWhileRunning": { "title": "Lanjutkan Saat Berjalan", "tooltip": "Lanjutkan meskipun ada peringatan" diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index f49a25dfa6..55f4d2a260 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -59,6 +59,10 @@ "title": "Esegui comando", "tooltip": "Esegui questo comando" }, + "addAndRunCommand": { + "title": "Aggiungi ed esegui", + "tooltip": "Aggiungi il pattern del comando alla whitelist ed eseguilo" + }, "proceedWhileRunning": { "title": "Procedi durante l'esecuzione", "tooltip": "Continua nonostante gli avvisi" diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index cb5ebcdafd..ca33bce27e 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -59,6 +59,10 @@ "title": "コマンド実行", "tooltip": "このコマンドを実行" }, + "addAndRunCommand": { + "title": "追加して実行", + "tooltip": "コマンドパターンをホワイトリストに追加して実行します" + }, "proceedWhileRunning": { "title": "実行中も続行", "tooltip": "警告にもかかわらず続行" diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 1f86dc8cf4..b74980a257 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -59,6 +59,10 @@ "title": "명령 실행", "tooltip": "이 명령 실행" }, + "addAndRunCommand": { + "title": "추가 및 실행", + "tooltip": "허용 목록에 명령 패턴을 추가하고 실행합니다." + }, "proceedWhileRunning": { "title": "실행 중에도 계속", "tooltip": "경고에도 불구하고 계속 진행" diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index d228d7b0c2..429c4170af 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -59,6 +59,10 @@ "title": "Commando uitvoeren", "tooltip": "Voer dit commando uit" }, + "addAndRunCommand": { + "title": "Toevoegen & uitvoeren", + "tooltip": "Commandopatroon toevoegen aan de witte lijst en uitvoeren" + }, "proceedWhileRunning": { "title": "Doorgaan tijdens uitvoeren", "tooltip": "Ga door ondanks waarschuwingen" diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index fdb39b0851..dd19bb326a 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -59,6 +59,10 @@ "title": "Uruchom polecenie", "tooltip": "Wykonaj to polecenie" }, + "addAndRunCommand": { + "title": "Dodaj i uruchom", + "tooltip": "Dodaj wzorzec polecenia do białej listy i uruchom" + }, "proceedWhileRunning": { "title": "Kontynuuj podczas wykonywania", "tooltip": "Kontynuuj pomimo ostrzeżeń" diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index b37879f2f0..9238fb18ad 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -59,6 +59,10 @@ "title": "Executar comando", "tooltip": "Executar este comando" }, + "addAndRunCommand": { + "title": "Adicionar & Executar", + "tooltip": "Adicionar padrão de comando à lista de permissões e executar" + }, "proceedWhileRunning": { "title": "Prosseguir durante execução", "tooltip": "Continuar apesar dos avisos" diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 5865e41a53..87894211b4 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -59,6 +59,10 @@ "title": "Выполнить команду", "tooltip": "Выполнить эту команду" }, + "addAndRunCommand": { + "title": "Добавить и выполнить", + "tooltip": "Добавить шаблон команды в белый список и выполнить" + }, "proceedWhileRunning": { "title": "Продолжить во время выполнения", "tooltip": "Продолжить несмотря на предупреждения" diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 6c5bd20353..893228e6d9 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -59,6 +59,10 @@ "title": "Komutu Çalıştır", "tooltip": "Bu komutu çalıştır" }, + "addAndRunCommand": { + "title": "Ekle ve Çalıştır", + "tooltip": "Komut desenini beyaz listeye ekle ve çalıştır" + }, "proceedWhileRunning": { "title": "Çalışırken Devam Et", "tooltip": "Uyarılara rağmen devam et" diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index eb7cdc2306..0852a72b1e 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -59,6 +59,10 @@ "title": "Chạy lệnh", "tooltip": "Thực thi lệnh này" }, + "addAndRunCommand": { + "title": "Thêm & Chạy", + "tooltip": "Thêm mẫu lệnh vào danh sách trắng và chạy" + }, "proceedWhileRunning": { "title": "Tiếp tục trong khi chạy", "tooltip": "Tiếp tục bất chấp cảnh báo" diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 93494e6f50..cbc72ab820 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -59,6 +59,10 @@ "title": "运行命令", "tooltip": "执行此命令" }, + "addAndRunCommand": { + "title": "添加并运行", + "tooltip": "将命令模式添加到白名单并运行" + }, "proceedWhileRunning": { "title": "强制继续", "tooltip": "忽略运行中的命令并继续" diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index e7a476cb37..88fecaf3dd 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -59,6 +59,10 @@ "title": "執行命令", "tooltip": "執行此命令" }, + "addAndRunCommand": { + "title": "新增並執行", + "tooltip": "將命令模式新增至白名單並執行" + }, "proceedWhileRunning": { "title": "執行時繼續", "tooltip": "儘管有警告仍繼續執行" diff --git a/webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts b/webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts new file mode 100644 index 0000000000..31452a42c0 --- /dev/null +++ b/webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from "vitest" +import { extractCommandPattern, getPatternDescription } from "../extract-command-pattern" + +describe("extractCommandPattern", () => { + it("handles empty or null input", () => { + expect(extractCommandPattern("")).toBe("") + expect(extractCommandPattern(" ")).toBe("") + expect(extractCommandPattern(null as any)).toBe("") + expect(extractCommandPattern(undefined as any)).toBe("") + }) + + describe("npm/yarn/pnpm/bun commands", () => { + it("extracts npm run patterns", () => { + expect(extractCommandPattern("npm run build")).toBe("npm run") + expect(extractCommandPattern("npm run test:unit")).toBe("npm run *") + expect(extractCommandPattern("yarn run dev")).toBe("yarn run") + expect(extractCommandPattern("pnpm run lint")).toBe("pnpm run") + expect(extractCommandPattern("bun run start")).toBe("bun run") + }) + + it("extracts npm script patterns", () => { + expect(extractCommandPattern("npm test")).toBe("npm test") + expect(extractCommandPattern("npm build")).toBe("npm build") + expect(extractCommandPattern("npm start")).toBe("npm start") + expect(extractCommandPattern("yarn test")).toBe("yarn test") + expect(extractCommandPattern("pnpm build")).toBe("pnpm build") + }) + + it("handles npm with flags", () => { + expect(extractCommandPattern("npm install --save-dev")).toBe("npm install") + expect(extractCommandPattern("npm test -- --coverage")).toBe("npm test") + expect(extractCommandPattern("npm -v")).toBe("npm") + }) + }) + + describe("git commands", () => { + it("extracts git subcommands", () => { + expect(extractCommandPattern("git commit -m 'message'")).toBe("git commit") + expect(extractCommandPattern("git push origin main")).toBe("git push") + expect(extractCommandPattern("git pull --rebase")).toBe("git pull") + expect(extractCommandPattern("git checkout -b feature")).toBe("git checkout") + }) + + it("handles git with flags only", () => { + expect(extractCommandPattern("git --version")).toBe("git") + }) + }) + + describe("script files", () => { + it("preserves full script paths", () => { + expect(extractCommandPattern("./scripts/deploy.sh production")).toBe("./scripts/deploy.sh") + expect(extractCommandPattern("/usr/local/bin/backup.sh")).toBe("/usr/local/bin/backup.sh") + expect(extractCommandPattern("scripts/test.py --verbose")).toBe("scripts/test.py") + expect(extractCommandPattern("./build.js --watch")).toBe("./build.js") + }) + }) + + describe("interpreters", () => { + it("extracts just the interpreter", () => { + expect(extractCommandPattern("python script.py --arg value")).toBe("python") + expect(extractCommandPattern("python3 -m pytest")).toBe("python3") + expect(extractCommandPattern("node index.js --port 3000")).toBe("node") + expect(extractCommandPattern("ruby app.rb")).toBe("ruby") + expect(extractCommandPattern("java -jar app.jar")).toBe("java") + }) + }) + + describe("dangerous commands", () => { + it("extracts just the base command", () => { + expect(extractCommandPattern("rm -rf node_modules")).toBe("rm") + expect(extractCommandPattern("mv old.txt new.txt")).toBe("mv") + expect(extractCommandPattern("chmod 755 script.sh")).toBe("chmod") + expect(extractCommandPattern("find . -name '*.log' -delete")).toBe("find") + }) + }) + + describe("chained commands", () => { + it("extracts patterns from all commands in chain", () => { + expect(extractCommandPattern("cd /path && npm install")).toBe("cd * && npm install") + expect(extractCommandPattern("npm test || echo 'failed'")).toBe("npm test || echo") + expect(extractCommandPattern("git pull; npm install; npm run build")).toBe( + "git pull ; npm install ; npm run", + ) + expect(extractCommandPattern("echo 'start' | grep start")).toBe("echo | grep") + }) + + it("handles complex chained commands with wildcards", () => { + expect(extractCommandPattern("cd /path/to/project && npm run build:prod --verbose")).toBe( + "cd * && npm run *", + ) + }) + }) + + describe("docker/kubectl commands", () => { + it("extracts docker subcommands", () => { + expect(extractCommandPattern("docker run -it ubuntu")).toBe("docker run") + expect(extractCommandPattern("docker build -t myapp .")).toBe("docker build") + expect(extractCommandPattern("kubectl get pods")).toBe("kubectl get") + expect(extractCommandPattern("kubectl apply -f config.yaml")).toBe("kubectl apply") + expect(extractCommandPattern("helm install myapp ./chart")).toBe("helm install") + }) + }) + + describe("make commands", () => { + it("extracts make targets", () => { + expect(extractCommandPattern("make build")).toBe("make build") + expect(extractCommandPattern("make test")).toBe("make test") + expect(extractCommandPattern("make clean install")).toBe("make clean") + expect(extractCommandPattern("make -j4")).toBe("make") + }) + }) + + describe("quoted arguments", () => { + it("handles single quotes", () => { + expect(extractCommandPattern("echo 'hello world'")).toBe("echo") + expect(extractCommandPattern("git commit -m 'feat: add feature'")).toBe("git commit") + }) + + it("handles double quotes", () => { + expect(extractCommandPattern('echo "hello world"')).toBe("echo") + expect(extractCommandPattern('npm run "test:unit"')).toBe("npm run *") + }) + + it("handles quotes with spaces", () => { + expect(extractCommandPattern('git commit -m "fix: resolve issue #123"')).toBe("git commit") + expect(extractCommandPattern("echo 'multiple spaces'")).toBe("echo") + }) + }) + + describe("edge cases", () => { + it("handles commands with redirects", () => { + expect(extractCommandPattern("npm test > output.log")).toBe("npm test") + expect(extractCommandPattern("echo hello 2>&1")).toBe("echo") + }) + + it("handles cd command", () => { + expect(extractCommandPattern("cd /home/user/project")).toBe("cd *") + expect(extractCommandPattern("cd ..")).toBe("cd *") + expect(extractCommandPattern("cd")).toBe("cd *") + }) + + it("handles commands with environment variables", () => { + expect(extractCommandPattern("NODE_ENV=production npm start")).toBe("NODE_ENV=production") + expect(extractCommandPattern("PORT=3000 node server.js")).toBe("PORT=3000") + }) + }) +}) + +describe("getPatternDescription", () => { + it("describes npm patterns", () => { + expect(getPatternDescription("npm run")).toBe("npm run scripts") + expect(getPatternDescription("npm test")).toBe("npm test commands") + expect(getPatternDescription("npm")).toBe("npm commands") + expect(getPatternDescription("yarn run")).toBe("yarn run scripts") + expect(getPatternDescription("pnpm build")).toBe("pnpm build commands") + }) + + it("describes git patterns", () => { + expect(getPatternDescription("git commit")).toBe("git commit commands") + expect(getPatternDescription("git push")).toBe("git push commands") + expect(getPatternDescription("git")).toBe("git commands") + }) + + it("describes script patterns", () => { + expect(getPatternDescription("./scripts/deploy.sh")).toBe("this specific script") + expect(getPatternDescription("/usr/bin/backup.py")).toBe("this specific script") + }) + + it("describes interpreter patterns", () => { + expect(getPatternDescription("python")).toBe("python scripts") + expect(getPatternDescription("node")).toBe("node scripts") + expect(getPatternDescription("ruby")).toBe("ruby scripts") + }) + + it("describes docker/kubectl patterns", () => { + expect(getPatternDescription("docker run")).toBe("docker run commands") + expect(getPatternDescription("kubectl get")).toBe("kubectl get commands") + expect(getPatternDescription("helm install")).toBe("helm install commands") + }) + + it("describes make patterns", () => { + expect(getPatternDescription("make build")).toBe("make build target") + expect(getPatternDescription("make test")).toBe("make test target") + expect(getPatternDescription("make")).toBe("make commands") + }) + + it("describes cd pattern", () => { + expect(getPatternDescription("cd")).toBe("directory navigation") + }) + + it("describes generic patterns", () => { + expect(getPatternDescription("echo")).toBe("echo commands") + expect(getPatternDescription("rm")).toBe("rm commands") + expect(getPatternDescription("custom-tool")).toBe("custom-tool commands") + }) + + it("handles empty input", () => { + expect(getPatternDescription("")).toBe("") + expect(getPatternDescription(null as any)).toBe("") + }) +}) diff --git a/webview-ui/src/utils/extract-command-pattern.ts b/webview-ui/src/utils/extract-command-pattern.ts new file mode 100644 index 0000000000..9bfbe60768 --- /dev/null +++ b/webview-ui/src/utils/extract-command-pattern.ts @@ -0,0 +1,232 @@ +/** + * Extracts a generalizable command pattern from a specific command. + * This function creates patterns that can be used for whitelisting similar commands. + * + * Examples: + * - "npm test" -> "npm test" + * - "npm run build" -> "npm run" + * - "git commit -m 'message'" -> "git commit" + * - "echo 'hello world'" -> "echo" + * - "python script.py --arg value" -> "python" + * - "./scripts/deploy.sh production" -> "./scripts/deploy.sh" + * - "cd /path/to/dir && npm install" -> "cd * && npm install" + * - "rm -rf node_modules" -> "rm" + */ +export function extractCommandPattern(command: string): string { + if (!command?.trim()) return "" + + // Remove leading/trailing whitespace + const trimmedCommand = command.trim() + + // Check if this is a chained command + const chainMatch = trimmedCommand.match(/^(.+?)\s*(&&|\|\||;|\|)\s*(.+)$/) + if (chainMatch) { + // Handle chained commands by processing each part + const [, firstPart, operator, restPart] = chainMatch + const firstPattern = extractSingleCommandPattern(firstPart.trim()) + const restPattern = extractCommandPattern(restPart.trim()) + return `${firstPattern} ${operator} ${restPattern}` + } + + // Not a chained command, process normally + return extractSingleCommandPattern(trimmedCommand) +} + +/** + * Extracts pattern from a single command (not chained) + */ +function extractSingleCommandPattern(command: string): string { + const firstCommand = command + + // Split the command into tokens, respecting quotes + const tokens: string[] = [] + let currentToken = "" + let inSingleQuote = false + let inDoubleQuote = false + let escapeNext = false + + for (let i = 0; i < firstCommand.length; i++) { + const char = firstCommand[i] + + if (escapeNext) { + currentToken += char + escapeNext = false + continue + } + + if (char === "\\") { + escapeNext = true + currentToken += char + continue + } + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote + currentToken += char + continue + } + + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote + currentToken += char + continue + } + + if (char === " " && !inSingleQuote && !inDoubleQuote) { + if (currentToken) { + tokens.push(currentToken) + currentToken = "" + } + } else { + currentToken += char + } + } + + if (currentToken) { + tokens.push(currentToken) + } + + if (tokens.length === 0) return "" + + const baseCommand = tokens[0] + + // Special handling for common patterns + + // 1. npm/yarn/pnpm commands - include subcommand with wildcards for scripts + if (["npm", "yarn", "pnpm", "bun"].includes(baseCommand) && tokens.length > 1) { + const subCommand = tokens[1] + // For "run" commands, include "run" with wildcard for script names + if (subCommand === "run" && tokens.length > 2) { + // Check if the script name contains special characters like colons + const scriptName = tokens[2] + if (scriptName && (scriptName.includes(":") || scriptName.includes("-"))) { + return `${baseCommand} run *` + } + return `${baseCommand} run` + } + // For direct scripts like "npm test", "npm build", include the script name + if (!subCommand.startsWith("-")) { + return `${baseCommand} ${subCommand}` + } + } + + // 2. git commands - include subcommand + if (baseCommand === "git" && tokens.length > 1) { + const subCommand = tokens[1] + if (!subCommand.startsWith("-")) { + return `${baseCommand} ${subCommand}` + } + } + + // 3. Script files - include the full script path + if ( + baseCommand.includes("/") || + baseCommand.endsWith(".sh") || + baseCommand.endsWith(".py") || + baseCommand.endsWith(".js") || + baseCommand.endsWith(".rb") + ) { + return baseCommand + } + + // 4. Python/node/ruby/etc interpreters - just the interpreter + if (["python", "python3", "node", "ruby", "perl", "php", "java", "go"].includes(baseCommand)) { + return baseCommand + } + + // 5. Common shell commands with dangerous flags - just the command + if (["rm", "mv", "cp", "chmod", "chown", "find", "grep", "sed", "awk"].includes(baseCommand)) { + return baseCommand + } + + // 6. cd command - include wildcard for paths + if (baseCommand === "cd") { + return "cd *" + } + + // 7. Docker/kubectl commands - include subcommand + if (["docker", "kubectl", "helm"].includes(baseCommand) && tokens.length > 1) { + const subCommand = tokens[1] + if (!subCommand.startsWith("-")) { + return `${baseCommand} ${subCommand}` + } + } + + // 8. Make commands - include target if present + if (baseCommand === "make" && tokens.length > 1) { + const target = tokens[1] + if (!target.startsWith("-")) { + return `${baseCommand} ${target}` + } + } + + // Default: just return the base command + return baseCommand +} + +/** + * Get a human-readable description of what the pattern will allow + * + * Examples: + * - "npm test" -> "npm test commands" + * - "npm run" -> "npm run scripts" + * - "git commit" -> "git commit commands" + * - "python" -> "python scripts" + * - "./scripts/deploy.sh" -> "this specific script" + */ +export function getPatternDescription(pattern: string): string { + if (!pattern) return "" + + const tokens = pattern.split(" ") + const baseCommand = tokens[0] + + // npm/yarn/pnpm patterns + if (["npm", "yarn", "pnpm", "bun"].includes(baseCommand)) { + if (tokens[1] === "run") { + return `${baseCommand} run scripts` + } + if (tokens[1]) { + return `${baseCommand} ${tokens[1]} commands` + } + return `${baseCommand} commands` + } + + // git patterns + if (baseCommand === "git" && tokens[1]) { + return `git ${tokens[1]} commands` + } + + // Script files + if ( + baseCommand.includes("/") || + baseCommand.endsWith(".sh") || + baseCommand.endsWith(".py") || + baseCommand.endsWith(".js") || + baseCommand.endsWith(".rb") + ) { + return "this specific script" + } + + // Interpreters + if (["python", "python3", "node", "ruby", "perl", "php", "java", "go"].includes(baseCommand)) { + return `${baseCommand} scripts` + } + + // Docker/kubectl + if (["docker", "kubectl", "helm"].includes(baseCommand) && tokens[1]) { + return `${baseCommand} ${tokens[1]} commands` + } + + // Make + if (baseCommand === "make" && tokens[1]) { + return `make ${tokens[1]} target` + } + + // cd + if (baseCommand === "cd") { + return "directory navigation" + } + + // Default + return `${baseCommand} commands` +} From d2a0390f5dd4d24eb108174122dafca28bb280d2 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Tue, 1 Jul 2025 16:44:42 -0600 Subject: [PATCH 2/8] fix: improve command pattern extraction security - Fix npm/yarn/pnpm commands with additional arguments to avoid overly broad wildcards - Improve handling of chained commands with proper quote-aware parsing - Add security limits for deeply nested command chains - Enhance detection of command injection patterns - Update tests to reflect more secure pattern extraction behavior This addresses the critical security issue where commands like 'npm run build:prod -- --env=production' were creating patterns that were too permissive. --- .../__tests__/extract-command-pattern.spec.ts | 40 +++++-- .../src/utils/extract-command-pattern.ts | 105 ++++++++++++++++-- 2 files changed, 127 insertions(+), 18 deletions(-) diff --git a/webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts b/webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts index 31452a42c0..b6715fcc4e 100644 --- a/webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts +++ b/webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts @@ -11,11 +11,17 @@ describe("extractCommandPattern", () => { describe("npm/yarn/pnpm/bun commands", () => { it("extracts npm run patterns", () => { - expect(extractCommandPattern("npm run build")).toBe("npm run") + expect(extractCommandPattern("npm run build")).toBe("npm run build") expect(extractCommandPattern("npm run test:unit")).toBe("npm run *") - expect(extractCommandPattern("yarn run dev")).toBe("yarn run") - expect(extractCommandPattern("pnpm run lint")).toBe("pnpm run") - expect(extractCommandPattern("bun run start")).toBe("bun run") + expect(extractCommandPattern("yarn run dev")).toBe("yarn run dev") + expect(extractCommandPattern("pnpm run lint")).toBe("pnpm run lint") + expect(extractCommandPattern("bun run start")).toBe("bun run start") + }) + + it("handles npm run with additional arguments", () => { + expect(extractCommandPattern("npm run build:prod -- --env=production")).toBe("npm run build:prod") + expect(extractCommandPattern("npm run test -- --coverage")).toBe("npm run test") + expect(extractCommandPattern("npm run build:dev --watch")).toBe("npm run *") }) it("extracts npm script patterns", () => { @@ -79,7 +85,7 @@ describe("extractCommandPattern", () => { expect(extractCommandPattern("cd /path && npm install")).toBe("cd * && npm install") expect(extractCommandPattern("npm test || echo 'failed'")).toBe("npm test || echo") expect(extractCommandPattern("git pull; npm install; npm run build")).toBe( - "git pull ; npm install ; npm run", + "git pull ; npm install ; npm run build", ) expect(extractCommandPattern("echo 'start' | grep start")).toBe("echo | grep") }) @@ -137,11 +143,31 @@ describe("extractCommandPattern", () => { expect(extractCommandPattern("cd /home/user/project")).toBe("cd *") expect(extractCommandPattern("cd ..")).toBe("cd *") expect(extractCommandPattern("cd")).toBe("cd *") + expect(extractCommandPattern("cd /usr/local/bin")).toBe("cd *") }) it("handles commands with environment variables", () => { - expect(extractCommandPattern("NODE_ENV=production npm start")).toBe("NODE_ENV=production") - expect(extractCommandPattern("PORT=3000 node server.js")).toBe("PORT=3000") + expect(extractCommandPattern("NODE_ENV=production npm start")).toBe("NODE_ENV=*") + expect(extractCommandPattern("PORT=3000 node server.js")).toBe("PORT=*") + }) + + it("handles dangerous commands more carefully", () => { + expect(extractCommandPattern("rm -rf /")).toBe("rm") + expect(extractCommandPattern("rm -rf node_modules")).toBe("rm") + expect(extractCommandPattern("find . -name '*.log' -delete")).toBe("find") + }) + + it("handles command injection patterns", () => { + expect(extractCommandPattern("echo $(whoami)")).toBe("echo") + expect(extractCommandPattern("echo `date`")).toBe("echo") + expect(extractCommandPattern("bash -c 'echo hello'")).toBe("bash") + }) + + it("limits deeply chained commands", () => { + const deepChain = "echo 1 && echo 2 && echo 3 && echo 4 && echo 5" + const result = extractCommandPattern(deepChain) + // Should stop at some point to prevent overly complex patterns + expect(result).toBe("echo") }) }) }) diff --git a/webview-ui/src/utils/extract-command-pattern.ts b/webview-ui/src/utils/extract-command-pattern.ts index 9bfbe60768..83f0628fb5 100644 --- a/webview-ui/src/utils/extract-command-pattern.ts +++ b/webview-ui/src/utils/extract-command-pattern.ts @@ -19,13 +19,69 @@ export function extractCommandPattern(command: string): string { const trimmedCommand = command.trim() // Check if this is a chained command - const chainMatch = trimmedCommand.match(/^(.+?)\s*(&&|\|\||;|\|)\s*(.+)$/) - if (chainMatch) { - // Handle chained commands by processing each part - const [, firstPart, operator, restPart] = chainMatch - const firstPattern = extractSingleCommandPattern(firstPart.trim()) - const restPattern = extractCommandPattern(restPart.trim()) - return `${firstPattern} ${operator} ${restPattern}` + // Use a more robust regex that handles nested quotes properly + const operators = ["&&", "||", ";", "|"] + let chainOperator: string | null = null + let splitIndex = -1 + + // Find the first unquoted operator + let inSingleQuote = false + let inDoubleQuote = false + let escapeNext = false + + for (let i = 0; i < trimmedCommand.length; i++) { + const char = trimmedCommand[i] + + if (escapeNext) { + escapeNext = false + continue + } + + if (char === "\\") { + escapeNext = true + continue + } + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote + continue + } + + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote + continue + } + + // Only look for operators outside of quotes + if (!inSingleQuote && !inDoubleQuote) { + for (const op of operators) { + if (trimmedCommand.substring(i, i + op.length) === op) { + chainOperator = op + splitIndex = i + break + } + } + if (chainOperator) break + } + } + + if (chainOperator && splitIndex > 0) { + const firstPart = trimmedCommand.substring(0, splitIndex).trim() + const restPart = trimmedCommand.substring(splitIndex + chainOperator.length).trim() + + // Process each part separately + const firstPattern = extractSingleCommandPattern(firstPart) + const restPattern = extractCommandPattern(restPart) + + // For security, limit the depth of chained commands + // Count existing operators in the pattern to prevent deeply nested chains + const operatorCount = (restPattern.match(/&&|\|\||;|\|/g) || []).length + if (operatorCount >= 3) { + // Too many chained commands, return a more restrictive pattern + return firstPattern + } + + return `${firstPattern} ${chainOperator} ${restPattern}` } // Not a chained command, process normally @@ -95,14 +151,24 @@ function extractSingleCommandPattern(command: string): string { // 1. npm/yarn/pnpm commands - include subcommand with wildcards for scripts if (["npm", "yarn", "pnpm", "bun"].includes(baseCommand) && tokens.length > 1) { const subCommand = tokens[1] - // For "run" commands, include "run" with wildcard for script names + // For "run" commands, be more specific about patterns if (subCommand === "run" && tokens.length > 2) { - // Check if the script name contains special characters like colons const scriptName = tokens[2] + // Check if there are additional arguments after the script name + const hasDoubleDash = tokens.includes("--") + const doubleDashIndex = hasDoubleDash ? tokens.indexOf("--") : -1 + + // For scripts with colons or complex names if (scriptName && (scriptName.includes(":") || scriptName.includes("-"))) { + // If there's a double dash with additional args, include the full script name + if (hasDoubleDash && doubleDashIndex > 2) { + return `${baseCommand} run ${scriptName}` + } + // Otherwise use wildcard for flexibility return `${baseCommand} run *` } - return `${baseCommand} run` + // For simple script names, include the script name itself + return `${baseCommand} run ${scriptName}` } // For direct scripts like "npm test", "npm build", include the script name if (!subCommand.startsWith("-")) { @@ -139,7 +205,7 @@ function extractSingleCommandPattern(command: string): string { return baseCommand } - // 6. cd command - include wildcard for paths + // 6. cd command - use wildcard for flexibility if (baseCommand === "cd") { return "cd *" } @@ -160,6 +226,23 @@ function extractSingleCommandPattern(command: string): string { } } + // 9. Environment variables - handle with care + if (baseCommand.includes("=")) { + // This might be an environment variable like NODE_ENV=production + const envMatch = baseCommand.match(/^([A-Z_]+)=/) + if (envMatch) { + // Don't include the value, just the variable name pattern + return `${envMatch[1]}=*` + } + } + + // 10. Commands with suspicious patterns - be restrictive + // Check for potential command injection patterns + if (baseCommand.includes("$") || baseCommand.includes("`") || baseCommand.includes("(")) { + // These could be command substitutions or variables, be very restrictive + return baseCommand.split(/[$`(]/)[0].trim() || "echo" + } + // Default: just return the base command return baseCommand } From 53bb163883608aa3dd9354c405165a9c93d407e8 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 7 Jul 2025 13:04:57 -0600 Subject: [PATCH 3/8] fix: add missing vscode.window mock to fix test failures --- src/core/webview/__tests__/webviewMessageHandler.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index d8dc1801e1..f73a531e2d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -14,6 +14,10 @@ vi.mock("vscode", () => ({ ConfigurationTarget: { Global: 1, }, + window: { + showErrorMessage: vi.fn().mockResolvedValue(undefined), + showInformationMessage: vi.fn().mockResolvedValue(undefined), + }, })) // Mock Package From 2ff969a76afd82b4af678b255cd50fe0da92d221 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 7 Jul 2025 13:26:34 -0600 Subject: [PATCH 4/8] feat: redesign command approval UI with separate whitelist functionality - Remove three-button layout (Run, Add & Run, Reject) - Implement two-row design with Run Command/Reject buttons on top - Add pattern display and 'Always allow' button on bottom row - 'Always allow' only whitelists the pattern without running it - Remove addAndRunButtonClicked handling from backend - Add new addToWhitelist message type and handler - Update translations for new UI elements - Remove obsolete tests for old Add & Run functionality --- .../presentAssistantMessage.ts | 35 -- src/core/webview/webviewMessageHandler.ts | 24 + src/shared/WebviewMessage.ts | 2 + webview-ui/src/components/chat/ChatView.tsx | 139 +++--- .../__tests__/ChatView.addAndRun.spec.tsx | 424 ------------------ webview-ui/src/i18n/locales/en/chat.json | 4 + 6 files changed, 105 insertions(+), 523 deletions(-) delete mode 100644 webview-ui/src/components/chat/__tests__/ChatView.addAndRun.spec.tsx diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 3b11a60baa..ee3fa148b4 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -274,41 +274,6 @@ export async function presentAssistantMessage(cline: Task) { isProtected || false, ) - // Handle "Add & Run" button for command approval - if (response === "addAndRunButtonClicked" && type === "command") { - // The text field contains the extracted command pattern when "Add & Run" is clicked - if (text) { - // Add the command pattern to allowed commands - const provider = cline.providerRef.deref() - if (provider) { - // Get current allowed commands - const currentState = await provider.getState() - const currentAllowedCommands = currentState.allowedCommands || [] - - // Add the new pattern if it's not already in the list - if (!currentAllowedCommands.includes(text)) { - const updatedCommands = [...currentAllowedCommands, text] - - // Update global state using contextProxy - await provider.contextProxy.setValue("allowedCommands", updatedCommands) - - // Also update workspace settings - const vscode = await import("vscode") - const { Package } = await import("../../shared/package") - await vscode.workspace - .getConfiguration(Package.name) - .update("allowedCommands", updatedCommands, vscode.ConfigurationTarget.Global) - - // Post state update to webview - await provider.postStateToWebview() - } - } - } - - // Return true to indicate approval and continue with command execution - return true - } - if (response !== "yesButtonClicked") { // Handle both messageResponse and noButtonClicked with text. if (text) { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index af6b04f9e6..c646224ba5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -587,6 +587,30 @@ export const webviewMessageHandler = async ( break } + case "addToWhitelist": { + // Handle adding a command pattern to the whitelist without running it + if (message.pattern) { + // Get current allowed commands + const currentAllowedCommands = getGlobalState("allowedCommands") || [] + + // Add the new pattern if it's not already in the list + if (!currentAllowedCommands.includes(message.pattern)) { + const updatedCommands = [...currentAllowedCommands, message.pattern] + + // Update global state + await updateGlobalState("allowedCommands", updatedCommands) + + // Also update workspace settings + await vscode.workspace + .getConfiguration(Package.name) + .update("allowedCommands", updatedCommands, vscode.ConfigurationTarget.Global) + + // Post state update to webview + await provider.postStateToWebview() + } + } + break + } case "openCustomModesSettings": { const customModesFilePath = await provider.customModesManager.getCustomModesFilePath() diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 7de420e695..a261e4e711 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -197,6 +197,7 @@ export interface WebviewMessage { | "checkRulesDirectoryResult" | "saveCodeIndexSettingsAtomic" | "requestCodeIndexSecretStatus" + | "addToWhitelist" text?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" disabled?: boolean @@ -238,6 +239,7 @@ export interface WebviewMessage { hasContent?: boolean // For checkRulesDirectoryResult checkOnly?: boolean // For deleteCustomMode check commandPattern?: string // For "Add & Run" button - the extracted command pattern to whitelist + pattern?: string // For "addToWhitelist" message - the command pattern to add to whitelist codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 4d4f484c97..c18d64bf9c 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -330,8 +330,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - switch (clineAsk) { - case "command": - // For the "Add & Run" button on command approval - // Extract the command pattern for whitelisting - const commandMessage = findLast( - messagesRef.current, - (msg) => msg.type === "ask" && msg.ask === "command", - ) - const commandText = commandMessage?.text || "" - const pattern = extractCommandPattern(commandText) - - // Send the pattern in the text field as expected by the backend - vscode.postMessage({ - type: "askResponse", - askResponse: "addAndRunButtonClicked", - text: pattern, // Send pattern in text field - images: images || [], - }) - // Clear input state after sending - setInputValue("") - setSelectedImages([]) - break - } - setSendingDisabled(true) - setClineAsk(undefined) - setEnableButtons(false) - }, - [clineAsk], // messagesRef is stable - ) - const handleSecondaryButtonClick = useCallback( (text?: string, images?: string[]) => { const trimmedInput = text?.trim() @@ -767,9 +735,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) : ( <> - {/* Three button layout for command approval */} - {tertiaryButtonText && clineAsk === "command" && !isStreaming ? ( + {/* Command approval with auto-approve pattern */} + {clineAsk === "command" && !isStreaming ? (
- {/* Top row: Run and Add & Run */} + {/* Top row: Run Command and Reject */}
handlePrimaryButtonClick(inputValue, selectedImages)}> + onClick={() => + handlePrimaryButtonClick(inputValue, selectedImages) + }> {primaryButtonText} + + + handleSecondaryButtonClick(inputValue, selectedImages) + }> + {secondaryButtonText} + + +
+ {/* Bottom row: Auto-approve pattern */} +
+
+ {(() => { + const commandMessage = findLast( + messagesRef.current, + (msg) => msg.type === "ask" && msg.ask === "command", + ) + const commandText = commandMessage?.text || "" + const pattern = extractCommandPattern(commandText) + return pattern || commandText + })()} +
{ const commandMessage = findLast( @@ -1717,28 +1708,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction handleTertiaryButtonClick(inputValue, selectedImages)}> - {secondaryButtonText} + onClick={() => { + // Extract the command pattern + const commandMessage = findLast( + messagesRef.current, + (msg) => msg.type === "ask" && msg.ask === "command", + ) + const commandText = commandMessage?.text || "" + const pattern = extractCommandPattern(commandText) + + // Add to whitelist without running + vscode.postMessage({ + type: "addToWhitelist", + pattern: pattern, + }) + + // Clear the ask state + setSendingDisabled(true) + setClineAsk(undefined) + setEnableButtons(false) + }}> + {t("chat:alwaysAllow.title")}
- {/* Bottom row: Reject */} - - handleSecondaryButtonClick(inputValue, selectedImages)}> - {tertiaryButtonText} - -
) : ( /* Standard two button layout */ @@ -1754,23 +1753,33 @@ const ChatViewComponent: React.ForwardRefRenderFunction handlePrimaryButtonClick(inputValue, selectedImages)}> + className={ + secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0" + } + onClick={() => + handlePrimaryButtonClick(inputValue, selectedImages) + }> {primaryButtonText}
@@ -1792,7 +1801,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction handleSecondaryButtonClick(inputValue, selectedImages)}> + onClick={() => + handleSecondaryButtonClick(inputValue, selectedImages) + }> {isStreaming ? t("chat:cancel.title") : secondaryButtonText} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.addAndRun.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.addAndRun.spec.tsx deleted file mode 100644 index fe4a6a3d49..0000000000 --- a/webview-ui/src/components/chat/__tests__/ChatView.addAndRun.spec.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import React from "react" -import { render, screen, fireEvent, waitFor } from "@testing-library/react" -import { vi } from "vitest" -import ChatView from "../ChatView" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" -import { TranslationProvider } from "@src/i18n/TranslationContext" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { TooltipProvider } from "@/components/ui/tooltip" -import { vscode } from "@src/utils/vscode" - -// Mock vscode API -vi.mock("@src/utils/vscode", () => ({ - vscode: { - postMessage: vi.fn(), - }, -})) - -// Mock extract-command-pattern -vi.mock("@src/utils/extract-command-pattern", () => ({ - extractCommandPattern: vi.fn((command: string) => { - // Simple mock implementation - if (command === "npm test") return "npm test" - if (command === "cd /path/to/project && npm run build:prod --verbose") return "cd * && npm run *" - return command - }), - getPatternDescription: vi.fn(() => "matches similar commands"), -})) - -// Mock use-sound -vi.mock("use-sound", () => ({ - default: () => [vi.fn()], -})) - -// Mock react-use -vi.mock("react-use", () => ({ - useEvent: vi.fn(), - useMount: vi.fn(), - useDeepCompareEffect: (fn: () => void, deps: any[]) => { - // Use regular useEffect for testing - // eslint-disable-next-line @typescript-eslint/no-require-imports - const React = require("react") - // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(fn, deps) - }, - useWindowSize: () => ({ width: 1024, height: 768 }), -})) - -// Mock debounce -vi.mock("debounce", () => ({ - default: (fn: any) => fn, -})) - -// Mock react-virtuoso -vi.mock("react-virtuoso", () => ({ - Virtuoso: ({ data, itemContent }: any) => ( -
- {data?.map((item: any, index: number) =>
{itemContent(index, item)}
)} -
- ), -})) - -// Mock all problematic dependencies -vi.mock("rehype-highlight", () => ({ - default: () => () => {}, -})) - -vi.mock("hast-util-to-text", () => ({ - default: () => "", -})) - -// Mock components that use ESM dependencies -vi.mock("../BrowserSessionRow", () => ({ - default: function MockBrowserSessionRow({ messages }: { messages: any[] }) { - return
{JSON.stringify(messages)}
- }, -})) - -vi.mock("../ChatRow", () => ({ - default: function MockChatRow({ message }: { message: any }) { - // Render the buttons if this is a command ask message - if (message.type === "ask" && message.ask === "command") { - return ( -
-
Command: {message.text}
-
- ) - } - return
{JSON.stringify(message)}
- }, -})) - -vi.mock("../TaskHeader", () => ({ - default: function MockTaskHeader({ task }: { task: any }) { - return
Task: {task.text}
- }, -})) - -vi.mock("../AutoApproveMenu", () => ({ - default: () => null, -})) - -vi.mock("@src/components/common/CodeBlock", () => ({ - default: () => null, - CODE_BLOCK_BG_COLOR: "rgb(30, 30, 30)", -})) - -vi.mock("@src/components/common/CodeAccordian", () => ({ - default: () => null, -})) - -vi.mock("@src/components/chat/ContextMenu", () => ({ - default: () => null, -})) - -// Mock i18n setup -vi.mock("@src/i18n/setup", () => { - const mockT = (key: string, _options?: any) => { - const translations: Record = { - "chat:runCommand.title": "Run Command", - "chat:addAndRunCommand.title": "Add & Run", - "chat:reject.title": "Reject", - "chat:typeMessage": "Type a message...", - "chat:typeTask": "Type a task...", - } - return translations[key] || key - } - - const mockI18n = { - language: "en", - changeLanguage: vi.fn(), - t: mockT, - use: vi.fn().mockReturnThis(), - init: vi.fn().mockReturnThis(), - } - - return { - default: mockI18n, - loadTranslations: vi.fn(), - } -}) - -// Mock react-i18next -vi.mock("react-i18next", () => { - const mockT = (key: string, _options?: any) => { - const translations: Record = { - "chat:runCommand.title": "Run Command", - "chat:addAndRunCommand.title": "Add & Run", - "chat:reject.title": "Reject", - "chat:typeMessage": "Type a message...", - "chat:typeTask": "Type a task...", - } - return translations[key] || key - } - - const mockI18n = { - language: "en", - changeLanguage: vi.fn(), - t: mockT, - use: vi.fn().mockReturnThis(), - init: vi.fn().mockReturnThis(), - } - - return { - useTranslation: () => ({ - t: mockT, - i18n: mockI18n, - }), - Trans: ({ i18nKey, children: _children }: any) => {i18nKey}, - initReactI18next: { - type: "3rdParty", - init: vi.fn(), - }, - } -}) - -// Mock window.postMessage to trigger state hydration -const mockPostMessage = (state: any) => { - window.postMessage( - { - type: "state", - state: { - version: "1.0.0", - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - allowedCommands: [], - alwaysAllowExecute: false, - autoApprovalEnabled: false, - alwaysAllowBrowser: false, - alwaysAllowReadOnly: false, - alwaysAllowReadOnlyOutsideWorkspace: false, - alwaysAllowWrite: false, - alwaysAllowWriteOutsideWorkspace: false, - alwaysAllowWriteProtected: false, - alwaysAllowMcp: false, - alwaysAllowModeSwitch: false, - alwaysAllowSubtasks: false, - writeDelayMs: 0, - mode: "code", - customModes: [], - telemetrySetting: "enabled", - hasSystemPromptOverride: false, - historyPreviewCollapsed: false, - soundEnabled: false, - soundVolume: 0.5, - cwd: "/test", - filePaths: [], - openedTabs: [], - currentApiConfigName: "test-config", - listApiConfigMeta: [], - pinnedApiConfigs: {}, - customModePrompts: {}, - codebaseIndexConfig: { codebaseIndexEnabled: false }, - ...state, - }, - }, - "*", - ) -} - -describe("ChatView - Add & Run Button", () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - - const renderChatView = () => { - return render( - - - - - {}} /> - - - - , - ) - } - - beforeEach(() => { - vi.clearAllMocks() - // Mock window.AUDIO_BASE_URI - ;(window as any).AUDIO_BASE_URI = "" - }) - - it("should display three buttons for command approval", async () => { - renderChatView() - - // Hydrate state with a task message first, then a command ask - mockPostMessage({ - clineMessages: [ - { - type: "say", - say: "task", - ts: Date.now() - 2000, - text: "Initial task", - partial: false, - }, - { - type: "ask", - ask: "command", - ts: Date.now(), - text: "npm test", - partial: false, - }, - ], - }) - - // Wait for the buttons to appear - await waitFor( - () => { - expect(screen.getByText("Run Command")).toBeInTheDocument() - }, - { timeout: 5000 }, - ) - - expect(screen.getByText("Add & Run")).toBeInTheDocument() - expect(screen.getByText("Reject")).toBeInTheDocument() - }) - - it("should send the command pattern when Add & Run button is clicked", async () => { - renderChatView() - - // Hydrate state with a task message first, then a command ask - mockPostMessage({ - clineMessages: [ - { - type: "say", - say: "task", - ts: Date.now() - 2000, - text: "Initial task", - partial: false, - }, - { - type: "ask", - ask: "command", - ts: Date.now(), - text: "npm test", - partial: false, - }, - ], - }) - - // Wait for the buttons to appear - await waitFor( - () => { - expect(screen.getByText("Run Command")).toBeInTheDocument() - expect(screen.getByText("Add & Run")).toBeInTheDocument() - expect(screen.getByText("Reject")).toBeInTheDocument() - }, - { timeout: 5000 }, - ) - - // Click the Add & Run button - const addAndRunButton = screen.getByText("Add & Run") - fireEvent.click(addAndRunButton) - - // Verify the correct message was sent - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "askResponse", - askResponse: "addAndRunButtonClicked", - text: "npm test", // The extracted pattern - images: [], - }) - }) - - it("should extract and send the correct pattern for complex commands", async () => { - const complexCommand = "cd /path/to/project && npm run build:prod --verbose" - - renderChatView() - - // Hydrate state with a task message first, then a command ask - mockPostMessage({ - clineMessages: [ - { - type: "say", - say: "task", - ts: Date.now() - 2000, - text: "Build project", - partial: false, - }, - { - type: "ask", - ask: "command", - ts: Date.now(), - text: complexCommand, - partial: false, - }, - ], - }) - - // Wait for the buttons to appear - await waitFor( - () => { - expect(screen.getByText("Add & Run")).toBeInTheDocument() - }, - { timeout: 5000 }, - ) - - // Click the Add & Run button - const addAndRunButton = screen.getByText("Add & Run") - fireEvent.click(addAndRunButton) - - // Verify the correct pattern was extracted and sent - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "askResponse", - askResponse: "addAndRunButtonClicked", - text: "cd * && npm run *", // The extracted pattern - images: [], - }) - }) - - it("should handle commands with user input", async () => { - renderChatView() - - // Hydrate state with a task message first, then a command ask - mockPostMessage({ - clineMessages: [ - { - type: "say", - say: "task", - ts: Date.now() - 2000, - text: "Initial task", - partial: false, - }, - { - type: "ask", - ask: "command", - ts: Date.now(), - text: "npm test", - partial: false, - }, - ], - }) - - // Wait for the buttons and input to appear - await waitFor( - () => { - expect(screen.getByText("Add & Run")).toBeInTheDocument() - }, - { timeout: 5000 }, - ) - - // Type some user input - const textarea = screen.getByPlaceholderText(/Type a message/i) - fireEvent.change(textarea, { target: { value: "additional feedback" } }) - - // Click the Add & Run button - const addAndRunButton = screen.getByText("Add & Run") - fireEvent.click(addAndRunButton) - - // Verify the pattern was sent (not the user input) - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "askResponse", - askResponse: "addAndRunButtonClicked", - text: "npm test", // The extracted pattern, not the user input - images: [], - }) - }) -}) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index cee58aedca..cf8d4c8a4b 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -71,6 +71,10 @@ "title": "Add & Run", "tooltip": "Add command pattern to whitelist and run" }, + "alwaysAllow": { + "title": "Always allow", + "tooltip": "Add this command pattern to the whitelist" + }, "proceedWhileRunning": { "title": "Proceed While Running", "tooltip": "Continue despite warnings" From c58268b88f912d6db24dacd110ea0339bbfb6c9c Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 7 Jul 2025 13:40:15 -0600 Subject: [PATCH 5/8] feat: add translations for alwaysAllow button --- webview-ui/src/i18n/locales/ca/chat.json | 4 ++++ webview-ui/src/i18n/locales/de/chat.json | 4 ++++ webview-ui/src/i18n/locales/es/chat.json | 4 ++++ webview-ui/src/i18n/locales/fr/chat.json | 4 ++++ webview-ui/src/i18n/locales/hi/chat.json | 4 ++++ webview-ui/src/i18n/locales/id/chat.json | 4 ++++ webview-ui/src/i18n/locales/it/chat.json | 4 ++++ webview-ui/src/i18n/locales/ja/chat.json | 4 ++++ webview-ui/src/i18n/locales/ko/chat.json | 4 ++++ webview-ui/src/i18n/locales/nl/chat.json | 4 ++++ webview-ui/src/i18n/locales/pl/chat.json | 4 ++++ webview-ui/src/i18n/locales/pt-BR/chat.json | 4 ++++ webview-ui/src/i18n/locales/ru/chat.json | 4 ++++ webview-ui/src/i18n/locales/tr/chat.json | 4 ++++ webview-ui/src/i18n/locales/vi/chat.json | 4 ++++ webview-ui/src/i18n/locales/zh-CN/chat.json | 4 ++++ webview-ui/src/i18n/locales/zh-TW/chat.json | 4 ++++ 17 files changed, 68 insertions(+) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index ae1aae9a71..31b83e0052 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -63,6 +63,10 @@ "title": "Afegeix i executa", "tooltip": "Afegeix el patró de comanda a la llista blanca i executa-la" }, + "alwaysAllow": { + "title": "Permet sempre", + "tooltip": "Afegeix aquest patró de comanda a la llista blanca" + }, "proceedWhileRunning": { "title": "Continuar mentre s'executa", "tooltip": "Continua malgrat els advertiments" diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index dd4c1b7936..16c22c85e5 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -63,6 +63,10 @@ "title": "Hinzufügen & Ausführen", "tooltip": "Befehlsmuster zur Whitelist hinzufügen und ausführen" }, + "alwaysAllow": { + "title": "Immer erlauben", + "tooltip": "Dieses Befehlsmuster zur Whitelist hinzufügen" + }, "proceedWhileRunning": { "title": "Während Ausführung fortfahren", "tooltip": "Trotz Warnungen fortfahren" diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 5484c655fc..84f059b499 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -63,6 +63,10 @@ "title": "Añadir y ejecutar", "tooltip": "Añadir patrón de comando a la lista blanca y ejecutar" }, + "alwaysAllow": { + "title": "Permitir siempre", + "tooltip": "Añadir este patrón de comando a la lista blanca" + }, "proceedWhileRunning": { "title": "Continuar mientras se ejecuta", "tooltip": "Continuar a pesar de las advertencias" diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 3fe78e1a2b..c5a933a7e4 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -63,6 +63,10 @@ "title": "Ajouter et exécuter", "tooltip": "Ajouter le modèle de commande à la liste blanche et exécuter" }, + "alwaysAllow": { + "title": "Toujours autoriser", + "tooltip": "Ajouter ce modèle de commande à la liste blanche" + }, "proceedWhileRunning": { "title": "Continuer pendant l'exécution", "tooltip": "Continuer malgré les avertissements" diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 2d48653f69..9515585bfe 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -63,6 +63,10 @@ "title": "जोड़ें और चलाएं", "tooltip": "कमांड पैटर्न को व्हाइटलिस्ट में जोड़ें और चलाएं" }, + "alwaysAllow": { + "title": "हमेशा अनुमति दें", + "tooltip": "इस कमांड पैटर्न को श्वेतसूची में जोड़ें" + }, "proceedWhileRunning": { "title": "चलते समय आगे बढ़ें", "tooltip": "चेतावनियों के बावजूद जारी रखें" diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 3e624139f0..76fa181a2d 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -77,6 +77,10 @@ "title": "Tambah & Jalankan", "tooltip": "Tambahkan pola perintah ke daftar putih dan jalankan" }, + "alwaysAllow": { + "title": "Selalu izinkan", + "tooltip": "Tambahkan pola perintah ini ke daftar putih" + }, "proceedWhileRunning": { "title": "Lanjutkan Saat Berjalan", "tooltip": "Lanjutkan meskipun ada peringatan" diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 55f4d2a260..891d835004 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -63,6 +63,10 @@ "title": "Aggiungi ed esegui", "tooltip": "Aggiungi il pattern del comando alla whitelist ed eseguilo" }, + "alwaysAllow": { + "title": "Consenti sempre", + "tooltip": "Aggiungi questo pattern di comando alla whitelist" + }, "proceedWhileRunning": { "title": "Procedi durante l'esecuzione", "tooltip": "Continua nonostante gli avvisi" diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index ca33bce27e..d3d7b4ca97 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -63,6 +63,10 @@ "title": "追加して実行", "tooltip": "コマンドパターンをホワイトリストに追加して実行します" }, + "alwaysAllow": { + "title": "常に許可", + "tooltip": "このコマンドパターンをホワイトリストに追加します" + }, "proceedWhileRunning": { "title": "実行中も続行", "tooltip": "警告にもかかわらず続行" diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index b74980a257..5895e59aa7 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -63,6 +63,10 @@ "title": "추가 및 실행", "tooltip": "허용 목록에 명령 패턴을 추가하고 실행합니다." }, + "alwaysAllow": { + "title": "항상 허용", + "tooltip": "이 명령어 패턴을 허용 목록에 추가" + }, "proceedWhileRunning": { "title": "실행 중에도 계속", "tooltip": "경고에도 불구하고 계속 진행" diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 429c4170af..d6ce044723 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -63,6 +63,10 @@ "title": "Toevoegen & uitvoeren", "tooltip": "Commandopatroon toevoegen aan de witte lijst en uitvoeren" }, + "alwaysAllow": { + "title": "Altijd toestaan", + "tooltip": "Voeg dit commandopatroon toe aan de whitelist" + }, "proceedWhileRunning": { "title": "Doorgaan tijdens uitvoeren", "tooltip": "Ga door ondanks waarschuwingen" diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index dd19bb326a..74c9fa7774 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -63,6 +63,10 @@ "title": "Dodaj i uruchom", "tooltip": "Dodaj wzorzec polecenia do białej listy i uruchom" }, + "alwaysAllow": { + "title": "Zawsze pozwalaj", + "tooltip": "Dodaj ten wzorzec polecenia do białej listy" + }, "proceedWhileRunning": { "title": "Kontynuuj podczas wykonywania", "tooltip": "Kontynuuj pomimo ostrzeżeń" diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 9238fb18ad..5b9a6c1838 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -63,6 +63,10 @@ "title": "Adicionar & Executar", "tooltip": "Adicionar padrão de comando à lista de permissões e executar" }, + "alwaysAllow": { + "title": "Sempre permitir", + "tooltip": "Adicionar este padrão de comando à lista de permissões" + }, "proceedWhileRunning": { "title": "Prosseguir durante execução", "tooltip": "Continuar apesar dos avisos" diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 87894211b4..934435546c 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -63,6 +63,10 @@ "title": "Добавить и выполнить", "tooltip": "Добавить шаблон команды в белый список и выполнить" }, + "alwaysAllow": { + "title": "Всегда разрешать", + "tooltip": "Добавить этот шаблон команды в белый список" + }, "proceedWhileRunning": { "title": "Продолжить во время выполнения", "tooltip": "Продолжить несмотря на предупреждения" diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 893228e6d9..5525a818fc 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -63,6 +63,10 @@ "title": "Ekle ve Çalıştır", "tooltip": "Komut desenini beyaz listeye ekle ve çalıştır" }, + "alwaysAllow": { + "title": "Her zaman izin ver", + "tooltip": "Bu komut desenini beyaz listeye ekle" + }, "proceedWhileRunning": { "title": "Çalışırken Devam Et", "tooltip": "Uyarılara rağmen devam et" diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 0852a72b1e..51cbeb9a5d 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -63,6 +63,10 @@ "title": "Thêm & Chạy", "tooltip": "Thêm mẫu lệnh vào danh sách trắng và chạy" }, + "alwaysAllow": { + "title": "Luôn cho phép", + "tooltip": "Thêm mẫu lệnh này vào danh sách trắng" + }, "proceedWhileRunning": { "title": "Tiếp tục trong khi chạy", "tooltip": "Tiếp tục bất chấp cảnh báo" diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index cbc72ab820..c792f3643a 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -63,6 +63,10 @@ "title": "添加并运行", "tooltip": "将命令模式添加到白名单并运行" }, + "alwaysAllow": { + "title": "始终允许", + "tooltip": "将此命令模式添加到白名单" + }, "proceedWhileRunning": { "title": "强制继续", "tooltip": "忽略运行中的命令并继续" diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 88fecaf3dd..a1e8a7370e 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -63,6 +63,10 @@ "title": "新增並執行", "tooltip": "將命令模式新增至白名單並執行" }, + "alwaysAllow": { + "title": "一律允許", + "tooltip": "將此指令模式新增至白名單" + }, "proceedWhileRunning": { "title": "執行時繼續", "tooltip": "儘管有警告仍繼續執行" From c43927b935c017262242a10fa8e5966cb9e57240 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 7 Jul 2025 14:15:15 -0600 Subject: [PATCH 6/8] fix: execute command after whitelisting with 'Always allow' button - Modified addToWhitelist handler to trigger command execution after whitelisting - Fixes issue where chat was stuck after clicking 'Always allow' - Command now gets whitelisted AND executed in one action --- src/core/webview/webviewMessageHandler.ts | 5 ++++- webview-ui/src/components/chat/ChatView.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c646224ba5..9b2e3286ac 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -588,7 +588,7 @@ export const webviewMessageHandler = async ( break } case "addToWhitelist": { - // Handle adding a command pattern to the whitelist without running it + // Handle adding a command pattern to the whitelist and then running it if (message.pattern) { // Get current allowed commands const currentAllowedCommands = getGlobalState("allowedCommands") || [] @@ -608,6 +608,9 @@ export const webviewMessageHandler = async ( // Post state update to webview await provider.postStateToWebview() } + + // After whitelisting, approve the pending command execution + provider.getCurrentCline()?.handleWebviewAskResponse("yesButtonClicked") } break } diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index c18d64bf9c..176f3e1ce3 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1659,7 +1659,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction {/* Command approval with auto-approve pattern */} {clineAsk === "command" && !isStreaming ? ( -
+
{/* Top row: Run Command and Reject */}
From 40b4175a373741f9b8b53928f40f03603de394de Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 7 Jul 2025 14:29:33 -0600 Subject: [PATCH 7/8] fix: remove extra margin causing spacing issue in command approval UI --- webview-ui/src/components/chat/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 176f3e1ce3..c18d64bf9c 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1659,7 +1659,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction {/* Command approval with auto-approve pattern */} {clineAsk === "command" && !isStreaming ? ( -
+
{/* Top row: Run Command and Reject */}
From 961b409b37fd71988d44b438fe14292c28a8c4be Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 7 Jul 2025 14:33:02 -0600 Subject: [PATCH 8/8] fix: add bottom padding to button container for proper spacing --- webview-ui/src/components/chat/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index c18d64bf9c..c188576047 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1634,7 +1634,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction