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` +}