From 6fda13643cd915b521c83272bbc0e02dd47389c3 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 16 Jul 2025 18:27:33 -0600 Subject: [PATCH 01/31] feat: Add terminal command permissions UI to chat interface (#5480) --- .../src/components/chat/CommandExecution.tsx | 100 ++++++- .../chat/CommandPatternSelector.tsx | 130 ++++++++ .../chat/__tests__/CommandExecution.spec.tsx | 277 ++++++++++++++++++ .../__tests__/CommandPatternSelector.spec.tsx | 252 ++++++++++++++++ webview-ui/src/i18n/locales/ca/chat.json | 16 + webview-ui/src/i18n/locales/de/chat.json | 16 + webview-ui/src/i18n/locales/en/chat.json | 16 + webview-ui/src/i18n/locales/es/chat.json | 16 + webview-ui/src/i18n/locales/fr/chat.json | 16 + webview-ui/src/i18n/locales/hi/chat.json | 16 + webview-ui/src/i18n/locales/id/chat.json | 16 + webview-ui/src/i18n/locales/it/chat.json | 16 + webview-ui/src/i18n/locales/ja/chat.json | 16 + webview-ui/src/i18n/locales/ko/chat.json | 16 + webview-ui/src/i18n/locales/nl/chat.json | 16 + webview-ui/src/i18n/locales/pl/chat.json | 16 + webview-ui/src/i18n/locales/pt-BR/chat.json | 16 + webview-ui/src/i18n/locales/ru/chat.json | 16 + webview-ui/src/i18n/locales/tr/chat.json | 16 + webview-ui/src/i18n/locales/vi/chat.json | 16 + webview-ui/src/i18n/locales/zh-CN/chat.json | 16 + webview-ui/src/i18n/locales/zh-TW/chat.json | 16 + .../utils/__tests__/commandPatterns.spec.ts | 275 +++++++++++++++++ webview-ui/src/utils/commandPatterns.ts | 197 +++++++++++++ 24 files changed, 1513 insertions(+), 6 deletions(-) create mode 100644 webview-ui/src/components/chat/CommandPatternSelector.tsx create mode 100644 webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx create mode 100644 webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx create mode 100644 webview-ui/src/utils/__tests__/commandPatterns.spec.ts create mode 100644 webview-ui/src/utils/commandPatterns.ts diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 8c92ec7e7b6..a2b63ec5bff 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -13,6 +13,13 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { cn } from "@src/lib/utils" import { Button } from "@src/components/ui" import CodeBlock from "../common/CodeBlock" +import { CommandPatternSelector } from "./CommandPatternSelector" +import { + extractCommandPatterns, + getPatternDescription, + parseCommandAndOutput as parseCommandAndOutputUtil, + CommandPattern, +} from "../../utils/commandPatterns" interface CommandExecutionProps { executionId: string @@ -22,21 +29,91 @@ interface CommandExecutionProps { } export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => { - const { terminalShellIntegrationDisabled = false } = useExtensionState() - - const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text]) + const { + terminalShellIntegrationDisabled = false, + allowedCommands = [], + deniedCommands = [], + setAllowedCommands, + setDeniedCommands, + } = useExtensionState() + + const { + command, + output: parsedOutput, + suggestions, + } = useMemo(() => { + // First try our enhanced parser + const enhanced = parseCommandAndOutputUtil(text || "") + // If it found a command, use it, otherwise fall back to the original parser + if (enhanced.command && enhanced.command !== text) { + return enhanced + } + // Fall back to original parser + const original = parseCommandAndOutput(text) + return { ...original, suggestions: [] } + }, [text]) // If we aren't opening the VSCode terminal for this command then we default // to expanding the command execution output. const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) const [streamingOutput, setStreamingOutput] = useState("") const [status, setStatus] = useState(null) + const [showSuggestions] = useState(true) // The command's output can either come from the text associated with the // task message (this is the case for completed commands) or from the // streaming output (this is the case for running commands). const output = streamingOutput || parsedOutput + // Extract command patterns + const commandPatterns = useMemo(() => { + const patterns: CommandPattern[] = [] + + // Use AI suggestions if available + if (suggestions.length > 0) { + suggestions.forEach((suggestion) => { + patterns.push({ + pattern: suggestion, + description: getPatternDescription(suggestion), + }) + }) + } else { + // Extract patterns programmatically + const extractedPatterns = extractCommandPatterns(command) + extractedPatterns.forEach((pattern) => { + patterns.push({ + pattern, + description: getPatternDescription(pattern), + }) + }) + } + + return patterns + }, [command, suggestions]) + + // Handle pattern changes + const handleAllowPatternChange = (pattern: string) => { + const isAllowed = allowedCommands.includes(pattern) + const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern] + const newDenied = deniedCommands.filter((p) => p !== pattern) + + setAllowedCommands(newAllowed) + setDeniedCommands(newDenied) + vscode.postMessage({ type: "allowedCommands", commands: newAllowed }) + vscode.postMessage({ type: "deniedCommands", commands: newDenied }) + } + + const handleDenyPatternChange = (pattern: string) => { + const isDenied = deniedCommands.includes(pattern) + const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern] + const newAllowed = allowedCommands.filter((p) => p !== pattern) + + setAllowedCommands(newAllowed) + setDeniedCommands(newDenied) + vscode.postMessage({ type: "allowedCommands", commands: newAllowed }) + vscode.postMessage({ type: "deniedCommands", commands: newDenied }) + } + const onMessage = useCallback( (event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -121,9 +198,20 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec -
- - +
+
+ + +
+ {showSuggestions && commandPatterns.length > 0 && ( + + )}
) diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx new file mode 100644 index 00000000000..17799a8aecc --- /dev/null +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react" +import { Check, ChevronDown, Info, X } from "lucide-react" +import { cn } from "../../lib/utils" +import { useTranslation, Trans } from "react-i18next" +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { CommandPattern } from "../../utils/commandPatterns" +import { StandardTooltip } from "../ui/standard-tooltip" + +interface CommandPatternSelectorProps { + patterns: CommandPattern[] + allowedCommands: string[] + deniedCommands: string[] + onAllowPatternChange: (pattern: string) => void + onDenyPatternChange: (pattern: string) => void +} + +export const CommandPatternSelector: React.FC = ({ + patterns, + allowedCommands, + deniedCommands, + onAllowPatternChange, + onDenyPatternChange, +}) => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => { + if (allowedCommands.includes(pattern)) return "allowed" + if (deniedCommands.includes(pattern)) return "denied" + return "none" + } + + return ( +
+ + + {isExpanded && ( +
+ {patterns.map((item, index) => { + const status = getPatternStatus(item.pattern) + return ( +
+
+ {item.pattern} + {item.description && ( + + - {item.description} + + )} +
+
+ + +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx new file mode 100644 index 00000000000..eafbde59aae --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -0,0 +1,277 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { CommandExecution } from "../CommandExecution" +import { ExtensionStateContext } from "../../../context/ExtensionStateContext" + +// Mock dependencies +vi.mock("react-use", () => ({ + useEvent: vi.fn(), +})) + +import { vscode } from "../../../utils/vscode" + +vi.mock("../../../utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("../../common/CodeBlock", () => ({ + default: ({ source }: { source: string }) =>
{source}
, +})) + +vi.mock("../CommandPatternSelector", () => ({ + CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => ( +
+ {patterns.map((p: any, i: number) => ( +
+ {p.pattern} + + +
+ ))} +
+ ), +})) + +// Mock ExtensionStateContext +const mockExtensionState = { + terminalShellIntegrationDisabled: false, + allowedCommands: ["npm"], + deniedCommands: ["rm"], + setAllowedCommands: vi.fn(), + setDeniedCommands: vi.fn(), +} + +const ExtensionStateWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe("CommandExecution", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should render command without output", () => { + render( + + + , + ) + + expect(screen.getByTestId("code-block")).toHaveTextContent("npm install") + }) + + it("should render command with output", () => { + render( + + + , + ) + + const codeBlocks = screen.getAllByTestId("code-block") + expect(codeBlocks[0]).toHaveTextContent("npm install") + }) + + it("should render with custom icon and title", () => { + const icon = 📦 + const title = Installing Dependencies + + render( + + + , + ) + + expect(screen.getByTestId("custom-icon")).toBeInTheDocument() + expect(screen.getByTestId("custom-title")).toBeInTheDocument() + }) + + it("should show command pattern selector for simple commands", () => { + render( + + + , + ) + + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + expect(screen.getByText("npm")).toBeInTheDocument() + expect(screen.getByText("npm install")).toBeInTheDocument() + }) + + it("should handle allow pattern change", () => { + render( + + + , + ) + + const allowButton = screen.getByText("Allow git") + fireEvent.click(allowButton) + + expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git"]) + expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git"] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) + }) + + it("should handle deny pattern change", () => { + render( + + + , + ) + + const denyButton = screen.getByText("Deny docker") + fireEvent.click(denyButton) + + expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) + expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker"]) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker"] }) + }) + + it("should toggle allowed pattern", () => { + render( + + + , + ) + + const allowButton = screen.getByText("Allow npm") + fireEvent.click(allowButton) + + // npm is already in allowedCommands, so it should be removed + expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith([]) + expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: [] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) + }) + + it("should toggle denied pattern", () => { + render( + + + , + ) + + const denyButton = screen.getByText("Deny rm") + fireEvent.click(denyButton) + + // rm is already in deniedCommands, so it should be removed + expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) + expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([]) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) + }) + + it("should parse command with $ prefix", () => { + render( + + + , + ) + + expect(screen.getByTestId("code-block")).toHaveTextContent("npm install") + }) + + it("should parse command with AI suggestions", () => { + render( + + + , + ) + + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + // Check that the patterns are present in the mock + expect(screen.getByText("npm")).toBeInTheDocument() + }) + + it("should handle commands with pipes", () => { + render( + + + , + ) + + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + expect(screen.getByText("ls")).toBeInTheDocument() + expect(screen.getByText("grep")).toBeInTheDocument() + }) + + it("should handle commands with && operator", () => { + render( + + + , + ) + + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + expect(screen.getByText("npm")).toBeInTheDocument() + expect(screen.getByText("npm install")).toBeInTheDocument() + expect(screen.getByText("npm test")).toBeInTheDocument() + }) + + it("should not show pattern selector for empty commands", () => { + render( + + + , + ) + + expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() + }) + + it("should expand output when terminal shell integration is disabled", () => { + const disabledState = { + ...mockExtensionState, + terminalShellIntegrationDisabled: true, + } + + render( + + + , + ) + + // Output should be visible when shell integration is disabled + expect(screen.getByText(/Output here/)).toBeInTheDocument() + }) + + it("should handle undefined allowedCommands and deniedCommands", () => { + const stateWithUndefined = { + ...mockExtensionState, + allowedCommands: undefined, + deniedCommands: undefined, + } + + render( + + + , + ) + + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + }) + + it("should handle pattern change when moving from denied to allowed", () => { + render( + + + , + ) + + const allowButton = screen.getByText("Allow rm") + fireEvent.click(allowButton) + + // rm should be removed from denied and added to allowed + expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm"]) + expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([]) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm"] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx new file mode 100644 index 00000000000..4dd69e3969a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -0,0 +1,252 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { CommandPatternSelector } from "../CommandPatternSelector" +import { CommandPattern } from "../../../utils/commandPatterns" + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ i18nKey, components }: any) => { + if (i18nKey === "chat:commandExecution.commandManagementDescription") { + return ( + + Manage command permissions: Click ✓ to allow auto-execution, ✗ to deny execution. Patterns can be + toggled on/off or removed from lists. {components.settingsLink} + + ) + } + return {i18nKey} + }, +})) + +// Mock VSCodeLink +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: ({ children, onClick }: any) => ( + + {children || "View all settings"} + + ), +})) + +// Mock StandardTooltip +vi.mock("../../ui/standard-tooltip", () => ({ + StandardTooltip: ({ children, content }: any) => ( +
+ {children} + {/* Render the content to make it testable */} +
{content}
+
+ ), +})) + +// Mock window.postMessage +const mockPostMessage = vi.fn() +window.postMessage = mockPostMessage + +describe("CommandPatternSelector", () => { + const mockPatterns: CommandPattern[] = [ + { pattern: "npm", description: "npm commands" }, + { pattern: "npm install", description: "npm install commands" }, + { pattern: "git", description: "git commands" }, + ] + + const defaultProps = { + patterns: mockPatterns, + allowedCommands: ["npm"], + deniedCommands: ["git"], + onAllowPatternChange: vi.fn(), + onDenyPatternChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should render collapsed by default", () => { + render() + + expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument() + expect(screen.queryByText("npm commands")).not.toBeInTheDocument() + }) + + it("should expand when clicked", () => { + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + // Check for the patterns themselves + expect(screen.getByText("npm")).toBeInTheDocument() + expect(screen.getByText("npm install")).toBeInTheDocument() + expect(screen.getByText("git")).toBeInTheDocument() + + // Check for the descriptions + expect(screen.getByText("- npm commands")).toBeInTheDocument() + expect(screen.getByText("- npm install commands")).toBeInTheDocument() + expect(screen.getByText("- git commands")).toBeInTheDocument() + }) + + it("should collapse when clicked again", () => { + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + const collapseButton = screen.getByRole("button", { name: "chat:commandExecution.collapseManagement" }) + fireEvent.click(collapseButton) + + expect(screen.queryByText("npm commands")).not.toBeInTheDocument() + }) + + it("should show correct status for patterns", () => { + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + // Check that npm has allowed styling (green) + const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0] + expect(npmAllowButton).toHaveClass("bg-green-500/20") + + // Check that git has denied styling (red) + const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0] + expect(gitDenyButton).toHaveClass("bg-red-500/20") + }) + + it("should call onAllowPatternChange when allow button is clicked", () => { + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + // Find all allow buttons with the "add to allowed" label + const allowButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToAllowed" }) + + // The second one should be for npm install (first is npm which is already allowed) + fireEvent.click(allowButtons[0]) + + expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm install") + }) + + it("should call onDenyPatternChange when deny button is clicked", () => { + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + // Find all deny buttons with the "add to denied" label + const denyButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToDenied" }) + + // The second one should be for npm install (first is npm, third is git which is already denied) + fireEvent.click(denyButtons[1]) + + expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("npm install") + }) + + it("should toggle allowed pattern when clicked", () => { + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + // Find the allow button for npm (which is already allowed) + const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0] + fireEvent.click(npmAllowButton) + + expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm") + }) + + it("should toggle denied pattern when clicked", () => { + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + // Find the deny button for git (which is already denied) + const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0] + fireEvent.click(gitDenyButton) + + expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("git") + }) + + it("should have tooltip with settings link", () => { + const { container } = render() + + // The info icon should have a tooltip + const tooltipWrapper = container.querySelector('[title="tooltip"]') + expect(tooltipWrapper).toBeTruthy() + + // The tooltip content includes a settings link (mocked as VSCodeLink) + // It's rendered in a hidden div for testing purposes + const settingsLink = container.querySelector('a[href="#"]') + expect(settingsLink).toBeTruthy() + expect(settingsLink?.textContent).toBe("View all settings") + + // Test that clicking the link posts the correct message + if (settingsLink) { + fireEvent.click(settingsLink) + + expect(mockPostMessage).toHaveBeenCalledWith( + { + type: "action", + action: "settingsButtonClicked", + values: { section: "autoApprove" }, + }, + "*", + ) + } + }) + + it("should render with empty patterns", () => { + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + // The expanded view should exist but be empty since there are no patterns + const expandedContent = screen + .getByRole("button", { name: "chat:commandExecution.collapseManagement" }) + .parentElement?.querySelector(".px-3.pb-3") + expect(expandedContent).toBeInTheDocument() + expect(expandedContent?.children.length).toBe(0) + }) + + it("should render patterns without descriptions", () => { + const patternsWithoutDesc: CommandPattern[] = [{ pattern: "custom-command" }] + + render() + + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + expect(screen.getByText("custom-command")).toBeInTheDocument() + }) + + it("should always show info icon with tooltip", () => { + const { container } = render() + + // Info icon should always be visible (not just when expanded) + // Look for the Info icon which is wrapped in StandardTooltip + const infoIcon = container.querySelector(".ml-1") + expect(infoIcon).toBeTruthy() + }) + + it("should apply correct classes for chevron rotation", () => { + const { container } = render() + + // Initially collapsed - chevron should be rotated + let chevron = container.querySelector(".size-3.transition-transform") + expect(chevron).toHaveClass("-rotate-90") + + // Click to expand + const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + fireEvent.click(expandButton) + + // When expanded - chevron should not be rotated + chevron = container.querySelector(".size-3.transition-transform") + expect(chevron).toHaveClass("rotate-0") + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 01d9ee1c1a8..17fe22d71e8 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo ha vist noms de definicions de codi font utilitzats en aquest directori (fora de l'espai de treball):" }, "commandOutput": "Sortida de l'ordre", + "commandExecution": { + "running": "Executant", + "pid": "PID: {{pid}}", + "exited": "Finalitzat ({{exitCode}})", + "manageCommands": "Gestiona els permisos de les ordres", + "commandManagementDescription": "Gestiona els permisos de les ordres: Fes clic a ✓ per permetre l'execució automàtica, ✗ per denegar l'execució. Els patrons es poden activar/desactivar o eliminar de les llistes. Mostra tots els paràmetres", + "addToAllowed": "Afegeix a la llista de permesos", + "removeFromAllowed": "Elimina de la llista de permesos", + "addToDenied": "Afegeix a la llista de denegats", + "removeFromDenied": "Elimina de la llista de denegats", + "abortCommand": "Interromp l'execució de l'ordre", + "expandOutput": "Amplia la sortida", + "collapseOutput": "Redueix la sortida", + "expandManagement": "Amplia la secció de gestió d'ordres", + "collapseManagement": "Redueix la secció de gestió d'ordres" + }, "response": "Resposta", "arguments": "Arguments", "mcp": { diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 032145234d9..2f603a37174 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo hat Quellcode-Definitionsnamen in diesem Verzeichnis (außerhalb des Arbeitsbereichs) angezeigt:" }, "commandOutput": "Befehlsausgabe", + "commandExecution": { + "running": "Wird ausgeführt", + "pid": "PID: {{pid}}", + "exited": "Beendet ({{exitCode}})", + "manageCommands": "Befehlsberechtigungen verwalten", + "commandManagementDescription": "Befehlsberechtigungen verwalten: Klicke auf ✓, um die automatische Ausführung zu erlauben, ✗, um die Ausführung zu verweigern. Muster können ein-/ausgeschaltet oder aus Listen entfernt werden. Alle Einstellungen anzeigen", + "addToAllowed": "Zur Liste der erlaubten Befehle hinzufügen", + "removeFromAllowed": "Von der Liste der erlaubten Befehle entfernen", + "addToDenied": "Zur Liste der verweigerten Befehle hinzufügen", + "removeFromDenied": "Von der Liste der verweigerten Befehle entfernen", + "abortCommand": "Befehlsausführung abbrechen", + "expandOutput": "Ausgabe erweitern", + "collapseOutput": "Ausgabe einklappen", + "expandManagement": "Befehlsverwaltungsbereich erweitern", + "collapseManagement": "Befehlsverwaltungsbereich einklappen" + }, "response": "Antwort", "arguments": "Argumente", "mcp": { diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 3bbb3fbf72f..d09c75424d8 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -211,6 +211,22 @@ "resultTooltip": "Similarity score: {{score}} (click to open file)" }, "commandOutput": "Command Output", + "commandExecution": { + "running": "Running", + "pid": "PID: {{pid}}", + "exited": "Exited ({{exitCode}})", + "manageCommands": "Manage Command Permissions", + "commandManagementDescription": "Manage command permissions: Click ✓ to allow auto-execution, ✗ to deny execution. Patterns can be toggled on/off or removed from lists. View all settings", + "addToAllowed": "Add to allowed list", + "removeFromAllowed": "Remove from allowed list", + "addToDenied": "Add to denied list", + "removeFromDenied": "Remove from denied list", + "abortCommand": "Abort command execution", + "expandOutput": "Expand output", + "collapseOutput": "Collapse output", + "expandManagement": "Expand command management section", + "collapseManagement": "Collapse command management section" + }, "response": "Response", "arguments": "Arguments", "mcp": { diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index adcfd1d40cd..97dd41d9525 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo vio nombres de definiciones de código fuente utilizados en este directorio (fuera del espacio de trabajo):" }, "commandOutput": "Salida del comando", + "commandExecution": { + "running": "Ejecutando", + "pid": "PID: {{pid}}", + "exited": "Finalizado ({{exitCode}})", + "manageCommands": "Gestionar permisos de comandos", + "commandManagementDescription": "Gestionar permisos de comandos: Haz clic en ✓ para permitir la ejecución automática, ✗ para denegar la ejecución. Los patrones se pueden activar/desactivar o eliminar de las listas. Ver todos los ajustes", + "addToAllowed": "Añadir a la lista de permitidos", + "removeFromAllowed": "Eliminar de la lista de permitidos", + "addToDenied": "Añadir a la lista de denegados", + "removeFromDenied": "Eliminar de la lista de denegados", + "abortCommand": "Abortar ejecución del comando", + "expandOutput": "Expandir salida", + "collapseOutput": "Contraer salida", + "expandManagement": "Expandir sección de gestión de comandos", + "collapseManagement": "Contraer sección de gestión de comandos" + }, "response": "Respuesta", "arguments": "Argumentos", "mcp": { diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 3e49a648677..1ecbeeca81e 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo a vu les noms de définitions de code source utilisés dans ce répertoire (hors espace de travail) :" }, "commandOutput": "Sortie de commande", + "commandExecution": { + "running": "En cours d'exécution", + "pid": "PID : {{pid}}", + "exited": "Terminé ({{exitCode}})", + "manageCommands": "Gérer les autorisations de commande", + "commandManagementDescription": "Gérer les autorisations de commande : Cliquez sur ✓ pour autoriser l'exécution automatique, ✗ pour refuser l'exécution. Les modèles peuvent être activés/désactivés ou supprimés des listes. Voir tous les paramètres", + "addToAllowed": "Ajouter à la liste autorisée", + "removeFromAllowed": "Retirer de la liste autorisée", + "addToDenied": "Ajouter à la liste refusée", + "removeFromDenied": "Retirer de la liste refusée", + "abortCommand": "Abandonner l'exécution de la commande", + "expandOutput": "Développer la sortie", + "collapseOutput": "Réduire la sortie", + "expandManagement": "Développer la section de gestion des commandes", + "collapseManagement": "Réduire la section de gestion des commandes" + }, "response": "Réponse", "arguments": "Arguments", "mcp": { diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 3b5c7b8a67c..48b99821720 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo ने इस निर्देशिका (कार्यक्षेत्र के बाहर) में उपयोग किए गए सोर्स कोड परिभाषा नामों को देखा:" }, "commandOutput": "कमांड आउटपुट", + "commandExecution": { + "running": "चलाया जा रहा है", + "pid": "पीआईडी: {{pid}}", + "exited": "बाहर निकल गया ({{exitCode}})", + "manageCommands": "कमांड अनुमतियाँ प्रबंधित करें", + "commandManagementDescription": "कमांड अनुमतियों का प्रबंधन करें: स्वतः-निष्पादन की अनुमति देने के लिए ✓ पर क्लिक करें, निष्पादन से इनकार करने के लिए ✗ पर क्लिक करें। पैटर्न को चालू/बंद किया जा सकता है या सूचियों से हटाया जा सकता है। सभी सेटिंग्स देखें", + "addToAllowed": "अनुमत सूची में जोड़ें", + "removeFromAllowed": "अनुमत सूची से हटाएं", + "addToDenied": "अस्वीकृत सूची में जोड़ें", + "removeFromDenied": "अस्वीकृत सूची से हटाएं", + "abortCommand": "कमांड निष्पादन रद्द करें", + "expandOutput": "आउटपुट का विस्तार करें", + "collapseOutput": "आउटपुट संक्षिप्त करें", + "expandManagement": "कमांड प्रबंधन अनुभाग का विस्तार करें", + "collapseManagement": "कमांड प्रबंधन अनुभाग संक्षिप्त करें" + }, "response": "प्रतिक्रिया", "arguments": "आर्ग्युमेंट्स", "mcp": { diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 2ef1cb75e7d..ba20ad324b7 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -214,6 +214,22 @@ "resultTooltip": "Skor kemiripan: {{score}} (klik untuk membuka file)" }, "commandOutput": "Output Perintah", + "commandExecution": { + "running": "Menjalankan", + "pid": "PID: {{pid}}", + "exited": "Keluar ({{exitCode}})", + "manageCommands": "Kelola Izin Perintah", + "commandManagementDescription": "Kelola izin perintah: Klik ✓ untuk mengizinkan eksekusi otomatis, ✗ untuk menolak eksekusi. Pola dapat diaktifkan/dinonaktifkan atau dihapus dari daftar. Lihat semua pengaturan", + "addToAllowed": "Tambahkan ke daftar yang diizinkan", + "removeFromAllowed": "Hapus dari daftar yang diizinkan", + "addToDenied": "Tambahkan ke daftar yang ditolak", + "removeFromDenied": "Hapus dari daftar yang ditolak", + "abortCommand": "Batalkan eksekusi perintah", + "expandOutput": "Perluas output", + "collapseOutput": "Ciutkan output", + "expandManagement": "Perluas bagian manajemen perintah", + "collapseManagement": "Ciutkan bagian manajemen perintah" + }, "response": "Respons", "arguments": "Argumen", "mcp": { diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index eb3984f1bed..55691c71ac5 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo ha visualizzato i nomi delle definizioni di codice sorgente utilizzate in questa directory (fuori dall'area di lavoro):" }, "commandOutput": "Output del comando", + "commandExecution": { + "running": "In esecuzione", + "pid": "PID: {{pid}}", + "exited": "Terminato ({{exitCode}})", + "manageCommands": "Gestisci autorizzazioni comandi", + "commandManagementDescription": "Gestisci le autorizzazioni dei comandi: fai clic su ✓ per consentire l'esecuzione automatica, ✗ per negare l'esecuzione. I pattern possono essere attivati/disattivati o rimossi dagli elenchi. Visualizza tutte le impostazioni", + "addToAllowed": "Aggiungi all'elenco consentiti", + "removeFromAllowed": "Rimuovi dall'elenco consentiti", + "addToDenied": "Aggiungi all'elenco negati", + "removeFromDenied": "Rimuovi dall'elenco negati", + "abortCommand": "Interrompi esecuzione comando", + "expandOutput": "Espandi output", + "collapseOutput": "Comprimi output", + "expandManagement": "Espandi la sezione di gestione dei comandi", + "collapseManagement": "Comprimi la sezione di gestione dei comandi" + }, "response": "Risposta", "arguments": "Argomenti", "mcp": { diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 2f6e6bfab73..d5021032602 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)で使用されているソースコード定義名を表示しました:" }, "commandOutput": "コマンド出力", + "commandExecution": { + "running": "実行中", + "pid": "PID: {{pid}}", + "exited": "終了しました ({{exitCode}})", + "manageCommands": "コマンド権限の管理", + "commandManagementDescription": "コマンドの権限を管理します:✓ をクリックして自動実行を許可し、✗ をクリックして実行を拒否します。パターンはオン/オフの切り替えやリストからの削除が可能です。すべての設定を表示", + "addToAllowed": "許可リストに追加", + "removeFromAllowed": "許可リストから削除", + "addToDenied": "拒否リストに追加", + "removeFromDenied": "拒否リストから削除", + "abortCommand": "コマンドの実行を中止", + "expandOutput": "出力を展開", + "collapseOutput": "出力を折りたたむ", + "expandManagement": "コマンド管理セクションを展開", + "collapseManagement": "コマンド管理セクションを折りたたむ" + }, "response": "応答", "arguments": "引数", "mcp": { diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index f4a5c336026..2fcac36cba9 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)에서 사용된 소스 코드 정의 이름을 보았습니다:" }, "commandOutput": "명령 출력", + "commandExecution": { + "running": "실행 중", + "pid": "PID: {{pid}}", + "exited": "종료됨 ({{exitCode}})", + "manageCommands": "명령 권한 관리", + "commandManagementDescription": "명령 권한 관리: 자동 실행을 허용하려면 ✓를 클릭하고 실행을 거부하려면 ✗를 클릭하십시오. 패턴은 켜거나 끄거나 목록에서 제거할 수 있습니다. 모든 설정 보기", + "addToAllowed": "허용 목록에 추가", + "removeFromAllowed": "허용 목록에서 제거", + "addToDenied": "거부 목록에 추가", + "removeFromDenied": "거부 목록에서 제거", + "abortCommand": "명령 실행 중단", + "expandOutput": "출력 확장", + "collapseOutput": "출력 축소", + "expandManagement": "명령 관리 섹션 확장", + "collapseManagement": "명령 관리 섹션 축소" + }, "response": "응답", "arguments": "인수", "mcp": { diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 1d11db26680..7f295a0b3d3 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -187,6 +187,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo heeft broncode-definitienamen bekeken die in deze map (buiten werkruimte) worden gebruikt:" }, "commandOutput": "Commando-uitvoer", + "commandExecution": { + "running": "Lopend", + "pid": "PID: {{pid}}", + "exited": "Afgesloten ({{exitCode}})", + "manageCommands": "Beheer Commando Toestemmingen", + "commandManagementDescription": "Beheer commando toestemmingen: Klik op ✓ om automatische uitvoering toe te staan, ✗ om uitvoering te weigeren. Patronen kunnen worden in- of uitgeschakeld of uit lijsten worden verwijderd. Bekijk alle instellingen", + "addToAllowed": "Toevoegen aan toegestane lijst", + "removeFromAllowed": "Verwijderen van toegestane lijst", + "addToDenied": "Toevoegen aan geweigerde lijst", + "removeFromDenied": "Verwijderen van geweigerde lijst", + "abortCommand": "Commando-uitvoering afbreken", + "expandOutput": "Uitvoer uitvouwen", + "collapseOutput": "Uitvoer samenvouwen", + "expandManagement": "Beheersectie voor commando's uitvouwen", + "collapseManagement": "Beheersectie voor commando's samenvouwen" + }, "response": "Antwoord", "arguments": "Argumenten", "mcp": { diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 88c58418ad4..807a2da6444 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo zobaczył nazwy definicji kodu źródłowego używane w tym katalogu (poza obszarem roboczym):" }, "commandOutput": "Wyjście polecenia", + "commandExecution": { + "running": "Wykonywanie", + "pid": "PID: {{pid}}", + "exited": "Zakończono ({{exitCode}})", + "manageCommands": "Zarządzaj uprawnieniami poleceń", + "commandManagementDescription": "Zarządzaj uprawnieniami poleceń: Kliknij ✓, aby zezwolić na automatyczne wykonanie, ✗, aby odmówić wykonania. Wzorce można włączać/wyłączać lub usuwać z list. Zobacz wszystkie ustawienia", + "addToAllowed": "Dodaj do listy dozwolonych", + "removeFromAllowed": "Usuń z listy dozwolonych", + "addToDenied": "Dodaj do listy odrzuconych", + "removeFromDenied": "Usuń z listy odrzuconych", + "abortCommand": "Przerwij wykonywanie polecenia", + "expandOutput": "Rozwiń wyjście", + "collapseOutput": "Zwiń wyjście", + "expandManagement": "Rozwiń sekcję zarządzania poleceniami", + "collapseManagement": "Zwiń sekcję zarządzania poleceniami" + }, "response": "Odpowiedź", "arguments": "Argumenty", "mcp": { diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 3784d6cc64d..e73eabfb93e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo visualizou nomes de definição de código-fonte usados neste diretório (fora do espaço de trabalho):" }, "commandOutput": "Saída do comando", + "commandExecution": { + "running": "Executando", + "pid": "PID: {{pid}}", + "exited": "Encerrado ({{exitCode}})", + "manageCommands": "Gerenciar Permissões de Comando", + "commandManagementDescription": "Gerencie as permissões de comando: Clique em ✓ para permitir a execução automática, ✗ para negar a execução. Os padrões podem ser ativados/desativados ou removidos das listas. Ver todas as configurações", + "addToAllowed": "Adicionar à lista de permitidos", + "removeFromAllowed": "Remover da lista de permitidos", + "addToDenied": "Adicionar à lista de negados", + "removeFromDenied": "Remover da lista de negados", + "abortCommand": "Abortar execução do comando", + "expandOutput": "Expandir saída", + "collapseOutput": "Recolher saída", + "expandManagement": "Expandir seção de gerenciamento de comandos", + "collapseManagement": "Recolher seção de gerenciamento de comandos" + }, "response": "Resposta", "arguments": "Argumentos", "mcp": { diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 0660d3e1d6d..01987bdda1f 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -187,6 +187,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo просмотрел имена определений исходного кода в этой директории (вне рабочего пространства):" }, "commandOutput": "Вывод команды", + "commandExecution": { + "running": "Выполняется", + "pid": "PID: {{pid}}", + "exited": "Завершено ({{exitCode}})", + "manageCommands": "Управление разрешениями команд", + "commandManagementDescription": "Управляйте разрешениями команд: Нажмите ✓, чтобы разрешить автоматическое выполнение, ✗, чтобы запретить выполнение. Шаблоны можно включать/выключать или удалять из списков. Просмотреть все настройки", + "addToAllowed": "Добавить в список разрешенных", + "removeFromAllowed": "Удалить из списка разрешенных", + "addToDenied": "Добавить в список запрещенных", + "removeFromDenied": "Удалить из списка запрещенных", + "abortCommand": "Прервать выполнение команды", + "expandOutput": "Развернуть вывод", + "collapseOutput": "Свернуть вывод", + "expandManagement": "Развернуть раздел управления командами", + "collapseManagement": "Свернуть раздел управления командами" + }, "response": "Ответ", "arguments": "Аргументы", "mcp": { diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 75bc126dffd..88e5fea67e7 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo bu dizinde (çalışma alanı dışında) kullanılan kaynak kod tanımlama isimlerini görüntüledi:" }, "commandOutput": "Komut Çıktısı", + "commandExecution": { + "running": "Çalışıyor", + "pid": "PID: {{pid}}", + "exited": "Çıkıldı ({{exitCode}})", + "manageCommands": "Komut İzinlerini Yönet", + "commandManagementDescription": "Komut izinlerini yönetin: Otomatik yürütmeye izin vermek için ✓'e, yürütmeyi reddetmek için ✗'e tıklayın. Desenler açılıp kapatılabilir veya listelerden kaldırılabilir. Tüm ayarları görüntüle", + "addToAllowed": "İzin verilenler listesine ekle", + "removeFromAllowed": "İzin verilenler listesinden kaldır", + "addToDenied": "Reddedilenler listesine ekle", + "removeFromDenied": "Reddedilenler listesinden kaldır", + "abortCommand": "Komut yürütmeyi iptal et", + "expandOutput": "Çıktıyı genişlet", + "collapseOutput": "Çıktıyı daralt", + "expandManagement": "Komut yönetimi bölümünü genişlet", + "collapseManagement": "Komut yönetimi bölümünü daralt" + }, "response": "Yanıt", "arguments": "Argümanlar", "mcp": { diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 944eabcb942..a0562a85dec 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo đã xem tên định nghĩa mã nguồn được sử dụng trong thư mục này (ngoài không gian làm việc):" }, "commandOutput": "Kết quả lệnh", + "commandExecution": { + "running": "Đang chạy", + "pid": "PID: {{pid}}", + "exited": "Đã thoát ({{exitCode}})", + "manageCommands": "Quản lý quyền lệnh", + "commandManagementDescription": "Quản lý quyền lệnh: Nhấp vào ✓ để cho phép tự động thực thi, ✗ để từ chối thực thi. Các mẫu có thể được bật/tắt hoặc xóa khỏi danh sách. Xem tất cả cài đặt", + "addToAllowed": "Thêm vào danh sách cho phép", + "removeFromAllowed": "Xóa khỏi danh sách cho phép", + "addToDenied": "Thêm vào danh sách từ chối", + "removeFromDenied": "Xóa khỏi danh sách từ chối", + "abortCommand": "Hủy bỏ thực thi lệnh", + "expandOutput": "Mở rộng kết quả", + "collapseOutput": "Thu gọn kết quả", + "expandManagement": "Mở rộng phần quản lý lệnh", + "collapseManagement": "Thu gọn phần quản lý lệnh" + }, "response": "Phản hồi", "arguments": "Tham số", "mcp": { diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 616cd14fec1..fe5c0bc768a 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo已查看此目录中使用的源代码定义名称(工作区外):" }, "commandOutput": "命令输出", + "commandExecution": { + "running": "正在运行", + "pid": "PID: {{pid}}", + "exited": "已退出 ({{exitCode}})", + "manageCommands": "管理命令权限", + "commandManagementDescription": "管理命令权限:点击 ✓ 允许自动执行,点击 ✗ 拒绝执行。可以打开/关闭模式或从列表中删除。查看所有设置", + "addToAllowed": "添加到允许列表", + "removeFromAllowed": "从允许列表中删除", + "addToDenied": "添加到拒绝列表", + "removeFromDenied": "从拒绝列表中删除", + "abortCommand": "中止命令执行", + "expandOutput": "展开输出", + "collapseOutput": "折叠输出", + "expandManagement": "展开命令管理部分", + "collapseManagement": "折叠命令管理部分" + }, "response": "响应", "arguments": "参数", "mcp": { diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 662421900ed..8623ad2b0ee 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -192,6 +192,22 @@ "didViewDefinitionsOutsideWorkspace": "Roo 已檢視此目錄(工作區外)中使用的原始碼定義名稱:" }, "commandOutput": "命令輸出", + "commandExecution": { + "running": "正在執行", + "pid": "PID: {{pid}}", + "exited": "已退出 ({{exitCode}})", + "manageCommands": "管理命令權限", + "commandManagementDescription": "管理命令權限:點擊 ✓ 允許自動執行,點擊 ✗ 拒絕執行。可以開啟/關閉模式或從清單中刪除。檢視所有設定", + "addToAllowed": "新增至允許清單", + "removeFromAllowed": "從允許清單中移除", + "addToDenied": "新增至拒絕清單", + "removeFromDenied": "從拒絕清單中移除", + "abortCommand": "中止命令執行", + "expandOutput": "展開輸出", + "collapseOutput": "折疊輸出", + "expandManagement": "展開命令管理部分", + "collapseManagement": "折疊命令管理部分" + }, "response": "回應", "arguments": "參數", "mcp": { diff --git a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts new file mode 100644 index 00000000000..2c16ab68a7e --- /dev/null +++ b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts @@ -0,0 +1,275 @@ +import { describe, it, expect } from "vitest" +import { extractCommandPatterns, getPatternDescription, parseCommandAndOutput } from "../commandPatterns" + +describe("extractCommandPatterns", () => { + it("should extract simple command", () => { + const patterns = extractCommandPatterns("ls") + expect(patterns).toEqual(["ls"]) + }) + + it("should extract command with arguments", () => { + const patterns = extractCommandPatterns("npm install express") + expect(patterns).toEqual(["npm", "npm install", "npm install express"]) + }) + + it("should handle piped commands", () => { + const patterns = extractCommandPatterns("ls -la | grep test") + expect(patterns).toContain("ls") + expect(patterns).toContain("grep") + expect(patterns).toContain("grep test") + }) + + it("should handle chained commands with &&", () => { + const patterns = extractCommandPatterns("npm install && npm run build") + expect(patterns).toContain("npm") + expect(patterns).toContain("npm install") + expect(patterns).toContain("npm run") + expect(patterns).toContain("npm run build") + }) + + it("should handle chained commands with ||", () => { + const patterns = extractCommandPatterns("npm test || npm run test:ci") + expect(patterns).toContain("npm") + expect(patterns).toContain("npm test") + expect(patterns).toContain("npm run") + expect(patterns).toContain("npm run test:ci") + }) + + it("should handle semicolon separated commands", () => { + const patterns = extractCommandPatterns("cd src; npm install") + expect(patterns).toContain("cd") + expect(patterns).toContain("cd src") + expect(patterns).toContain("npm") + expect(patterns).toContain("npm install") + }) + + it("should stop at flags", () => { + const patterns = extractCommandPatterns('git commit -m "test message"') + expect(patterns).toContain("git") + expect(patterns).toContain("git commit") + expect(patterns).not.toContain("git commit -m") + }) + + it("should stop at paths with slashes", () => { + const patterns = extractCommandPatterns("cd /usr/local/bin") + expect(patterns).toContain("cd") + expect(patterns).not.toContain("cd /usr/local/bin") + }) + + it("should handle empty or null input", () => { + expect(extractCommandPatterns("")).toEqual([]) + expect(extractCommandPatterns(" ")).toEqual([]) + expect(extractCommandPatterns(null as any)).toEqual([]) + expect(extractCommandPatterns(undefined as any)).toEqual([]) + }) + + it("should handle complex command with multiple operators", () => { + const patterns = extractCommandPatterns('npm install && npm test | grep success || echo "failed"') + expect(patterns).toContain("npm") + expect(patterns).toContain("npm install") + expect(patterns).toContain("npm test") + expect(patterns).toContain("grep") + expect(patterns).toContain("grep success") + expect(patterns).toContain("echo") + }) + + it("should handle malformed commands gracefully", () => { + const patterns = extractCommandPatterns("npm install && ") + expect(patterns).toContain("npm") + expect(patterns).toContain("npm install") + }) + + it("should extract main command even if parsing fails", () => { + // Create a command that might cause parsing issues + const patterns = extractCommandPatterns('echo "unclosed quote') + expect(patterns).toContain("echo") + }) + + it("should handle commands with special characters in arguments", () => { + const patterns = extractCommandPatterns("git add .") + expect(patterns).toContain("git") + expect(patterns).toContain("git add") + expect(patterns).not.toContain("git add .") + }) + + it("should return sorted patterns", () => { + const patterns = extractCommandPatterns("npm run build && git push") + expect(patterns).toEqual([...patterns].sort()) + }) +}) + +describe("getPatternDescription", () => { + it("should return descriptions for common commands", () => { + expect(getPatternDescription("cd")).toBe("directory navigation") + expect(getPatternDescription("npm")).toBe("npm commands") + expect(getPatternDescription("npm install")).toBe("npm install commands") + expect(getPatternDescription("git")).toBe("git commands") + expect(getPatternDescription("git push")).toBe("git push commands") + expect(getPatternDescription("python")).toBe("python scripts") + }) + + it("should return default description for unknown commands", () => { + expect(getPatternDescription("unknowncommand")).toBe("unknowncommand commands") + expect(getPatternDescription("custom-tool")).toBe("custom-tool commands") + }) + + it("should handle package managers", () => { + expect(getPatternDescription("yarn")).toBe("yarn commands") + expect(getPatternDescription("pnpm")).toBe("pnpm commands") + expect(getPatternDescription("bun")).toBe("bun scripts") + }) + + it("should handle build tools", () => { + expect(getPatternDescription("make")).toBe("build automation") + expect(getPatternDescription("cmake")).toBe("CMake build system") + expect(getPatternDescription("cargo")).toBe("Rust cargo commands") + expect(getPatternDescription("go build")).toBe("go build commands") + }) +}) + +describe("parseCommandAndOutput", () => { + it("should parse command with $ prefix", () => { + const text = "$ npm install\nInstalling packages..." + const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm install") + expect(result.output).toBe("Installing packages...") + }) + + it("should parse command with ❯ prefix", () => { + const text = "❯ git status\nOn branch main" + const result = parseCommandAndOutput(text) + expect(result.command).toBe("git status") + expect(result.output).toBe("On branch main") + }) + + it("should parse command with > prefix", () => { + const text = "> echo hello\nhello" + const result = parseCommandAndOutput(text) + expect(result.command).toBe("echo hello") + expect(result.output).toBe("hello") + }) + + it("should return original text if no command prefix found", () => { + const text = "npm install" + const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm install") + expect(result.output).toBe("") + }) + + it("should extract AI suggestions from output", () => { + const text = "$ npm install\nSuggested patterns: npm, npm install, npm run" + const result = parseCommandAndOutput(text) + expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) + }) + + it("should extract suggestions with different formats", () => { + const text = "$ git push\nCommand patterns: git, git push" + const result = parseCommandAndOutput(text) + expect(result.suggestions).toEqual(["git", "git push"]) + }) + + it('should extract suggestions from "you can allow" format', () => { + const text = "$ docker run\nYou can allow: docker, docker run" + const result = parseCommandAndOutput(text) + expect(result.suggestions).toEqual(["docker", "docker run"]) + }) + + it("should extract suggestions from bullet points", () => { + const text = `$ npm test +Output here... +- npm +- npm test +- npm run` + const result = parseCommandAndOutput(text) + expect(result.suggestions).toContain("npm") + expect(result.suggestions).toContain("npm test") + expect(result.suggestions).toContain("npm run") + }) + + it("should extract suggestions from various bullet formats", () => { + const text = `$ command +• npm +* git +- docker +▪ python` + const result = parseCommandAndOutput(text) + expect(result.suggestions).toContain("npm") + expect(result.suggestions).toContain("git") + expect(result.suggestions).toContain("docker") + expect(result.suggestions).toContain("python") + }) + + it("should extract suggestions with backticks", () => { + const text = "$ npm install\n- `npm`\n- `npm install`" + const result = parseCommandAndOutput(text) + expect(result.suggestions).toContain("npm") + expect(result.suggestions).toContain("npm install") + }) + + it("should handle empty text", () => { + const result = parseCommandAndOutput("") + expect(result.command).toBe("") + expect(result.output).toBe("") + expect(result.suggestions).toEqual([]) + }) + + it("should handle multiline commands", () => { + const text = `$ npm install \\ + express \\ + mongoose +Installing...` + const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm install \\") + expect(result.output).toContain("express") + }) + + it("should include all suggestions from comma-separated list", () => { + const text = "$ test\nSuggested patterns: npm, npm install, npm run" + const result = parseCommandAndOutput(text) + expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) + }) + + it("should handle case variations in suggestion patterns", () => { + const text = "$ test\nSuggested Patterns: npm, git\nCommand Patterns: docker" + const result = parseCommandAndOutput(text) + // Now it should accumulate all suggestions + expect(result.suggestions).toContain("npm") + expect(result.suggestions).toContain("git") + expect(result.suggestions).toContain("docker") + }) + + it("should handle text already split by Output:", () => { + const text = "npm install && cd backend\nOutput:\ngithub-pr-contributors-tracker@1.0.0 prepare" + const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm install && cd backend") + expect(result.output).toBe("github-pr-contributors-tracker@1.0.0 prepare") + }) + + it("should preserve original command when Output: separator is present", () => { + const text = "npm install\nOutput:\n$ npm install\nInstalling packages..." + const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm install") + expect(result.output).toBe("$ npm install\nInstalling packages...") + }) + + it("should handle Output: separator with no output", () => { + const text = "ls -la\nOutput:" + const result = parseCommandAndOutput(text) + expect(result.command).toBe("ls -la") + expect(result.output).toBe("") + }) + + it("should handle Output: separator with whitespace", () => { + const text = "git status\nOutput: \n On branch main " + const result = parseCommandAndOutput(text) + expect(result.command).toBe("git status") + expect(result.output).toBe("On branch main") + }) + + it("should only use first Output: occurrence as separator", () => { + const text = 'echo "test"\nOutput:\nFirst output\nOutput: Second output' + const result = parseCommandAndOutput(text) + expect(result.command).toBe('echo "test"') + expect(result.output).toBe("First output\nOutput: Second output") + }) +}) diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts new file mode 100644 index 00000000000..f6c2b482940 --- /dev/null +++ b/webview-ui/src/utils/commandPatterns.ts @@ -0,0 +1,197 @@ +import { parse } from "shell-quote" + +export interface CommandPattern { + pattern: string + description?: string +} + +export function extractCommandPatterns(command: string): string[] { + if (!command?.trim()) return [] + + const patterns = new Set() + + try { + const parsed = parse(command) + + const commandSeparators = new Set(["|", "&&", "||", ";"]) + let current: any[] = [] + + for (const token of parsed) { + if (typeof token === "object" && "op" in token && token.op && commandSeparators.has(token.op)) { + if (current.length) processCommand(current, patterns) + current = [] + } else { + current.push(token) + } + } + + if (current.length) processCommand(current, patterns) + } catch (_error) { + // If parsing fails, try to extract at least the main command + const mainCommand = command.trim().split(/\s+/)[0] + if (mainCommand) patterns.add(mainCommand) + } + + return Array.from(patterns).sort() +} + +function processCommand(cmd: any[], patterns: Set) { + if (!cmd.length || typeof cmd[0] !== "string") return + + const mainCmd = cmd[0] + patterns.add(mainCmd) + + // Patterns that indicate we should stop looking for subcommands + const breakingExps = [/^-/, /[\\/.~ ]/] + + // Build up patterns progressively + for (let i = 1; i < cmd.length; i++) { + const arg = cmd[i] + if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break + + const pattern = cmd.slice(0, i + 1).join(" ") + patterns.add(pattern) + } +} + +export function getPatternDescription(pattern: string): string { + // Generate human-readable descriptions for common patterns + const descriptions: Record = { + cd: "directory navigation", + ls: "list directory contents", + pwd: "print working directory", + mkdir: "create directories", + rm: "remove files/directories", + cp: "copy files/directories", + mv: "move/rename files", + cat: "display file contents", + echo: "display text", + npm: "npm commands", + "npm install": "npm install commands", + "npm run": "all npm run scripts", + "npm test": "npm test commands", + "npm start": "npm start commands", + "npm build": "npm build commands", + yarn: "yarn commands", + "yarn install": "yarn install commands", + "yarn run": "all yarn run scripts", + pnpm: "pnpm commands", + "pnpm install": "pnpm install commands", + "pnpm run": "all pnpm run scripts", + git: "git commands", + "git add": "git add commands", + "git commit": "git commit commands", + "git push": "git push commands", + "git pull": "git pull commands", + "git clone": "git clone commands", + "git checkout": "git checkout commands", + "git branch": "git branch commands", + "git merge": "git merge commands", + "git status": "git status commands", + "git log": "git log commands", + python: "python scripts", + python3: "python3 scripts", + node: "node.js scripts", + deno: "deno scripts", + bun: "bun scripts", + docker: "docker commands", + "docker run": "docker run commands", + "docker build": "docker build commands", + "docker compose": "docker compose commands", + curl: "HTTP requests", + wget: "download files", + grep: "search text patterns", + find: "find files/directories", + sed: "stream editor", + awk: "text processing", + make: "build automation", + cmake: "CMake build system", + go: "go commands", + "go run": "go run commands", + "go build": "go build commands", + "go test": "go test commands", + cargo: "Rust cargo commands", + "cargo run": "cargo run commands", + "cargo build": "cargo build commands", + "cargo test": "cargo test commands", + dotnet: ".NET commands", + "dotnet run": "dotnet run commands", + "dotnet build": "dotnet build commands", + "dotnet test": "dotnet test commands", + } + + return descriptions[pattern] || `${pattern} commands` +} + +export function parseCommandAndOutput(text: string): { + command: string + output: string + suggestions: string[] +} { + // Default result + const result = { + command: text, + output: "", + suggestions: [] as string[], + } + + // First check if the text already has been split by COMMAND_OUTPUT_STRING + // This happens when the command has already been executed and we have the output + const outputSeparator = "Output:" + const outputIndex = text.indexOf(outputSeparator) + + if (outputIndex !== -1) { + // Text is already split into command and output + result.command = text.slice(0, outputIndex).trim() + result.output = text.slice(outputIndex + outputSeparator.length).trim() + } else { + // Try to extract command from the text + // Look for patterns like "$ command" or "❯ command" at the start + const commandMatch = text.match(/^[$❯>]\s*(.+?)(?:\n|$)/m) + if (commandMatch) { + result.command = commandMatch[1].trim() + result.output = text.substring(commandMatch.index! + commandMatch[0].length).trim() + } + } + + // Look for AI suggestions in the output + // These might be in a format like: + // "Suggested patterns: npm, npm install, npm run" + // or as a list + const suggestionPatterns = [ + /Suggested patterns?:\s*(.+?)(?:\n|$)/i, + /Command patterns?:\s*(.+?)(?:\n|$)/i, + /You (?:can|may|might) (?:want to )?(?:allow|add):\s*(.+?)(?:\n|$)/i, + ] + + for (const pattern of suggestionPatterns) { + const match = result.output.match(pattern) + if (match) { + // Split by common delimiters and clean up + const suggestions = match[1] + .split(/[,;]/) + .map((s) => s.trim()) + .filter((s) => s) // Allow multi-word patterns like "npm install" + + if (suggestions.length > 0) { + // Add to existing suggestions instead of replacing + result.suggestions.push(...suggestions) + } + } + } + + // Remove duplicates + result.suggestions = Array.from(new Set(result.suggestions)) + + // Also look for bullet points or numbered lists + // const listPattern = /^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/gm + const lines = result.output.split("\n") + for (const line of lines) { + const match = line.match(/^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/) + if (match && match[1] && !result.suggestions.includes(match[1])) { + result.suggestions.push(match[1]) + } + } + + return result +} From 6fa0a528b9a63643ef6f4996e24bc82fc7dd86b2 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Thu, 17 Jul 2025 12:42:32 -0600 Subject: [PATCH 02/31] fix: address PR review feedback - Fix import error in CommandExecution.tsx (removed undefined parseCommandAndOutput) - Add security features with detectSecurityIssues function for subshell detection - Remove hardcoded command descriptions, use dynamic pattern instead - Add comprehensive security tests for subshell detection - Add integration tests for CommandExecution + CommandPatternSelector - Fix Polish translation typo: 'z list' -> 'z listy' - Simplify commandPatterns.ts by removing unnecessary complexity --- .../src/components/chat/CommandExecution.tsx | 29 +--- .../chat/__tests__/CommandExecution.spec.tsx | 139 ++++++++++++++++++ webview-ui/src/i18n/locales/pl/chat.json | 2 +- .../utils/__tests__/commandPatterns.spec.ts | 97 ++++++++++-- webview-ui/src/utils/commandPatterns.ts | 95 ++++-------- 5 files changed, 259 insertions(+), 103 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index a2b63ec5bff..560424546d5 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -6,7 +6,6 @@ import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/ import { ExtensionMessage } from "@roo/ExtensionMessage" import { safeJsonParse } from "@roo/safeJsonParse" -import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences" import { vscode } from "@src/utils/vscode" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -42,15 +41,8 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec output: parsedOutput, suggestions, } = useMemo(() => { - // First try our enhanced parser - const enhanced = parseCommandAndOutputUtil(text || "") - // If it found a command, use it, otherwise fall back to the original parser - if (enhanced.command && enhanced.command !== text) { - return enhanced - } - // Fall back to original parser - const original = parseCommandAndOutput(text) - return { ...original, suggestions: [] } + // Use the enhanced parser from commandPatterns + return parseCommandAndOutputUtil(text || "") }, [text]) // If we aren't opening the VSCode terminal for this command then we default @@ -230,20 +222,3 @@ const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; ) const OutputContainer = memo(OutputContainerInternal) - -const parseCommandAndOutput = (text: string | undefined) => { - if (!text) { - return { command: "", output: "" } - } - - const index = text.indexOf(COMMAND_OUTPUT_STRING) - - if (index === -1) { - return { command: text, output: "" } - } - - return { - command: text.slice(0, index), - output: text.slice(index + COMMAND_OUTPUT_STRING.length), - } -} diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index eafbde59aae..c356aabdc14 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -274,4 +274,143 @@ describe("CommandExecution", () => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm"] }) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) }) + + describe("integration with CommandPatternSelector", () => { + it("should extract patterns from complex commands with multiple operators", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(screen.getByText("npm")).toBeInTheDocument() + expect(screen.getByText("npm install")).toBeInTheDocument() + expect(screen.getByText("npm test")).toBeInTheDocument() + expect(screen.getByText("echo")).toBeInTheDocument() + }) + + it("should handle commands with malformed suggestions gracefully", () => { + const commandWithMalformedSuggestions = `npm install +Output: +Suggested patterns: npm, , npm install, +Other output here` + + render( + + icon} + title={Run Command} + /> + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + // Should still show valid patterns + expect(screen.getAllByText("npm")[0]).toBeInTheDocument() + expect(screen.getAllByText("npm install")[0]).toBeInTheDocument() + }) + + it("should handle commands with subshells by not including them in patterns", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(screen.getByText("echo")).toBeInTheDocument() + expect(screen.getByText("git")).toBeInTheDocument() + expect(screen.getByText("git status")).toBeInTheDocument() + // Should not include subshell content + expect(screen.queryByText("whoami")).not.toBeInTheDocument() + }) + + it("should handle commands with backtick subshells", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(screen.getByText("git")).toBeInTheDocument() + expect(screen.getByText("git commit")).toBeInTheDocument() + // Should not include subshell content + expect(screen.queryByText("date")).not.toBeInTheDocument() + }) + + it("should handle pattern changes for commands with special characters", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(screen.getByText("cd")).toBeInTheDocument() + expect(screen.getByText("npm")).toBeInTheDocument() + expect(screen.getByText("npm start")).toBeInTheDocument() + }) + + it("should handle commands with mixed content including output and suggestions", () => { + const commandWithMixedContent = `npm test +Output: +Running tests... +✓ Test 1 passed +✓ Test 2 passed + +Suggested patterns: npm, npm test, npm run +- npm +- npm test +- npm run test` + + render( + + icon} + title={Run Command} + /> + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + // Should show patterns from suggestions + expect(screen.getAllByText("npm")[0]).toBeInTheDocument() + expect(screen.getAllByText("npm test")[0]).toBeInTheDocument() + expect(screen.getAllByText("npm run")[0]).toBeInTheDocument() + }) + + it("should update both allowed and denied lists when patterns conflict", () => { + const conflictState = { + ...mockExtensionState, + allowedCommands: ["git"], + deniedCommands: ["git push"], + } + + render( + + + , + ) + + // Click to allow "git push" + const allowButton = screen.getByText("Allow git push") + fireEvent.click(allowButton) + + // Should add to allowed and remove from denied + expect(conflictState.setAllowedCommands).toHaveBeenCalledWith(["git", "git push"]) + expect(conflictState.setDeniedCommands).toHaveBeenCalledWith([]) + }) + }) }) diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 807a2da6444..b0a9d16e6a5 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -197,7 +197,7 @@ "pid": "PID: {{pid}}", "exited": "Zakończono ({{exitCode}})", "manageCommands": "Zarządzaj uprawnieniami poleceń", - "commandManagementDescription": "Zarządzaj uprawnieniami poleceń: Kliknij ✓, aby zezwolić na automatyczne wykonanie, ✗, aby odmówić wykonania. Wzorce można włączać/wyłączać lub usuwać z list. Zobacz wszystkie ustawienia", + "commandManagementDescription": "Zarządzaj uprawnieniami poleceń: Kliknij ✓, aby zezwolić na automatyczne wykonanie, ✗, aby odmówić wykonania. Wzorce można włączać/wyłączać lub usuwać z listy. Zobacz wszystkie ustawienia", "addToAllowed": "Dodaj do listy dozwolonych", "removeFromAllowed": "Usuń z listy dozwolonych", "addToDenied": "Dodaj do listy odrzuconych", diff --git a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts index 2c16ab68a7e..b3d5ffacddf 100644 --- a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts +++ b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest" -import { extractCommandPatterns, getPatternDescription, parseCommandAndOutput } from "../commandPatterns" +import { + extractCommandPatterns, + getPatternDescription, + parseCommandAndOutput, + detectSecurityIssues, +} from "../commandPatterns" describe("extractCommandPatterns", () => { it("should extract simple command", () => { @@ -99,16 +104,16 @@ describe("extractCommandPatterns", () => { }) describe("getPatternDescription", () => { - it("should return descriptions for common commands", () => { - expect(getPatternDescription("cd")).toBe("directory navigation") + it("should return pattern followed by commands", () => { + expect(getPatternDescription("cd")).toBe("cd commands") expect(getPatternDescription("npm")).toBe("npm commands") expect(getPatternDescription("npm install")).toBe("npm install commands") expect(getPatternDescription("git")).toBe("git commands") expect(getPatternDescription("git push")).toBe("git push commands") - expect(getPatternDescription("python")).toBe("python scripts") + expect(getPatternDescription("python")).toBe("python commands") }) - it("should return default description for unknown commands", () => { + it("should handle any command pattern", () => { expect(getPatternDescription("unknowncommand")).toBe("unknowncommand commands") expect(getPatternDescription("custom-tool")).toBe("custom-tool commands") }) @@ -116,13 +121,13 @@ describe("getPatternDescription", () => { it("should handle package managers", () => { expect(getPatternDescription("yarn")).toBe("yarn commands") expect(getPatternDescription("pnpm")).toBe("pnpm commands") - expect(getPatternDescription("bun")).toBe("bun scripts") + expect(getPatternDescription("bun")).toBe("bun commands") }) it("should handle build tools", () => { - expect(getPatternDescription("make")).toBe("build automation") - expect(getPatternDescription("cmake")).toBe("CMake build system") - expect(getPatternDescription("cargo")).toBe("Rust cargo commands") + expect(getPatternDescription("make")).toBe("make commands") + expect(getPatternDescription("cmake")).toBe("cmake commands") + expect(getPatternDescription("cargo")).toBe("cargo commands") expect(getPatternDescription("go build")).toBe("go build commands") }) }) @@ -273,3 +278,77 @@ Installing...` expect(result.output).toBe("First output\nOutput: Second output") }) }) + +describe("detectSecurityIssues", () => { + it("should detect subshell execution with $()", () => { + const warnings = detectSecurityIssues("echo $(malicious)") + expect(warnings).toHaveLength(1) + expect(warnings[0].type).toBe("subshell") + expect(warnings[0].message).toContain("subshell execution") + }) + + it("should detect subshell execution with backticks", () => { + const warnings = detectSecurityIssues("echo `malicious`") + expect(warnings).toHaveLength(1) + expect(warnings[0].type).toBe("subshell") + expect(warnings[0].message).toContain("subshell execution") + }) + + it("should detect nested subshells", () => { + const warnings = detectSecurityIssues("echo $(echo $(date))") + expect(warnings).toHaveLength(1) + expect(warnings[0].type).toBe("subshell") + }) + + it("should detect subshells in complex commands", () => { + const warnings = detectSecurityIssues("npm install && echo $(whoami) || git push") + expect(warnings).toHaveLength(1) + expect(warnings[0].type).toBe("subshell") + }) + + it("should not detect issues in safe commands", () => { + const warnings = detectSecurityIssues("npm install express") + expect(warnings).toHaveLength(0) + }) + + it("should handle empty commands", () => { + const warnings = detectSecurityIssues("") + expect(warnings).toHaveLength(0) + }) + + it("should detect multiple subshell patterns", () => { + const warnings = detectSecurityIssues("echo $(date) && echo `whoami`") + expect(warnings).toHaveLength(1) // Should still be 1 warning for subshell presence + expect(warnings[0].type).toBe("subshell") + }) + + it("should detect subshells in quoted strings", () => { + const warnings = detectSecurityIssues('echo "Current user: $(whoami)"') + expect(warnings).toHaveLength(1) + expect(warnings[0].type).toBe("subshell") + }) +}) + +describe("security integration with extractCommandPatterns", () => { + it("should not include subshell content in patterns", () => { + const patterns = extractCommandPatterns("echo $(malicious)") + expect(patterns).toContain("echo") + expect(patterns).not.toContain("$(malicious)") + expect(patterns).not.toContain("malicious") + }) + + it("should handle commands with subshells properly", () => { + const patterns = extractCommandPatterns("npm install && echo $(whoami)") + expect(patterns).toContain("npm") + expect(patterns).toContain("npm install") + expect(patterns).toContain("echo") + expect(patterns).not.toContain("whoami") + }) + + it("should extract patterns from commands with backtick subshells", () => { + const patterns = extractCommandPatterns("git commit -m `date`") + expect(patterns).toContain("git") + expect(patterns).toContain("git commit") + expect(patterns).not.toContain("date") + }) +}) diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts index f6c2b482940..1dd2836ff05 100644 --- a/webview-ui/src/utils/commandPatterns.ts +++ b/webview-ui/src/utils/commandPatterns.ts @@ -5,13 +5,23 @@ export interface CommandPattern { description?: string } +export interface SecurityWarning { + type: "subshell" | "injection" + message: string +} + export function extractCommandPatterns(command: string): string[] { if (!command?.trim()) return [] const patterns = new Set() try { - const parsed = parse(command) + // First, remove subshell expressions to avoid extracting their contents + const cleanedCommand = command + .replace(/\$\([^)]*\)/g, "") // Remove $() subshells + .replace(/`[^`]*`/g, "") // Remove backtick subshells + + const parsed = parse(cleanedCommand) const commandSeparators = new Set(["|", "&&", "||", ";"]) let current: any[] = [] @@ -54,73 +64,26 @@ function processCommand(cmd: any[], patterns: Set) { } } -export function getPatternDescription(pattern: string): string { - // Generate human-readable descriptions for common patterns - const descriptions: Record = { - cd: "directory navigation", - ls: "list directory contents", - pwd: "print working directory", - mkdir: "create directories", - rm: "remove files/directories", - cp: "copy files/directories", - mv: "move/rename files", - cat: "display file contents", - echo: "display text", - npm: "npm commands", - "npm install": "npm install commands", - "npm run": "all npm run scripts", - "npm test": "npm test commands", - "npm start": "npm start commands", - "npm build": "npm build commands", - yarn: "yarn commands", - "yarn install": "yarn install commands", - "yarn run": "all yarn run scripts", - pnpm: "pnpm commands", - "pnpm install": "pnpm install commands", - "pnpm run": "all pnpm run scripts", - git: "git commands", - "git add": "git add commands", - "git commit": "git commit commands", - "git push": "git push commands", - "git pull": "git pull commands", - "git clone": "git clone commands", - "git checkout": "git checkout commands", - "git branch": "git branch commands", - "git merge": "git merge commands", - "git status": "git status commands", - "git log": "git log commands", - python: "python scripts", - python3: "python3 scripts", - node: "node.js scripts", - deno: "deno scripts", - bun: "bun scripts", - docker: "docker commands", - "docker run": "docker run commands", - "docker build": "docker build commands", - "docker compose": "docker compose commands", - curl: "HTTP requests", - wget: "download files", - grep: "search text patterns", - find: "find files/directories", - sed: "stream editor", - awk: "text processing", - make: "build automation", - cmake: "CMake build system", - go: "go commands", - "go run": "go run commands", - "go build": "go build commands", - "go test": "go test commands", - cargo: "Rust cargo commands", - "cargo run": "cargo run commands", - "cargo build": "cargo build commands", - "cargo test": "cargo test commands", - dotnet: ".NET commands", - "dotnet run": "dotnet run commands", - "dotnet build": "dotnet build commands", - "dotnet test": "dotnet test commands", +export function detectSecurityIssues(command: string): SecurityWarning[] { + const warnings: SecurityWarning[] = [] + + // Check for subshell execution attempts + if (command.includes("$(") || command.includes("`")) { + warnings.push({ + type: "subshell", + message: "Command contains subshell execution which could bypass restrictions", + }) } - return descriptions[pattern] || `${pattern} commands` + return warnings +} + +/** + * Get a human-readable description for a command pattern. + * Simply returns the pattern followed by "commands". + */ +export function getPatternDescription(pattern: string): string { + return `${pattern} commands` } export function parseCommandAndOutput(text: string): { From dd533c2af7018ae22cd2f1f3850a2461517534ad Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Thu, 17 Jul 2025 13:19:26 -0600 Subject: [PATCH 03/31] refactor: convert showSuggestions from state to constant - Addresses code review feedback about unused state variable - showSuggestions is always true and never changes, so it doesn't need to be state - All tests pass and no linting issues --- webview-ui/src/components/chat/CommandExecution.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 560424546d5..d8451c2ae66 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -50,7 +50,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) const [streamingOutput, setStreamingOutput] = useState("") const [status, setStatus] = useState(null) - const [showSuggestions] = useState(true) + const showSuggestions = true // The command's output can either come from the text associated with the // task message (this is the case for completed commands) or from the From b24400b549337a1fc710ba9e4fb481bd9ee7c770 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 21 Jul 2025 17:09:58 +0000 Subject: [PATCH 04/31] fix: address PR review feedback - Refactored showSuggestions from state variable to constant SHOW_SUGGESTIONS - Renamed breakingExps to stopPatterns for better clarity - Added test coverage for edge cases in command parsing - Fixed test assertion for multiline content handling --- .../src/components/chat/CommandExecution.tsx | 5 +- .../chat/__tests__/CommandExecution.spec.tsx | 51 +++++++++++++++++++ webview-ui/src/utils/commandPatterns.ts | 4 +- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index d8451c2ae66..38caaa62aa0 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -50,7 +50,8 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) const [streamingOutput, setStreamingOutput] = useState("") const [status, setStatus] = useState(null) - const showSuggestions = true + // Show suggestions is always enabled for command pattern management + const SHOW_SUGGESTIONS = true // The command's output can either come from the text associated with the // task message (this is the case for completed commands) or from the @@ -195,7 +196,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
- {showSuggestions && commandPatterns.length > 0 && ( + {SHOW_SUGGESTIONS && commandPatterns.length > 0 && ( { + // Test with a command that might cause parsing issues + const unparsableCommand = "echo 'test with unclosed quote" + + render( + + + , + ) + + // Should still render the command + expect(screen.getByTestId("code-block")).toHaveTextContent("echo 'test with unclosed quote") + + // Should show pattern selector with at least the main command + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + expect(screen.getByText("echo")).toBeInTheDocument() + }) + + it("should handle empty or whitespace-only commands", () => { + render( + + + , + ) + + // Should render without errors + expect(screen.getByTestId("code-block")).toBeInTheDocument() + + // Should not show pattern selector for empty commands + expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() + }) + + it("should handle commands with only output and no command prefix", () => { + const outputOnly = `Some output without a command +Multiple lines of output +Without any command prefix` + + render( + + + , + ) + + // Should treat the entire text as command when no prefix is found + const codeBlock = screen.getByTestId("code-block") + // The mock CodeBlock component renders text content without preserving newlines + expect(codeBlock.textContent).toContain("Some output without a command") + expect(codeBlock.textContent).toContain("Multiple lines of output") + expect(codeBlock.textContent).toContain("Without any command prefix") + }) }) }) diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts index 1dd2836ff05..23421c1bf00 100644 --- a/webview-ui/src/utils/commandPatterns.ts +++ b/webview-ui/src/utils/commandPatterns.ts @@ -52,12 +52,12 @@ function processCommand(cmd: any[], patterns: Set) { patterns.add(mainCmd) // Patterns that indicate we should stop looking for subcommands - const breakingExps = [/^-/, /[\\/.~ ]/] + const stopPatterns = [/^-/, /[\\/.~ ]/] // Build up patterns progressively for (let i = 1; i < cmd.length; i++) { const arg = cmd[i] - if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break + if (typeof arg !== "string" || stopPatterns.some((re) => re.test(arg))) break const pattern = cmd.slice(0, i + 1).join(" ") patterns.add(pattern) From 860631cfe358e569859e9784f6a14c054ccbba71 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 21 Jul 2025 17:18:02 +0000 Subject: [PATCH 05/31] fix: address code review feedback - Replace unused showSuggestions state with SHOW_SUGGESTIONS constant - Fix parseCommandAndOutput import name consistency - Add test coverage for fallback case where command equals text --- .../src/components/chat/CommandExecution.tsx | 6 ++--- .../chat/__tests__/CommandExecution.spec.tsx | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 38caaa62aa0..ad024a378ed 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -16,7 +16,7 @@ import { CommandPatternSelector } from "./CommandPatternSelector" import { extractCommandPatterns, getPatternDescription, - parseCommandAndOutput as parseCommandAndOutputUtil, + parseCommandAndOutput, CommandPattern, } from "../../utils/commandPatterns" @@ -42,7 +42,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec suggestions, } = useMemo(() => { // Use the enhanced parser from commandPatterns - return parseCommandAndOutputUtil(text || "") + return parseCommandAndOutput(text || "") }, [text]) // If we aren't opening the VSCode terminal for this command then we default @@ -64,7 +64,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec // Use AI suggestions if available if (suggestions.length > 0) { - suggestions.forEach((suggestion) => { + suggestions.forEach((suggestion: string) => { patterns.push({ pattern: suggestion, description: getPatternDescription(suggestion), diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index 6448457cbdf..3312434ab29 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -463,5 +463,29 @@ Without any command prefix` expect(codeBlock.textContent).toContain("Multiple lines of output") expect(codeBlock.textContent).toContain("Without any command prefix") }) + + it("should handle fallback case where parsed command equals original text", () => { + // This tests the case where parseCommandAndOutput returns command === text + // which happens when there's no output separator or command prefix + const plainCommand = "docker build ." + + render( + + + , + ) + + // Should render the command + expect(screen.getByTestId("code-block")).toHaveTextContent("docker build .") + + // Should show pattern selector with extracted patterns + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + expect(screen.getByText("docker")).toBeInTheDocument() + expect(screen.getByText("docker build")).toBeInTheDocument() + + // Verify no output is shown (since command === text means no output) + const codeBlocks = screen.getAllByTestId("code-block") + expect(codeBlocks).toHaveLength(1) // Only the command block, no output block + }) }) }) From ebf1b241c17818dc18fc8075ed989e0ac31bf6da Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Tue, 22 Jul 2025 14:50:52 -0600 Subject: [PATCH 06/31] fix: improve command parsing to handle Output: separator correctly - Fixed parseCommandAndOutput to properly handle the newline + 'Output:' separator - Added test cases for commands with numbers at the start of output lines - Updated existing tests to use template literals for proper newline handling - Fixed test assertions to handle multiple code blocks when output is present This resolves the issue where output lines starting with numbers (like 'wc -l' output) were being incorrectly parsed as the command instead of the actual command text. --- .../chat/__tests__/CommandExecution.spec.tsx | 52 ++++++-- .../utils/__tests__/commandPatterns.spec.ts | 111 ++++++++++++++---- webview-ui/src/utils/commandPatterns.ts | 28 +++-- 3 files changed, 148 insertions(+), 43 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index 3312434ab29..98097262676 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -21,6 +21,19 @@ vi.mock("../../common/CodeBlock", () => ({ default: ({ source }: { source: string }) =>
{source}
, })) +// Mock the commandPatterns module but use the actual implementation +vi.mock("../../../utils/commandPatterns", async () => { + const actual = await vi.importActual( + "../../../utils/commandPatterns", + ) + return { + ...actual, + parseCommandAndOutput: actual.parseCommandAndOutput, + extractCommandPatterns: actual.extractCommandPatterns, + getPatternDescription: actual.getPatternDescription, + } +}) + vi.mock("../CommandPatternSelector", () => ({ CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => (
@@ -66,7 +79,7 @@ describe("CommandExecution", () => { it("should render command with output", () => { render( - + , ) @@ -166,29 +179,42 @@ describe("CommandExecution", () => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) }) - it("should parse command with $ prefix", () => { + it("should parse command with Output: separator", () => { + const commandText = `npm install +Output: +Installing...` + render( - + , ) - expect(screen.getByTestId("code-block")).toHaveTextContent("npm install") + const codeBlocks = screen.getAllByTestId("code-block") + expect(codeBlocks[0]).toHaveTextContent("npm install") }) it("should parse command with AI suggestions", () => { + const commandText = `npm install +Output: +Suggested patterns: npm, npm install, npm run` + render( - + , ) + // First check that the command was parsed correctly + const codeBlocks = screen.getAllByTestId("code-block") + expect(codeBlocks[0]).toHaveTextContent("npm install") + expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run") + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() // Check that the patterns are present in the mock expect(screen.getByText("npm")).toBeInTheDocument() + expect(screen.getAllByText("npm install").length).toBeGreaterThan(0) + expect(screen.getByText("npm run")).toBeInTheDocument() }) it("should handle commands with pipes", () => { @@ -232,14 +258,20 @@ describe("CommandExecution", () => { terminalShellIntegrationDisabled: true, } + const commandText = `npm install +Output: +Output here` + render( - + , ) // Output should be visible when shell integration is disabled - expect(screen.getByText(/Output here/)).toBeInTheDocument() + const codeBlocks = screen.getAllByTestId("code-block") + expect(codeBlocks).toHaveLength(2) // Command and output blocks + expect(codeBlocks[1]).toHaveTextContent("Output here") }) it("should handle undefined allowedCommands and deniedCommands", () => { diff --git a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts index b3d5ffacddf..9d26531a4e0 100644 --- a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts +++ b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts @@ -133,25 +133,28 @@ describe("getPatternDescription", () => { }) describe("parseCommandAndOutput", () => { - it("should parse command with $ prefix", () => { + it("should handle command with $ prefix without Output: separator", () => { const text = "$ npm install\nInstalling packages..." const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm install") - expect(result.output).toBe("Installing packages...") + // Without Output: separator, the entire text is treated as command + expect(result.command).toBe("$ npm install\nInstalling packages...") + expect(result.output).toBe("") }) - it("should parse command with ❯ prefix", () => { + it("should handle command with ❯ prefix without Output: separator", () => { const text = "❯ git status\nOn branch main" const result = parseCommandAndOutput(text) - expect(result.command).toBe("git status") - expect(result.output).toBe("On branch main") + // Without Output: separator, the entire text is treated as command + expect(result.command).toBe("❯ git status\nOn branch main") + expect(result.output).toBe("") }) - it("should parse command with > prefix", () => { + it("should handle command with > prefix without Output: separator", () => { const text = "> echo hello\nhello" const result = parseCommandAndOutput(text) - expect(result.command).toBe("echo hello") - expect(result.output).toBe("hello") + // Without Output: separator, the entire text is treated as command + expect(result.command).toBe("> echo hello\nhello") + expect(result.output).toBe("") }) it("should return original text if no command prefix found", () => { @@ -161,43 +164,50 @@ describe("parseCommandAndOutput", () => { expect(result.output).toBe("") }) - it("should extract AI suggestions from output", () => { - const text = "$ npm install\nSuggested patterns: npm, npm install, npm run" + it("should extract AI suggestions from output with Output: separator", () => { + const text = "npm install\nOutput:\nSuggested patterns: npm, npm install, npm run" const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm install") expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) }) it("should extract suggestions with different formats", () => { - const text = "$ git push\nCommand patterns: git, git push" + const text = "git push\nOutput:\nCommand patterns: git, git push" const result = parseCommandAndOutput(text) + expect(result.command).toBe("git push") expect(result.suggestions).toEqual(["git", "git push"]) }) it('should extract suggestions from "you can allow" format', () => { - const text = "$ docker run\nYou can allow: docker, docker run" + const text = "docker run\nOutput:\nYou can allow: docker, docker run" const result = parseCommandAndOutput(text) + expect(result.command).toBe("docker run") expect(result.suggestions).toEqual(["docker", "docker run"]) }) it("should extract suggestions from bullet points", () => { - const text = `$ npm test + const text = `npm test +Output: Output here... - npm - npm test - npm run` const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm test") expect(result.suggestions).toContain("npm") expect(result.suggestions).toContain("npm test") expect(result.suggestions).toContain("npm run") }) it("should extract suggestions from various bullet formats", () => { - const text = `$ command + const text = `command +Output: • npm * git - docker ▪ python` const result = parseCommandAndOutput(text) + expect(result.command).toBe("command") expect(result.suggestions).toContain("npm") expect(result.suggestions).toContain("git") expect(result.suggestions).toContain("docker") @@ -205,8 +215,9 @@ Output here... }) it("should extract suggestions with backticks", () => { - const text = "$ npm install\n- `npm`\n- `npm install`" + const text = "npm install\nOutput:\n- `npm`\n- `npm install`" const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm install") expect(result.suggestions).toContain("npm") expect(result.suggestions).toContain("npm install") }) @@ -218,25 +229,28 @@ Output here... expect(result.suggestions).toEqual([]) }) - it("should handle multiline commands", () => { + it("should handle multiline commands without Output: separator", () => { const text = `$ npm install \\ - express \\ - mongoose + express \\ + mongoose Installing...` const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm install \\") - expect(result.output).toContain("express") + // Without Output: separator, entire text is treated as command + expect(result.command).toBe(text) + expect(result.output).toBe("") }) - it("should include all suggestions from comma-separated list", () => { - const text = "$ test\nSuggested patterns: npm, npm install, npm run" + it("should include all suggestions from comma-separated list with Output: separator", () => { + const text = "test\nOutput:\nSuggested patterns: npm, npm install, npm run" const result = parseCommandAndOutput(text) + expect(result.command).toBe("test") expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) }) it("should handle case variations in suggestion patterns", () => { - const text = "$ test\nSuggested Patterns: npm, git\nCommand Patterns: docker" + const text = "test\nOutput:\nSuggested Patterns: npm, git\nCommand Patterns: docker" const result = parseCommandAndOutput(text) + expect(result.command).toBe("test") // Now it should accumulate all suggestions expect(result.suggestions).toContain("npm") expect(result.suggestions).toContain("git") @@ -277,6 +291,55 @@ Installing...` expect(result.command).toBe('echo "test"') expect(result.output).toBe("First output\nOutput: Second output") }) + + it("should handle output with numbers at the start of lines", () => { + const text = `wc -l *.go *.java +Output: +25 hello_world.go +316 HelloWorld.java +341 total` + const result = parseCommandAndOutput(text) + expect(result.command).toBe("wc -l *.go *.java") + expect(result.output).toBe("25 hello_world.go\n316 HelloWorld.java\n341 total") + expect(result.suggestions).toEqual([]) + }) + + it("should handle edge case where text starts with Output:", () => { + const text = "Output:\nSome output without a command" + const result = parseCommandAndOutput(text) + expect(result.command).toBe("") + expect(result.output).toBe("Some output without a command") + }) + + it("should not be confused by Output: appearing in the middle of output", () => { + const text = `echo "Output: test" +Output: +Output: test` + const result = parseCommandAndOutput(text) + expect(result.command).toBe('echo "Output: test"') + expect(result.output).toBe("Output: test") + }) + + it("should handle commands without shell prompt when Output: separator is present", () => { + const text = `npm install +Output: +Installing packages...` + const result = parseCommandAndOutput(text) + expect(result.command).toBe("npm install") + expect(result.output).toBe("Installing packages...") + }) + + it("should not parse shell prompts from output when Output: separator exists", () => { + const text = `ls -la +Output: +$ total 341 +drwxr-xr-x 10 user staff 320 Jan 22 12:00 . +drwxr-xr-x 20 user staff 640 Jan 22 11:00 ..` + const result = parseCommandAndOutput(text) + expect(result.command).toBe("ls -la") + expect(result.output).toContain("$ total 341") + expect(result.output).toContain("drwxr-xr-x") + }) }) describe("detectSecurityIssues", () => { diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts index 23421c1bf00..7fe97f074fb 100644 --- a/webview-ui/src/utils/commandPatterns.ts +++ b/webview-ui/src/utils/commandPatterns.ts @@ -101,20 +101,30 @@ export function parseCommandAndOutput(text: string): { // First check if the text already has been split by COMMAND_OUTPUT_STRING // This happens when the command has already been executed and we have the output const outputSeparator = "Output:" - const outputIndex = text.indexOf(outputSeparator) + const outputIndex = text.indexOf(`\n${outputSeparator}`) if (outputIndex !== -1) { // Text is already split into command and output + // The command is everything before the output separator result.command = text.slice(0, outputIndex).trim() - result.output = text.slice(outputIndex + outputSeparator.length).trim() - } else { - // Try to extract command from the text - // Look for patterns like "$ command" or "❯ command" at the start - const commandMatch = text.match(/^[$❯>]\s*(.+?)(?:\n|$)/m) - if (commandMatch) { - result.command = commandMatch[1].trim() - result.output = text.substring(commandMatch.index! + commandMatch[0].length).trim() + // The output is everything after the output separator + // We need to skip the newline and "Output:" text + const afterNewline = outputIndex + 1 // Skip the newline + const afterSeparator = afterNewline + outputSeparator.length // Skip "Output:" + // Check if there's a colon and potential space after it + let startOfOutput = afterSeparator + if (text[afterSeparator] === "\n") { + startOfOutput = afterSeparator + 1 // Skip additional newline after "Output:" } + result.output = text.slice(startOfOutput).trim() + } else if (text.indexOf(outputSeparator) === 0) { + // Edge case: text starts with "Output:" (no command) + result.command = "" + result.output = text.slice(outputSeparator.length).trim() + } else { + // No output separator found, the entire text is the command + result.command = text.trim() + result.output = "" } // Look for AI suggestions in the output From f6642f6afeda78b78b234c156288b9e2426a73e5 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Tue, 22 Jul 2025 17:30:22 -0600 Subject: [PATCH 07/31] fix: prevent command output from appearing in permissions UI - Fixed CommandExecution.tsx to only extract patterns from actual commands, not AI suggestions - Enhanced extractCommandPatterns to filter out numeric patterns and common output words - Added comprehensive test coverage for the bug scenario - Ensures 'Manage Command Permissions' only shows actual executed commands Fixes the issue where output like '0 total' from wc commands was incorrectly shown as a command pattern --- .../src/components/chat/CommandExecution.tsx | 36 +++----- .../chat/__tests__/CommandExecution.spec.tsx | 76 ++++++++++++++++- .../utils/__tests__/commandPatterns.spec.ts | 84 +++++++++++++++++++ webview-ui/src/utils/commandPatterns.ts | 26 +++++- 4 files changed, 192 insertions(+), 30 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index ad024a378ed..4ffbd32d881 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -36,11 +36,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec setDeniedCommands, } = useExtensionState() - const { - command, - output: parsedOutput, - suggestions, - } = useMemo(() => { + const { command, output: parsedOutput } = useMemo(() => { // Use the enhanced parser from commandPatterns return parseCommandAndOutput(text || "") }, [text]) @@ -58,31 +54,23 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec // streaming output (this is the case for running commands). const output = streamingOutput || parsedOutput - // Extract command patterns + // Extract command patterns from the actual command that was executed const commandPatterns = useMemo(() => { const patterns: CommandPattern[] = [] - // Use AI suggestions if available - if (suggestions.length > 0) { - suggestions.forEach((suggestion: string) => { - patterns.push({ - pattern: suggestion, - description: getPatternDescription(suggestion), - }) - }) - } else { - // Extract patterns programmatically - const extractedPatterns = extractCommandPatterns(command) - extractedPatterns.forEach((pattern) => { - patterns.push({ - pattern, - description: getPatternDescription(pattern), - }) + // Always extract patterns from the actual command that was executed + // We don't use AI suggestions because the patterns should reflect + // what was actually executed, not what the AI thinks might be useful + const extractedPatterns = extractCommandPatterns(command) + extractedPatterns.forEach((pattern) => { + patterns.push({ + pattern, + description: getPatternDescription(pattern), }) - } + }) return patterns - }, [command, suggestions]) + }, [command]) // Handle pattern changes const handleAllowPatternChange = (pattern: string) => { diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index 98097262676..162ccd007cd 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -211,10 +211,11 @@ Suggested patterns: npm, npm install, npm run` expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run") expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - // Check that the patterns are present in the mock + // Check that only patterns from the actual command are extracted, not from AI suggestions expect(screen.getByText("npm")).toBeInTheDocument() expect(screen.getAllByText("npm install").length).toBeGreaterThan(0) - expect(screen.getByText("npm run")).toBeInTheDocument() + // "npm run" should NOT be in the patterns since it's only in the AI suggestions, not the actual command + expect(screen.queryByText("npm run")).not.toBeInTheDocument() }) it("should handle commands with pipes", () => { @@ -417,10 +418,11 @@ Suggested patterns: npm, npm test, npm run const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should show patterns from suggestions + // Should show patterns only from the actual command, not from AI suggestions expect(screen.getAllByText("npm")[0]).toBeInTheDocument() expect(screen.getAllByText("npm test")[0]).toBeInTheDocument() - expect(screen.getAllByText("npm run")[0]).toBeInTheDocument() + // "npm run" should NOT be in the patterns since it's only in the AI suggestions + expect(screen.queryByText("npm run")).not.toBeInTheDocument() }) it("should update both allowed and denied lists when patterns conflict", () => { @@ -519,5 +521,71 @@ Without any command prefix` const codeBlocks = screen.getAllByTestId("code-block") expect(codeBlocks).toHaveLength(1) // Only the command block, no output block }) + + it("should not extract patterns from command output numbers", () => { + // This tests the specific bug where "0 total" from wc output was being extracted as a command + const commandWithNumericOutput = `wc -l *.go *.java +Output: + 10 file1.go + 20 file2.go + 15 Main.java + 45 total` + + render( + + + , + ) + + // Should render the command and output + const codeBlocks = screen.getAllByTestId("code-block") + expect(codeBlocks[0]).toHaveTextContent("wc -l *.go *.java") + + // Should show pattern selector + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + + // Should only extract "wc" from the actual command + expect(screen.getByText("wc")).toBeInTheDocument() + + // Should NOT extract numeric patterns from output like "45 total" + expect(screen.queryByText("45")).not.toBeInTheDocument() + expect(screen.queryByText("total")).not.toBeInTheDocument() + expect(screen.queryByText("45 total")).not.toBeInTheDocument() + }) + + it("should handle the edge case of 0 total in output", () => { + // This is the exact case from the bug report + const commandWithZeroTotal = `wc -l *.go *.java +Output: + 0 total` + + render( + + + , + ) + + // Should show pattern selector + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + + // Should only extract "wc" from the actual command + // Check within the pattern selector specifically + const patternTexts = Array.from(selector.querySelectorAll("span")).map((el) => el.textContent) + + // Should have "wc" as a pattern + expect(patternTexts).toContain("wc") + + // Should NOT have "0", "total", or "0 total" as patterns + expect(patternTexts).not.toContain("0") + expect(patternTexts).not.toContain("total") + expect(patternTexts).not.toContain("0 total") + + // The output should still be displayed in the code block + const codeBlocks = screen.getAllByTestId("code-block") + expect(codeBlocks.length).toBeGreaterThan(1) + expect(codeBlocks[1]).toHaveTextContent("0 total") + }) }) }) diff --git a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts index 9d26531a4e0..92feda0412f 100644 --- a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts +++ b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts @@ -101,6 +101,18 @@ describe("extractCommandPatterns", () => { const patterns = extractCommandPatterns("npm run build && git push") expect(patterns).toEqual([...patterns].sort()) }) + + it("should handle numeric input like '0 total'", () => { + const patterns = extractCommandPatterns("0 total") + // Should return empty array since "0" is not a valid command + expect(patterns).toEqual([]) + }) + + it("should handle pure numeric commands", () => { + const patterns = extractCommandPatterns("0") + // Should return empty array since pure numbers are not valid commands + expect(patterns).toEqual([]) + }) }) describe("getPatternDescription", () => { @@ -415,3 +427,75 @@ describe("security integration with extractCommandPatterns", () => { expect(patterns).not.toContain("date") }) }) + +describe("integration: parseCommandAndOutput with extractCommandPatterns", () => { + it("should not extract patterns from output text", () => { + const text = `wc -l *.go *.java +Output: +wc: *.go: open: No such file or directory +wc: *.java: open: No such file or directory +0 total` + const { command } = parseCommandAndOutput(text) + const patterns = extractCommandPatterns(command) + + // Should only extract patterns from the command, not the output + expect(patterns).toContain("wc") + expect(patterns).not.toContain("0") + expect(patterns).not.toContain("total") + expect(patterns).not.toContain("0 total") + }) + + it("should handle the specific wc command case", () => { + const text = `wc -l *.go *.java +Output: +25 hello_world.go +316 HelloWorld.java +341 total` + const { command } = parseCommandAndOutput(text) + const patterns = extractCommandPatterns(command) + + // Should only extract "wc" from the command + expect(patterns).toEqual(["wc"]) + expect(patterns).not.toContain("341") + expect(patterns).not.toContain("total") + expect(patterns).not.toContain("341 total") + }) + + it("should handle wc command with error output", () => { + const text = `wc -l *.go *.java +Output: +wc: *.go: open: No such file or directory +wc: *.java: open: No such file or directory +0 total` + const { command, output } = parseCommandAndOutput(text) + const patterns = extractCommandPatterns(command) + + // Should only extract "wc" from the command + expect(command).toBe("wc -l *.go *.java") + expect(output).toContain("0 total") + expect(patterns).toEqual(["wc"]) + expect(patterns).not.toContain("0") + expect(patterns).not.toContain("total") + expect(patterns).not.toContain("0 total") + }) + + it("should handle case where only output line is provided", () => { + // This simulates if somehow only "0 total" is passed as the text + const text = "0 total" + const { command } = parseCommandAndOutput(text) + const patterns = extractCommandPatterns(command) + + // In this case, the entire text is treated as command + expect(command).toBe("0 total") + // But "0 total" is not a valid command pattern (starts with number) + expect(patterns).toEqual([]) + }) + + it("should handle commands without output separator", () => { + const text = "npm install" + const { command } = parseCommandAndOutput(text) + const patterns = extractCommandPatterns(command) + + expect(patterns).toEqual(["npm", "npm install"]) + }) +}) diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts index 7fe97f074fb..30fadc30df4 100644 --- a/webview-ui/src/utils/commandPatterns.ts +++ b/webview-ui/src/utils/commandPatterns.ts @@ -39,7 +39,16 @@ export function extractCommandPatterns(command: string): string[] { } catch (_error) { // If parsing fails, try to extract at least the main command const mainCommand = command.trim().split(/\s+/)[0] - if (mainCommand) patterns.add(mainCommand) + + // Apply same validation as in processCommand + if ( + mainCommand && + !/^\d+$/.test(mainCommand) && // Skip pure numbers + !["total", "error", "warning", "failed", "success", "done"].includes(mainCommand.toLowerCase()) && + (/[a-zA-Z]/.test(mainCommand) || mainCommand.includes("/")) + ) { + patterns.add(mainCommand) + } } return Array.from(patterns).sort() @@ -49,7 +58,20 @@ function processCommand(cmd: any[], patterns: Set) { if (!cmd.length || typeof cmd[0] !== "string") return const mainCmd = cmd[0] - patterns.add(mainCmd) + + // Skip if it's just a number (like "0" from "0 total") + if (/^\d+$/.test(mainCmd)) return + + // Skip common output patterns that aren't commands + const skipWords = ["total", "error", "warning", "failed", "success", "done"] + if (skipWords.includes(mainCmd.toLowerCase())) return + + // Only add if it contains at least one letter or is a valid path + if (/[a-zA-Z]/.test(mainCmd) || mainCmd.includes("/")) { + patterns.add(mainCmd) + } else { + return // Don't process further if main command is invalid + } // Patterns that indicate we should stop looking for subcommands const stopPatterns = [/^-/, /[\\/.~ ]/] From 0a25464cc3e18b2cca8f0ee9393ca476227dbf99 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Tue, 22 Jul 2025 18:04:42 -0600 Subject: [PATCH 08/31] refactor: eliminate code redundancy between extractCommandPatterns and parseCommand - Refactored extractCommandPatterns to use the existing parseCommand function - Ensures consistent command parsing behavior across the codebase - Maintains security by removing subshell contents before parsing - All existing tests continue to pass --- webview-ui/src/utils/commandPatterns.ts | 96 ++++++++++--------------- 1 file changed, 39 insertions(+), 57 deletions(-) diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts index 30fadc30df4..76331e69afc 100644 --- a/webview-ui/src/utils/commandPatterns.ts +++ b/webview-ui/src/utils/commandPatterns.ts @@ -1,4 +1,4 @@ -import { parse } from "shell-quote" +import { parseCommand } from "./command-validation" export interface CommandPattern { pattern: string @@ -15,75 +15,57 @@ export function extractCommandPatterns(command: string): string[] { const patterns = new Set() - try { - // First, remove subshell expressions to avoid extracting their contents - const cleanedCommand = command - .replace(/\$\([^)]*\)/g, "") // Remove $() subshells - .replace(/`[^`]*`/g, "") // Remove backtick subshells + // First, check if the command contains subshells and remove them + // This is important for security - we don't want to extract patterns from subshell contents + const cleanedCommand = command + .replace(/\$\([^)]*\)/g, "") // Remove $() subshells + .replace(/`[^`]*`/g, "") // Remove backtick subshells - const parsed = parse(cleanedCommand) + // Use parseCommand to split the cleaned command into sub-commands + // This ensures consistent parsing behavior with command-validation + const subCommands = parseCommand(cleanedCommand) - const commandSeparators = new Set(["|", "&&", "||", ";"]) - let current: any[] = [] + // Process each sub-command to extract patterns + for (const subCommand of subCommands) { + // Skip empty commands + if (!subCommand.trim()) continue - for (const token of parsed) { - if (typeof token === "object" && "op" in token && token.op && commandSeparators.has(token.op)) { - if (current.length) processCommand(current, patterns) - current = [] - } else { - current.push(token) - } - } + // Split the command into tokens + const tokens = subCommand.trim().split(/\s+/) - if (current.length) processCommand(current, patterns) - } catch (_error) { - // If parsing fails, try to extract at least the main command - const mainCommand = command.trim().split(/\s+/)[0] - - // Apply same validation as in processCommand - if ( - mainCommand && - !/^\d+$/.test(mainCommand) && // Skip pure numbers - !["total", "error", "warning", "failed", "success", "done"].includes(mainCommand.toLowerCase()) && - (/[a-zA-Z]/.test(mainCommand) || mainCommand.includes("/")) - ) { - patterns.add(mainCommand) - } - } + if (tokens.length === 0) continue - return Array.from(patterns).sort() -} + const mainCmd = tokens[0] -function processCommand(cmd: any[], patterns: Set) { - if (!cmd.length || typeof cmd[0] !== "string") return + // Skip if it's just a number (like "0" from "0 total") + if (/^\d+$/.test(mainCmd)) continue - const mainCmd = cmd[0] + // Skip common output patterns that aren't commands + const skipWords = ["total", "error", "warning", "failed", "success", "done"] + if (skipWords.includes(mainCmd.toLowerCase())) continue - // Skip if it's just a number (like "0" from "0 total") - if (/^\d+$/.test(mainCmd)) return + // Only add if it contains at least one letter or is a valid path + if (/[a-zA-Z]/.test(mainCmd) || mainCmd.includes("/")) { + patterns.add(mainCmd) - // Skip common output patterns that aren't commands - const skipWords = ["total", "error", "warning", "failed", "success", "done"] - if (skipWords.includes(mainCmd.toLowerCase())) return + // Build up patterns progressively (e.g., "npm", "npm install", "npm install express") + // Stop at flags or special characters + const stopPatterns = [/^-/, /[\\/.~]/] - // Only add if it contains at least one letter or is a valid path - if (/[a-zA-Z]/.test(mainCmd) || mainCmd.includes("/")) { - patterns.add(mainCmd) - } else { - return // Don't process further if main command is invalid - } - - // Patterns that indicate we should stop looking for subcommands - const stopPatterns = [/^-/, /[\\/.~ ]/] + for (let i = 1; i < tokens.length; i++) { + const token = tokens[i] - // Build up patterns progressively - for (let i = 1; i < cmd.length; i++) { - const arg = cmd[i] - if (typeof arg !== "string" || stopPatterns.some((re) => re.test(arg))) break + // Stop if we hit a flag or special character + if (stopPatterns.some((re) => re.test(token))) break - const pattern = cmd.slice(0, i + 1).join(" ") - patterns.add(pattern) + // Build the pattern up to this point + const pattern = tokens.slice(0, i + 1).join(" ") + patterns.add(pattern) + } + } } + + return Array.from(patterns).sort() } export function detectSecurityIssues(command: string): SecurityWarning[] { From 4b257fc6280544269283fc29964bd6afd0d98732 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 23 Jul 2025 14:45:53 +0000 Subject: [PATCH 09/31] refactor: simplify command pattern parser using shell-quote library - Replace custom parsing logic with shell-quote library - Implement simplified extractPatterns and processCommand functions - Maintain all existing test compatibility - Remove dependency on parseCommand from command-validation --- webview-ui/src/utils/commandPatterns.ts | 85 +++++++++++++------------ 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts index 76331e69afc..0265ad61bb5 100644 --- a/webview-ui/src/utils/commandPatterns.ts +++ b/webview-ui/src/utils/commandPatterns.ts @@ -1,4 +1,4 @@ -import { parseCommand } from "./command-validation" +import { parse } from "shell-quote" export interface CommandPattern { pattern: string @@ -10,60 +10,63 @@ export interface SecurityWarning { message: string } -export function extractCommandPatterns(command: string): string[] { - if (!command?.trim()) return [] +function processCommand(cmd: string[], patterns: Set): void { + if (!cmd.length || typeof cmd[0] !== "string") return - const patterns = new Set() + const mainCmd = cmd[0] - // First, check if the command contains subshells and remove them - // This is important for security - we don't want to extract patterns from subshell contents - const cleanedCommand = command - .replace(/\$\([^)]*\)/g, "") // Remove $() subshells - .replace(/`[^`]*`/g, "") // Remove backtick subshells + // Skip if it's just a number (like "0" from "0 total") + if (/^\d+$/.test(mainCmd)) return + + // Skip common output patterns that aren't commands + const skipWords = ["total", "error", "warning", "failed", "success", "done"] + if (skipWords.includes(mainCmd.toLowerCase())) return + + patterns.add(mainCmd) - // Use parseCommand to split the cleaned command into sub-commands - // This ensures consistent parsing behavior with command-validation - const subCommands = parseCommand(cleanedCommand) + const breakingExps = [/^-/, /[\\/.~]/] - // Process each sub-command to extract patterns - for (const subCommand of subCommands) { - // Skip empty commands - if (!subCommand.trim()) continue + for (let i = 1; i < cmd.length; i++) { + const arg = cmd[i] - // Split the command into tokens - const tokens = subCommand.trim().split(/\s+/) + if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break - if (tokens.length === 0) continue + const pattern = cmd.slice(0, i + 1).join(" ") + patterns.add(pattern) + } +} - const mainCmd = tokens[0] +function extractPatterns(cmdStr: string): Set { + const patterns = new Set() - // Skip if it's just a number (like "0" from "0 total") - if (/^\d+$/.test(mainCmd)) continue + const parsed = parse(cmdStr) - // Skip common output patterns that aren't commands - const skipWords = ["total", "error", "warning", "failed", "success", "done"] - if (skipWords.includes(mainCmd.toLowerCase())) continue + const commandSeparators = new Set(["|", "&&", "||", ";"]) + let current: string[] = [] + for (const token of parsed) { + if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { + if (current.length) processCommand(current, patterns) + current = [] + } else { + current.push(String(token)) + } + } - // Only add if it contains at least one letter or is a valid path - if (/[a-zA-Z]/.test(mainCmd) || mainCmd.includes("/")) { - patterns.add(mainCmd) + if (current.length) processCommand(current, patterns) - // Build up patterns progressively (e.g., "npm", "npm install", "npm install express") - // Stop at flags or special characters - const stopPatterns = [/^-/, /[\\/.~]/] + return patterns +} - for (let i = 1; i < tokens.length; i++) { - const token = tokens[i] +export function extractCommandPatterns(command: string): string[] { + if (!command?.trim()) return [] - // Stop if we hit a flag or special character - if (stopPatterns.some((re) => re.test(token))) break + // First, check if the command contains subshells and remove them + // This is important for security - we don't want to extract patterns from subshell contents + const cleanedCommand = command + .replace(/\$\([^)]*\)/g, "") // Remove $() subshells + .replace(/`[^`]*`/g, "") // Remove backtick subshells - // Build the pattern up to this point - const pattern = tokens.slice(0, i + 1).join(" ") - patterns.add(pattern) - } - } - } + const patterns = extractPatterns(cleanedCommand) return Array.from(patterns).sort() } From 38ed84b9671f557d88930e759911ebe5a5b0dd66 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 23 Jul 2025 11:37:33 -0600 Subject: [PATCH 10/31] fix: consolidate command parsing logic and integrate security warnings - Created shared command-parser.ts to eliminate duplicate parsing logic - Integrated detectSecurityIssues to display warnings in the UI - Made command suggestions configurable (only show when restrictions are enabled) - Added comprehensive tests for the new command parser - Updated existing tests to handle the new behavior --- .roo/temp/pr-5798/architecture-review.md | 128 + .roo/temp/pr-5798/comments.json | 184 ++ .roo/temp/pr-5798/final-review.md | 93 + .roo/temp/pr-5798/pattern-analysis.md | 118 + .roo/temp/pr-5798/pr-metadata.json | 47 + .roo/temp/pr-5798/pr.diff | 2314 +++++++++++++++++ .roo/temp/pr-5798/review-context.json | 56 + .roo/temp/pr-5798/reviews.json | 79 + .roo/temp/pr-5798/test-analysis.md | 166 ++ .../src/components/chat/CommandExecution.tsx | 30 +- .../chat/__tests__/CommandExecution.spec.tsx | 44 +- .../utils/__tests__/command-parser.spec.ts | 161 ++ webview-ui/src/utils/command-parser.ts | 215 ++ webview-ui/src/utils/command-validation.ts | 185 +- webview-ui/src/utils/commandPatterns.ts | 79 +- 15 files changed, 3638 insertions(+), 261 deletions(-) create mode 100644 .roo/temp/pr-5798/architecture-review.md create mode 100644 .roo/temp/pr-5798/comments.json create mode 100644 .roo/temp/pr-5798/final-review.md create mode 100644 .roo/temp/pr-5798/pattern-analysis.md create mode 100644 .roo/temp/pr-5798/pr-metadata.json create mode 100644 .roo/temp/pr-5798/pr.diff create mode 100644 .roo/temp/pr-5798/review-context.json create mode 100644 .roo/temp/pr-5798/reviews.json create mode 100644 .roo/temp/pr-5798/test-analysis.md create mode 100644 webview-ui/src/utils/__tests__/command-parser.spec.ts create mode 100644 webview-ui/src/utils/command-parser.ts diff --git a/.roo/temp/pr-5798/architecture-review.md b/.roo/temp/pr-5798/architecture-review.md new file mode 100644 index 00000000000..211eed1dad4 --- /dev/null +++ b/.roo/temp/pr-5798/architecture-review.md @@ -0,0 +1,128 @@ +## Architecture Review for PR #5798 + +### Module Boundaries + +**✅ GOOD: Clear separation of concerns** + +- The command permission UI logic is properly separated into dedicated components: + - `CommandExecution.tsx` - Handles command execution display and permission management + - `CommandPatternSelector.tsx` - UI component for pattern selection + - `commandPatterns.ts` - Business logic for pattern extraction and validation + +**✅ GOOD: Proper layering** + +- UI components (`CommandExecution`, `CommandPatternSelector`) depend on utility functions (`commandPatterns.ts`) +- State management flows through proper channels (ExtensionStateContext → Components → VSCode messages) +- No circular dependencies detected + +**⚠️ CONCERN: Overlapping responsibilities** + +- Both `command-validation.ts` and `commandPatterns.ts` handle command parsing +- `command-validation.ts` uses shell-quote for validation logic +- `commandPatterns.ts` also uses shell-quote for pattern extraction +- This creates potential for divergent parsing behavior + +### Dependency Analysis + +**✅ GOOD: Appropriate dependency choice** + +- `shell-quote` (v1.8.2) is a well-established library for shell command parsing +- Already used in `command-validation.ts`, so no new dependency introduced +- Lightweight and focused on a single responsibility + +**⚠️ CONCERN: Dependency duplication** + +- Both runtime dependencies and devDependencies include shell-quote types +- Consider if `@types/shell-quote` should only be in devDependencies + +### Architectural Concerns + +**❌ ISSUE: Inconsistent command parsing** + +- Two separate parsing implementations: + 1. `parseCommand()` in `command-validation.ts` - Complex parsing with subshell handling + 2. `parse()` usage in `commandPatterns.ts` - Simpler pattern extraction +- Risk of commands being parsed differently for validation vs. pattern extraction + +**✅ GOOD: State synchronization** + +- Proper flow: UI → ExtensionState → VSCode messages → Backend persistence +- Uses established patterns for state updates (`setAllowedCommands`, `setDeniedCommands`) +- Backend properly validates and sanitizes command arrays + +**⚠️ CONCERN: Security considerations** + +- `commandPatterns.ts` removes subshells before pattern extraction (good) +- However, the security warning detection (`detectSecurityIssues`) is not used in the UI +- Pattern extraction might miss edge cases that the validation logic catches + +**✅ GOOD: Internationalization support** + +- All UI strings use i18n keys +- 17 translation files updated consistently +- Follows established i18n patterns + +### Impact on System Architecture + +**Integration with existing permission system:** + +- ✅ Properly integrates with existing `allowedCommands` and `deniedCommands` state +- ✅ Uses the same validation logic (`getCommandDecision`) for auto-approval/denial +- ✅ Maintains backward compatibility with existing permission settings + +**UI/UX consistency:** + +- ✅ Follows existing UI patterns (VSCode toolkit components, Tailwind styling) +- ✅ Integrates seamlessly into the command execution flow +- ✅ Provides immediate visual feedback for permission states + +**Performance considerations:** + +- ✅ Pattern extraction is memoized with `useMemo` +- ✅ No unnecessary re-renders (proper React optimization) +- ⚠️ Pattern extraction runs on every command - consider caching for repeated commands + +### Consistency with Architectural Patterns + +**✅ GOOD: Follows established patterns** + +- Component structure matches other chat components +- State management through context follows app conventions +- Message passing to extension follows established patterns + +**✅ GOOD: Test coverage** + +- Comprehensive unit tests for both components and utilities +- Tests cover edge cases and user interactions +- Follows existing test patterns + +### Recommendations + +1. **Consolidate command parsing logic** + + - Extract common parsing logic into a shared utility + - Ensure `command-validation.ts` and `commandPatterns.ts` use the same parser + - This prevents divergent behavior between validation and pattern extraction + +2. **Add pattern caching** + + - Cache extracted patterns for recently executed commands + - Reduces redundant parsing operations + +3. **Enhance security integration** + + - Use `detectSecurityIssues` from `commandPatterns.ts` to show warnings in UI + - Ensure pattern extraction doesn't bypass security checks + +4. **Consider extracting pattern management** + + - Create a dedicated service/manager for command patterns + - Would centralize pattern extraction, caching, and persistence + +5. **Add integration tests** + - Test the full flow: UI interaction → state update → backend persistence + - Ensure pattern extraction and validation remain synchronized + +### Overall Assessment + +The PR demonstrates good architectural practices with clear module boundaries and proper separation of concerns. The main architectural concern is the duplication of command parsing logic, which could lead to inconsistent behavior. The integration with the existing permission system is well-designed and maintains backward compatibility. With the recommended improvements, particularly consolidating the parsing logic, this feature would be a solid addition to the codebase. diff --git a/.roo/temp/pr-5798/comments.json b/.roo/temp/pr-5798/comments.json new file mode 100644 index 00000000000..414a2ee7167 --- /dev/null +++ b/.roo/temp/pr-5798/comments.json @@ -0,0 +1,184 @@ +[ + { + "author": { "login": "delve-auditor" }, + "authorAssociation": "NONE", + "body": "✅ **No security or compliance issues detected.** Reviewed everything up to 47259df9547fe38e0b49d7fcb6e3eef84223212a.\n\n\n\u003cdetails\u003e\n\u003csummary\u003eSecurity Overview\u003c/summary\u003e\n\n- 🔎 **Scanned files:** 24 changed file(s)\n\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003eDetected Code Changes\u003c/summary\u003e\n\nThe diff is too large to display a summary of code changes.\n\n\u003c/details\u003e\n\nReply to this PR with `@delve-auditor` followed by a description of what change you want and we'll auto-submit a change to this PR to implement it.", + "createdAt": "2025-07-17T00:30:34Z", + "id": "IC_kwDONIq5lM63sr_k", + "includesCreatedEdit": true, + "isMinimized": false, + "minimizedReason": "", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3081945060", + "viewerDidAuthor": false + }, + { + "author": { "login": "daniel-lxs" }, + "authorAssociation": "COLLABORATOR", + "body": "@roomote-agent Use the switch_mode tool to switch to pr-fixer and address the reviews on this PR, commit the changes to this PR, do not create a new one", + "createdAt": "2025-07-21T16:38:47Z", + "id": "IC_kwDONIq5lM64oHNJ", + "includesCreatedEdit": false, + "isMinimized": false, + "minimizedReason": "", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097523017", + "viewerDidAuthor": false + }, + { + "author": { "login": "roomote" }, + "authorAssociation": "NONE", + "body": "Hi @daniel-lxs! I see your request to address the reviews on this PR. Ill now switch to pr-fixer mode and work on addressing the feedback from the code reviews. Ill commit the changes directly to this PR branch as requested.", + "createdAt": "2025-07-21T17:03:24Z", + "id": "IC_kwDONIq5lM64odzj", + "includesCreatedEdit": false, + "isMinimized": true, + "minimizedReason": "RESOLVED", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097615587", + "viewerDidAuthor": false + }, + { + "author": { "login": "roomote" }, + "authorAssociation": "NONE", + "body": "Hi @daniel-lxs! I've successfully addressed all the code review feedback on this PR:\n\n✅ **Fixed Issues:**\n1. Replaced the unused `showSuggestions` state variable with a `SHOW_SUGGESTIONS` constant\n2. Fixed the `parseCommandAndOutput` import name consistency issue\n3. Added test coverage for the fallback case where `enhanced.command === text`\n\n✅ **All CI checks are passing** - The changes have been committed and pushed to the PR branch.\n\nThe PR is now ready for re-review. Thanks for your patience!", + "createdAt": "2025-07-21T17:27:39Z", + "id": "IC_kwDONIq5lM64our9", + "includesCreatedEdit": false, + "isMinimized": true, + "minimizedReason": "RESOLVED", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097684733", + "viewerDidAuthor": false + }, + { + "author": { "login": "daniel-lxs" }, + "authorAssociation": "COLLABORATOR", + "body": "@roomote-agent please also address the review by Copilot by updating this PR, do not create a new PR", + "createdAt": "2025-07-21T18:20:20Z", + "id": "IC_kwDONIq5lM64pm7T", + "includesCreatedEdit": false, + "isMinimized": true, + "minimizedReason": "RESOLVED", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097915091", + "viewerDidAuthor": false + }, + { + "author": { "login": "roomote" }, + "authorAssociation": "NONE", + "body": "Hi @daniel-lxs! I see your request to address the Copilot review feedback. I'll work on fixing the following issues identified by Copilot:\n\n1. Convert the unused `showSuggestions` state variable to a constant\n2. Fix the `parseCommandAndOutput` import issue\n3. Add test coverage for the fallback case where `enhanced.command === text`\n\nI'll update this PR with the fixes shortly.", + "createdAt": "2025-07-21T18:22:14Z", + "id": "IC_kwDONIq5lM64poKm", + "includesCreatedEdit": false, + "isMinimized": true, + "minimizedReason": "RESOLVED", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097920166", + "viewerDidAuthor": false + }, + { + "author": { "login": "roomote" }, + "authorAssociation": "NONE", + "body": "Hi @daniel-lxs! I've reviewed the Copilot feedback and found that all the issues have already been addressed in previous commits:\n\n✅ **Issue 1**: The `showSuggestions` state variable has been converted to a constant `SHOW_SUGGESTIONS` (fixed in commit 94efb1a12)\n✅ **Issue 2**: The `parseCommandAndOutput` function is properly imported from `commandPatterns.ts` and used correctly\n✅ **Issue 3**: Test coverage for the fallback case already exists in the test file (lines 467-489)\n\nAll CI checks are passing. The PR appears to be ready for review. The Copilot review seems to be based on an older version of the code before these fixes were applied.", + "createdAt": "2025-07-21T18:23:18Z", + "id": "IC_kwDONIq5lM64po0H", + "includesCreatedEdit": false, + "isMinimized": true, + "minimizedReason": "RESOLVED", + "reactionGroups": [{ "content": "THUMBS_UP", "users": { "totalCount": 1 } }], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097922823", + "viewerDidAuthor": false + }, + { + "author": { "login": "daniel-lxs" }, + "authorAssociation": "COLLABORATOR", + "body": "I am seeing some odd behavior with certain commands:\r\n\u003cimg width=\"413\" height=\"244\" alt=\"image\" src=\"https://github.com/user-attachments/assets/e76efbd8-40a6-4d6a-86f4-7c2298db92b8\" /\u003e\r\n\u003cimg width=\"411\" height=\"204\" alt=\"image\" src=\"https://github.com/user-attachments/assets/8d422a2e-e547-499c-86eb-a04aac3feefd\" /\u003e\r\n\r\nNot sure what is going on but it seems like for the first image the \"341\" appeared when the result of the command was returned, replacing the correct command prefix.\r\n\r\nShould the command be parsed from the actual tool call rather than the terminal context?\r\n\r\n", + "createdAt": "2025-07-21T22:05:57Z", + "id": "IC_kwDONIq5lM64vjai", + "includesCreatedEdit": false, + "isMinimized": true, + "minimizedReason": "RESOLVED", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3099473570", + "viewerDidAuthor": false + }, + { + "author": { "login": "hannesrudolph" }, + "authorAssociation": "MEMBER", + "body": "## Critical Issues Found\r\n\r\nThis PR duplicates significant existing functionality and introduces architectural concerns that need to be addressed:\r\n\r\n### 1. Major Code Redundancy\r\n\r\n- **Pattern Extraction**: The new `extractCommandPatterns()` duplicates the existing `parseCommand()` function with inconsistent behavior\r\n", + "createdAt": "2025-07-22T23:55:44Z", + "id": "IC_kwDONIq5lM65FUFH", + "includesCreatedEdit": true, + "isMinimized": false, + "minimizedReason": "", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3105177927", + "viewerDidAuthor": true + }, + { + "author": { "login": "hannesrudolph" }, + "authorAssociation": "MEMBER", + "body": "@roomote-agent the parser should be simplified to ```import { parse } from 'shell-quote';\r\n\r\nfunction extractPatterns(cmdStr) {\r\n const patterns = new Set();\r\n \r\n const parsed = parse(cmdStr);\r\n \r\n const commandSeparators = new Set(['|', '\u0026\u0026', '||', ';']);\r\n let current = [];\r\n for (const token of parsed) {\r\n if (typeof token === 'object' \u0026\u0026 token.op \u0026\u0026 commandSeparators.has(token.op)) {\r\n if (current.length) processCommand(current, patterns);\r\n current = [];\r\n } else {\r\n current.push(token);\r\n }\r\n }\r\n\r\n if (current.length) processCommand(current, patterns);\r\n \r\n return patterns;\r\n}``` and the filter should be simplified to ```function processCommand(cmd, patterns) {\r\n if (!cmd.length || typeof cmd[0] !== 'string') return;\r\n \r\n const mainCmd = cmd[0];\r\n patterns.add(mainCmd);\r\n \r\n const breakingExps = [ /^-/, /[\\\\/:.~ ]/, ];\r\n \r\n for (let i = 1; i \u003c cmd.length; i++) {\r\n const arg = cmd[i];\r\n\r\n if (typeof arg !== 'string' || breakingExps.some(re =\u003e re.test(arg))) break;\r\n \r\n const pattern = cmd.slice(0, i + 1).join(' ');\r\n patterns.add(pattern);\r\n }\r\n}```. That should be combined with the shell-quote library. ", + "createdAt": "2025-07-23T14:37:00Z", + "id": "IC_kwDONIq5lM65To9T", + "includesCreatedEdit": false, + "isMinimized": false, + "minimizedReason": "", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3108933459", + "viewerDidAuthor": true + }, + { + "author": { "login": "roomote" }, + "authorAssociation": "NONE", + "body": "Hi @hannesrudolph, I see your request to simplify the parser and filter functions using shell-quote. I'll implement these changes now. Working on it!", + "createdAt": "2025-07-23T14:38:54Z", + "id": "IC_kwDONIq5lM65TqxD", + "includesCreatedEdit": false, + "isMinimized": false, + "minimizedReason": "", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3108940867", + "viewerDidAuthor": false + }, + { + "author": { "login": "roomote" }, + "authorAssociation": "NONE", + "body": "Hi @hannesrudolph, I've implemented the simplified parser as requested! \n\n## Changes made:\n- ✅ Replaced custom parsing logic with the `shell-quote` library\n- ✅ Implemented the simplified `extractPatterns` function exactly as specified\n- ✅ Implemented the simplified `processCommand` helper function exactly as specified\n- ✅ Removed dependency on `parseCommand` from command-validation\n- ✅ All 60 existing tests pass without modification\n\n## Key improvements:\n- The parser now properly handles complex shell syntax using the battle-tested `shell-quote` library\n- The code is more concise and easier to understand\n- The breaking expressions have been simplified to `[ /^-/, /[\\\\/:.~ ]/ ]` as requested\n\nThe changes have been pushed to the PR branch and CI checks are currently running. The implementation maintains full backward compatibility while simplifying the codebase significantly.", + "createdAt": "2025-07-23T14:47:04Z", + "id": "IC_kwDONIq5lM65TyCz", + "includesCreatedEdit": false, + "isMinimized": false, + "minimizedReason": "", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3108970675", + "viewerDidAuthor": false + }, + { + "author": { "login": "hannesrudolph" }, + "authorAssociation": "MEMBER", + "body": "@roomote-agent run pr-reviewer mode on this and then fix the critical issues with pr-fixer mode", + "createdAt": "2025-07-23T16:25:44Z", + "id": "IC_kwDONIq5lM65VJx8", + "includesCreatedEdit": false, + "isMinimized": false, + "minimizedReason": "", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3109330044", + "viewerDidAuthor": true + }, + { + "author": { "login": "roomote" }, + "authorAssociation": "NONE", + "body": "Hi @hannesrudolph, I see your request. I will now run pr-reviewer mode on this PR to identify critical issues, then switch to pr-fixer mode to address them. Starting the review process now...", + "createdAt": "2025-07-23T16:30:01Z", + "id": "IC_kwDONIq5lM65VMvl", + "includesCreatedEdit": false, + "isMinimized": false, + "minimizedReason": "", + "reactionGroups": [], + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3109342181", + "viewerDidAuthor": false + } +] diff --git a/.roo/temp/pr-5798/final-review.md b/.roo/temp/pr-5798/final-review.md new file mode 100644 index 00000000000..531898adbeb --- /dev/null +++ b/.roo/temp/pr-5798/final-review.md @@ -0,0 +1,93 @@ +# PR Review Summary for #5798: Add terminal command permissions UI to chat interface + +## Executive Summary + +This PR implements a well-designed UI component for managing terminal command permissions directly from the chat interface. The implementation demonstrates good code quality, follows established patterns, and includes comprehensive test coverage. However, there are critical architectural concerns that should be addressed before merging. + +## Critical Issues (Must Fix) + +### 1. **Duplicate Command Parsing Logic** 🔴 + +The most significant issue is the duplication of command parsing logic between `command-validation.ts` and `commandPatterns.ts`. Both files use the `shell-quote` library but implement parsing differently, which could lead to: + +- Inconsistent behavior between validation and pattern extraction +- Security vulnerabilities if patterns bypass validation logic +- Maintenance burden with two implementations to keep in sync + +**Recommendation**: Consolidate the parsing logic into a shared utility to ensure consistency. + +### 2. **Unused Security Features** 🔴 + +The `detectSecurityIssues` function in `commandPatterns.ts` is implemented but not utilized in the UI, missing an opportunity to warn users about potentially dangerous commands. + +**Recommendation**: Integrate security warnings into the UI to alert users about subshell execution attempts. + +## Pattern Inconsistencies + +### 1. **Hardcoded Configuration** 🟡 + +The `SHOW_SUGGESTIONS = true` constant in `CommandExecution.tsx` should be configurable through extension settings rather than hardcoded. + +### 2. **Large Test Files** 🟡 + +`CommandExecution.spec.tsx` at 591 lines is too large and should be split into focused test modules for better maintainability. + +### 3. **Minor Style Inconsistencies** 🟡 + +Some inline styles are used where Tailwind classes would be more appropriate, breaking from the established pattern. + +## Redundancy Findings + +✅ **No significant redundancy found**. The implementation properly reuses existing components and utilities where appropriate. The pattern extraction logic is centralized in `commandPatterns.ts` and used consistently. + +## Architecture Concerns + +### 1. **Performance Optimization Opportunity** 🟡 + +Pattern extraction runs on every command without caching. For frequently used commands, this could impact performance. + +**Recommendation**: Implement caching for extracted patterns to improve performance. + +### 2. **Module Organization** 🟡 + +Consider creating a dedicated pattern management service to centralize pattern extraction, caching, and persistence logic. + +## Test Coverage Issues + +### 1. **Missing Test Scenarios** 🟡 + +- No error boundary tests +- Missing accessibility tests (keyboard navigation, screen reader) +- No performance tests for handling large commands + +### 2. **Test Organization** 🟡 + +Test files could benefit from better organization using shared mock utilities and test data fixtures. + +## Minor Suggestions + +1. **Documentation**: Add JSDoc comments to exported interfaces and document the command pattern extraction algorithm +2. **Type Safety**: Consider moving `@types/shell-quote` to devDependencies only +3. **Integration Tests**: Add tests for the full flow from UI interaction to backend persistence +4. **i18n**: All translations are properly implemented ✅ + +## Positive Findings + +- ✅ Excellent separation of concerns between UI and business logic +- ✅ Comprehensive test coverage (61 tests) +- ✅ Proper state synchronization with VSCode extension +- ✅ Good accessibility implementation with ARIA attributes +- ✅ Follows established UI patterns and component structure +- ✅ Backward compatible with existing permission system +- ✅ All 17 language translations included + +## Recommendation + +**APPROVE WITH CHANGES**: This PR demonstrates high-quality implementation with good patterns and test coverage. However, the critical issue of duplicate command parsing logic must be addressed before merging to prevent potential security issues and maintenance problems. Once the parsing logic is consolidated and security warnings are integrated into the UI, this will be an excellent addition to the codebase. + +## Priority Actions + +1. **High Priority**: Consolidate command parsing logic between `command-validation.ts` and `commandPatterns.ts` +2. **High Priority**: Integrate `detectSecurityIssues` warnings into the UI +3. **Medium Priority**: Make `SHOW_SUGGESTIONS` configurable +4. **Low Priority**: Split large test files and add missing test scenarios diff --git a/.roo/temp/pr-5798/pattern-analysis.md b/.roo/temp/pr-5798/pattern-analysis.md new file mode 100644 index 00000000000..515288a6a92 --- /dev/null +++ b/.roo/temp/pr-5798/pattern-analysis.md @@ -0,0 +1,118 @@ +## Pattern Analysis for PR #5798 + +### Similar Existing Implementations + +1. **Permission/Toggle Components** + + - [`AutoApproveToggle`](webview-ui/src/components/settings/AutoApproveToggle.tsx:108) - Uses toggle buttons for permissions + - [`TelemetryBanner`](webview-ui/src/components/common/TelemetryBanner.tsx:74) - Allow/Deny pattern with buttons + - [`McpToolRow`](webview-ui/src/components/mcp/McpToolRow.tsx:71) - Always Allow checkbox pattern + +2. **Expandable/Collapsible UI Components** + + - [`AutoApproveMenu`](webview-ui/src/components/chat/AutoApproveMenu.tsx:18) - Uses `isExpanded` state with chevron + - [`ContextCondenseRow`](webview-ui/src/components/chat/ContextCondenseRow.tsx:12) - Similar expand/collapse pattern + - [`CodeAccordian`](webview-ui/src/components/common/CodeAccordian.tsx:15) - Accordion pattern with `onToggleExpand` + +3. **Command/Pattern Management** + - [`AutoApproveSettings`](webview-ui/src/components/settings/AutoApproveSettings.tsx:145) - Manages allowed/denied commands + - [`McpView`](webview-ui/src/components/mcp/McpView.tsx:200) - Server management with enable/disable + +### Established Patterns + +1. **State Management Pattern** + + - Use `useState` for local UI state (expand/collapse) + - Props include arrays for allowed/denied items + - Callbacks follow `onXxxChange` naming convention + +2. **UI Interaction Patterns** + + - Chevron icons rotate based on expanded state: `rotate-0` when expanded, `-rotate-90` when collapsed + - Use `cn()` utility for conditional classes + - Buttons use icon components from lucide-react + +3. **Component Structure** + + - Props interfaces clearly defined with TypeScript + - Memoization used for performance (`memo`, `useMemo`, `useCallback`) + - Consistent use of `aria-` attributes for accessibility + +4. **Testing Patterns** + - Mock dependencies at module level + - Use `data-testid` for test selectors + - Test both UI interactions and callback invocations + - Mock translations return the key for easier testing + +### Pattern Deviations + +1. **CommandPatternSelector Implementation** + + - ✅ Follows expand/collapse pattern correctly + - ✅ Uses proper chevron rotation classes + - ✅ Implements accessibility attributes + - ⚠️ Uses inline styles in some places where classes could be used + +2. **CommandExecution Implementation** + - ✅ Properly extracts patterns using utility functions + - ✅ Follows memoization patterns + - ⚠️ Has a hardcoded `SHOW_SUGGESTIONS = true` constant that could be configurable + +### Redundancy Findings + +1. **Pattern Extraction Logic** + + - The new `extractCommandPatterns` utility properly centralizes pattern extraction + - No redundant implementations found - other components use different pattern matching + +2. **UI Components** + + - No direct redundancy with existing components + - The allow/deny button pattern is similar to other components but serves a specific purpose + +3. **State Management** + - Uses existing `useExtensionState` for allowed/denied commands + - No redundant state management + +### Organization Issues + +1. **File Organization** + + - ✅ Components properly placed in `webview-ui/src/components/chat/` + - ✅ Utilities in `webview-ui/src/utils/` + - ✅ Tests follow `__tests__` convention + +2. **Import Organization** + + - ✅ Imports are well-organized + - ✅ Uses path aliases (`@src/`, `@roo/`) + +3. **Code Structure** + - ✅ Clear separation of concerns + - ✅ Proper TypeScript interfaces + - ⚠️ Some test files are quite large (591 lines for CommandExecution.spec.tsx) + +### Recommendations + +1. **Consider Configuration** + + - Make `SHOW_SUGGESTIONS` configurable rather than hardcoded + - Could be part of extension settings + +2. **Test File Size** + + - Consider splitting large test files into smaller, focused test suites + - Group related tests into separate files + +3. **Consistency Improvements** + + - Replace inline styles with Tailwind classes where possible + - Ensure all tooltips use `StandardTooltip` component consistently + +4. **Pattern Documentation** + - Consider adding JSDoc comments to exported interfaces + - Document the command pattern extraction algorithm + +### Conclusion + +The PR follows established patterns well and integrates cleanly with the existing codebase. The implementation is consistent with similar components and properly organized. Minor improvements could be made around configurability and test organization, but overall the code quality is high and follows the project's conventions. diff --git a/.roo/temp/pr-5798/pr-metadata.json b/.roo/temp/pr-5798/pr-metadata.json new file mode 100644 index 00000000000..285949daa0d --- /dev/null +++ b/.roo/temp/pr-5798/pr-metadata.json @@ -0,0 +1,47 @@ +{ + "additions": 2015, + "author": { "id": "MDQ6VXNlcjQ5MTAzMjQ3", "is_bot": false, "login": "hannesrudolph", "name": "Hannes Rudolph" }, + "baseRefName": "main", + "body": "\n\n### Related GitHub Issue\n\n\n\nCloses: #5480 \n\n### Roo Code Task Context (Optional)\n\n\n\nN/A\n\n### Description\n\n\n\nThis PR implements an interactive UI component for managing terminal command permissions directly from the chat interface. The implementation includes:\n\n**Key Implementation Details:**\n- Created `CommandPatternSelector` component that displays command patterns with allow/deny toggle buttons\n- Integrated pattern extraction using the `shell-quote` library to handle complex shell syntax (pipes, chains, redirects)\n- Added pattern extraction logic that generates human-readable descriptions for common command patterns\n- Implemented state synchronization between UI and VSCode extension state for persistence\n- Added comprehensive test coverage (61 tests) for all new components and utilities\n\n**Design Choices:**\n- Used collapsible UI section to avoid cluttering the command execution display\n- Implemented mutual exclusivity between allow/deny lists to prevent conflicts\n- Prioritized AI-suggested patterns when available, falling back to programmatic extraction\n- Used VSCode theme variables for consistent appearance across different themes\n\n**Translation Updates:**\n- Added new translation keys to all 17 supported languages for the command permissions UI\n- All translations follow the project's localization guidelines\n\n**Areas for Review Focus:**\n- Pattern extraction logic in `commandPatterns.ts` - ensures proper handling of complex shell commands\n- State management integration in `CommandExecution.tsx` - verifies proper synchronization\n- UI/UX of the `CommandPatternSelector` component - accessibility and usability\n\n### Test Procedure\n\n\n\n**Unit Tests Added:**\n- `commandPatterns.spec.ts` (32 tests) - Tests pattern extraction, AI suggestion parsing, and edge cases\n- `CommandPatternSelector.spec.tsx` (13 tests) - Tests UI interactions, state management, and accessibility\n- `CommandExecution.spec.tsx` (16 tests) - Tests integration and message posting\n\n**Manual Testing Steps:**\n1. Open Roo Code in VSCode\n2. Execute a terminal command in the chat (e.g., `npm install express`)\n3. Click \"Manage Command Permissions\" in the command execution block\n4. Verify patterns are extracted correctly (e.g., `npm`, `npm install`)\n5. Click ✓ to add a pattern to the allowed list\n6. Click ✗ to add a pattern to the denied list\n7. Toggle patterns between states and verify visual feedback\n8. Check that changes persist across sessions\n9. Verify integration with existing settings UI\n\n**Test Results:**\n- All 61 unit tests pass\n- Manual testing verified all acceptance criteria\n- Minor lint warnings found but don't affect functionality\n\n### Pre-Submission Checklist\n\n\n\n- [x] **Issue Linked**: This PR is linked to an approved GitHub Issue (see \"Related GitHub Issue\" above).\n- [x] **Scope**: My changes are focused on the linked issue (one major feature/fix per PR).\n- [x] **Self-Review**: I have performed a thorough self-review of my code.\n- [x] **Testing**: New and/or updated tests have been added to cover my changes (if applicable).\n- [x] **Documentation Impact**: I have considered if my changes require documentation updates (see \"Documentation Updates\" section below).\n- [x] **Contribution Guidelines**: I have read and agree to the [Contributor Guidelines](/CONTRIBUTING.md).\n\n### Screenshots / Videos\n\n\n\n[Screenshots to be added showing the command permissions UI in action]\n\n### Documentation Updates\n\n\n\n- [ ] No documentation updates are required.\n- [ ] Yes, documentation updates are required. (Please describe what needs to be updated or link to a PR in the docs repository).\n\n### Additional Notes\n\n\n\nThis feature enhances the user experience by providing a more intuitive way to manage command permissions without navigating to settings. The implementation follows all existing patterns in the codebase and maintains backward compatibility with the existing permission system.\n\n### Get in Touch\n\n\n\n[Your Discord username]\n\n\n\n----\n\n> [!IMPORTANT]\n> This PR adds a UI component for managing terminal command permissions in the chat interface, with pattern extraction, state synchronization, and comprehensive test coverage.\n> \n> - **Behavior**:\n> - Adds `CommandPatternSelector` component in `CommandExecution.tsx` for managing command permissions with allow/deny toggles.\n> - Integrates `shell-quote` for pattern extraction handling complex shell syntax.\n> - Synchronizes state between UI and VSCode extension for persistence.\n> - **Pattern Extraction**:\n> - Implements pattern extraction in `commandPatterns.ts` to generate descriptions for command patterns.\n> - Handles complex shell commands like pipes, chains, and redirects.\n> - **Testing**:\n> - Adds 61 tests across `commandPatterns.spec.ts`, `CommandPatternSelector.spec.tsx`, and `CommandExecution.spec.tsx`.\n> - **Translations**:\n> - Updates translation keys for 17 languages for the command permissions UI.\n> \n> This description was created by [\"Ellipsis\"](https://www.ellipsis.dev?ref=RooCodeInc%2FRoo-Code&utm_source=github&utm_medium=referral) for b358c958bff8d817e4f848bc1683a2adff45c283. You can [customize](https://app.ellipsis.dev/RooCodeInc/settings/summaries) this summary. It will automatically update as commits are pushed.\n\n\n", + "changedFiles": 24, + "deletions": 24, + "files": [ + { "path": "webview-ui/src/components/chat/CommandExecution.tsx", "additions": 76, "deletions": 24 }, + { "path": "webview-ui/src/components/chat/CommandPatternSelector.tsx", "additions": 130, "deletions": 0 }, + { + "path": "webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx", + "additions": 591, + "deletions": 0 + }, + { + "path": "webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx", + "additions": 252, + "deletions": 0 + }, + { "path": "webview-ui/src/i18n/locales/ca/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/de/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/en/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/es/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/fr/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/hi/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/id/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/it/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/ja/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/ko/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/nl/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/pl/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/pt-BR/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/ru/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/tr/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/vi/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/zh-CN/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/i18n/locales/zh-TW/chat.json", "additions": 16, "deletions": 0 }, + { "path": "webview-ui/src/utils/__tests__/commandPatterns.spec.ts", "additions": 501, "deletions": 0 }, + { "path": "webview-ui/src/utils/commandPatterns.ts", "additions": 177, "deletions": 0 } + ], + "headRefName": "feat/issue-5480-command-permissions-ui", + "number": 5798, + "state": "OPEN", + "title": "feat: Add terminal command permissions UI to chat interface (#5480)", + "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798" +} diff --git a/.roo/temp/pr-5798/pr.diff b/.roo/temp/pr-5798/pr.diff new file mode 100644 index 00000000000..734725df54b --- /dev/null +++ b/.roo/temp/pr-5798/pr.diff @@ -0,0 +1,2314 @@ +diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx +index 8c92ec7e7b6..4ffbd32d881 100644 +--- a/webview-ui/src/components/chat/CommandExecution.tsx ++++ b/webview-ui/src/components/chat/CommandExecution.tsx +@@ -6,13 +6,19 @@ import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/ + + import { ExtensionMessage } from "@roo/ExtensionMessage" + import { safeJsonParse } from "@roo/safeJsonParse" +-import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences" + + import { vscode } from "@src/utils/vscode" + import { useExtensionState } from "@src/context/ExtensionStateContext" + import { cn } from "@src/lib/utils" + import { Button } from "@src/components/ui" + import CodeBlock from "../common/CodeBlock" ++import { CommandPatternSelector } from "./CommandPatternSelector" ++import { ++ extractCommandPatterns, ++ getPatternDescription, ++ parseCommandAndOutput, ++ CommandPattern, ++} from "../../utils/commandPatterns" + + interface CommandExecutionProps { + executionId: string +@@ -22,21 +28,73 @@ interface CommandExecutionProps { + } + + export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => { +- const { terminalShellIntegrationDisabled = false } = useExtensionState() +- +- const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text]) ++ const { ++ terminalShellIntegrationDisabled = false, ++ allowedCommands = [], ++ deniedCommands = [], ++ setAllowedCommands, ++ setDeniedCommands, ++ } = useExtensionState() ++ ++ const { command, output: parsedOutput } = useMemo(() => { ++ // Use the enhanced parser from commandPatterns ++ return parseCommandAndOutput(text || "") ++ }, [text]) + + // If we aren't opening the VSCode terminal for this command then we default + // to expanding the command execution output. + const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) + const [streamingOutput, setStreamingOutput] = useState("") + const [status, setStatus] = useState(null) ++ // Show suggestions is always enabled for command pattern management ++ const SHOW_SUGGESTIONS = true + + // The command's output can either come from the text associated with the + // task message (this is the case for completed commands) or from the + // streaming output (this is the case for running commands). + const output = streamingOutput || parsedOutput + ++ // Extract command patterns from the actual command that was executed ++ const commandPatterns = useMemo(() => { ++ const patterns: CommandPattern[] = [] ++ ++ // Always extract patterns from the actual command that was executed ++ // We don't use AI suggestions because the patterns should reflect ++ // what was actually executed, not what the AI thinks might be useful ++ const extractedPatterns = extractCommandPatterns(command) ++ extractedPatterns.forEach((pattern) => { ++ patterns.push({ ++ pattern, ++ description: getPatternDescription(pattern), ++ }) ++ }) ++ ++ return patterns ++ }, [command]) ++ ++ // Handle pattern changes ++ const handleAllowPatternChange = (pattern: string) => { ++ const isAllowed = allowedCommands.includes(pattern) ++ const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern] ++ const newDenied = deniedCommands.filter((p) => p !== pattern) ++ ++ setAllowedCommands(newAllowed) ++ setDeniedCommands(newDenied) ++ vscode.postMessage({ type: "allowedCommands", commands: newAllowed }) ++ vscode.postMessage({ type: "deniedCommands", commands: newDenied }) ++ } ++ ++ const handleDenyPatternChange = (pattern: string) => { ++ const isDenied = deniedCommands.includes(pattern) ++ const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern] ++ const newAllowed = allowedCommands.filter((p) => p !== pattern) ++ ++ setAllowedCommands(newAllowed) ++ setDeniedCommands(newDenied) ++ vscode.postMessage({ type: "allowedCommands", commands: newAllowed }) ++ vscode.postMessage({ type: "deniedCommands", commands: newDenied }) ++ } ++ + const onMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data +@@ -121,9 +179,20 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec +
+ + +-
+- +- ++
++
++ ++ ++
++ {SHOW_SUGGESTIONS && commandPatterns.length > 0 && ( ++ ++ )} +
+ + ) +@@ -142,20 +211,3 @@ const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; + ) + + const OutputContainer = memo(OutputContainerInternal) +- +-const parseCommandAndOutput = (text: string | undefined) => { +- if (!text) { +- return { command: "", output: "" } +- } +- +- const index = text.indexOf(COMMAND_OUTPUT_STRING) +- +- if (index === -1) { +- return { command: text, output: "" } +- } +- +- return { +- command: text.slice(0, index), +- output: text.slice(index + COMMAND_OUTPUT_STRING.length), +- } +-} +diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx +new file mode 100644 +index 00000000000..17799a8aecc +--- /dev/null ++++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx +@@ -0,0 +1,130 @@ ++import React, { useState } from "react" ++import { Check, ChevronDown, Info, X } from "lucide-react" ++import { cn } from "../../lib/utils" ++import { useTranslation, Trans } from "react-i18next" ++import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" ++import { CommandPattern } from "../../utils/commandPatterns" ++import { StandardTooltip } from "../ui/standard-tooltip" ++ ++interface CommandPatternSelectorProps { ++ patterns: CommandPattern[] ++ allowedCommands: string[] ++ deniedCommands: string[] ++ onAllowPatternChange: (pattern: string) => void ++ onDenyPatternChange: (pattern: string) => void ++} ++ ++export const CommandPatternSelector: React.FC = ({ ++ patterns, ++ allowedCommands, ++ deniedCommands, ++ onAllowPatternChange, ++ onDenyPatternChange, ++}) => { ++ const { t } = useTranslation() ++ const [isExpanded, setIsExpanded] = useState(false) ++ ++ const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => { ++ if (allowedCommands.includes(pattern)) return "allowed" ++ if (deniedCommands.includes(pattern)) return "denied" ++ return "none" ++ } ++ ++ return ( ++
++ ++ ++ {isExpanded && ( ++
++ {patterns.map((item, index) => { ++ const status = getPatternStatus(item.pattern) ++ return ( ++
++
++ {item.pattern} ++ {item.description && ( ++ ++ - {item.description} ++ ++ )} ++
++
++ ++ ++
++
++ ) ++ })} ++
++ )} ++
++ ) ++} +diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +new file mode 100644 +index 00000000000..162ccd007cd +--- /dev/null ++++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +@@ -0,0 +1,591 @@ ++import React from "react" ++import { render, screen, fireEvent } from "@testing-library/react" ++import { describe, it, expect, vi, beforeEach } from "vitest" ++import { CommandExecution } from "../CommandExecution" ++import { ExtensionStateContext } from "../../../context/ExtensionStateContext" ++ ++// Mock dependencies ++vi.mock("react-use", () => ({ ++ useEvent: vi.fn(), ++})) ++ ++import { vscode } from "../../../utils/vscode" ++ ++vi.mock("../../../utils/vscode", () => ({ ++ vscode: { ++ postMessage: vi.fn(), ++ }, ++})) ++ ++vi.mock("../../common/CodeBlock", () => ({ ++ default: ({ source }: { source: string }) =>
{source}
, ++})) ++ ++// Mock the commandPatterns module but use the actual implementation ++vi.mock("../../../utils/commandPatterns", async () => { ++ const actual = await vi.importActual( ++ "../../../utils/commandPatterns", ++ ) ++ return { ++ ...actual, ++ parseCommandAndOutput: actual.parseCommandAndOutput, ++ extractCommandPatterns: actual.extractCommandPatterns, ++ getPatternDescription: actual.getPatternDescription, ++ } ++}) ++ ++vi.mock("../CommandPatternSelector", () => ({ ++ CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => ( ++
++ {patterns.map((p: any, i: number) => ( ++
++ {p.pattern} ++ ++ ++
++ ))} ++
++ ), ++})) ++ ++// Mock ExtensionStateContext ++const mockExtensionState = { ++ terminalShellIntegrationDisabled: false, ++ allowedCommands: ["npm"], ++ deniedCommands: ["rm"], ++ setAllowedCommands: vi.fn(), ++ setDeniedCommands: vi.fn(), ++} ++ ++const ExtensionStateWrapper = ({ children }: { children: React.ReactNode }) => ( ++ {children} ++) ++ ++describe("CommandExecution", () => { ++ beforeEach(() => { ++ vi.clearAllMocks() ++ }) ++ ++ it("should render command without output", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ expect(screen.getByTestId("code-block")).toHaveTextContent("npm install") ++ }) ++ ++ it("should render command with output", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const codeBlocks = screen.getAllByTestId("code-block") ++ expect(codeBlocks[0]).toHaveTextContent("npm install") ++ }) ++ ++ it("should render with custom icon and title", () => { ++ const icon = 📦 ++ const title = Installing Dependencies ++ ++ render( ++ ++ ++ , ++ ) ++ ++ expect(screen.getByTestId("custom-icon")).toBeInTheDocument() ++ expect(screen.getByTestId("custom-title")).toBeInTheDocument() ++ }) ++ ++ it("should show command pattern selector for simple commands", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() ++ expect(screen.getByText("npm")).toBeInTheDocument() ++ expect(screen.getByText("npm install")).toBeInTheDocument() ++ }) ++ ++ it("should handle allow pattern change", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const allowButton = screen.getByText("Allow git") ++ fireEvent.click(allowButton) ++ ++ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git"]) ++ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git"] }) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) ++ }) ++ ++ it("should handle deny pattern change", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const denyButton = screen.getByText("Deny docker") ++ fireEvent.click(denyButton) ++ ++ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) ++ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker"]) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker"] }) ++ }) ++ ++ it("should toggle allowed pattern", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const allowButton = screen.getByText("Allow npm") ++ fireEvent.click(allowButton) ++ ++ // npm is already in allowedCommands, so it should be removed ++ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith([]) ++ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: [] }) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) ++ }) ++ ++ it("should toggle denied pattern", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const denyButton = screen.getByText("Deny rm") ++ fireEvent.click(denyButton) ++ ++ // rm is already in deniedCommands, so it should be removed ++ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) ++ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([]) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) ++ }) ++ ++ it("should parse command with Output: separator", () => { ++ const commandText = `npm install ++Output: ++Installing...` ++ ++ render( ++ ++ ++ , ++ ) ++ ++ const codeBlocks = screen.getAllByTestId("code-block") ++ expect(codeBlocks[0]).toHaveTextContent("npm install") ++ }) ++ ++ it("should parse command with AI suggestions", () => { ++ const commandText = `npm install ++Output: ++Suggested patterns: npm, npm install, npm run` ++ ++ render( ++ ++ ++ , ++ ) ++ ++ // First check that the command was parsed correctly ++ const codeBlocks = screen.getAllByTestId("code-block") ++ expect(codeBlocks[0]).toHaveTextContent("npm install") ++ expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run") ++ ++ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() ++ // Check that only patterns from the actual command are extracted, not from AI suggestions ++ expect(screen.getByText("npm")).toBeInTheDocument() ++ expect(screen.getAllByText("npm install").length).toBeGreaterThan(0) ++ // "npm run" should NOT be in the patterns since it's only in the AI suggestions, not the actual command ++ expect(screen.queryByText("npm run")).not.toBeInTheDocument() ++ }) ++ ++ it("should handle commands with pipes", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() ++ expect(screen.getByText("ls")).toBeInTheDocument() ++ expect(screen.getByText("grep")).toBeInTheDocument() ++ }) ++ ++ it("should handle commands with && operator", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() ++ expect(screen.getByText("npm")).toBeInTheDocument() ++ expect(screen.getByText("npm install")).toBeInTheDocument() ++ expect(screen.getByText("npm test")).toBeInTheDocument() ++ }) ++ ++ it("should not show pattern selector for empty commands", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() ++ }) ++ ++ it("should expand output when terminal shell integration is disabled", () => { ++ const disabledState = { ++ ...mockExtensionState, ++ terminalShellIntegrationDisabled: true, ++ } ++ ++ const commandText = `npm install ++Output: ++Output here` ++ ++ render( ++ ++ ++ , ++ ) ++ ++ // Output should be visible when shell integration is disabled ++ const codeBlocks = screen.getAllByTestId("code-block") ++ expect(codeBlocks).toHaveLength(2) // Command and output blocks ++ expect(codeBlocks[1]).toHaveTextContent("Output here") ++ }) ++ ++ it("should handle undefined allowedCommands and deniedCommands", () => { ++ const stateWithUndefined = { ++ ...mockExtensionState, ++ allowedCommands: undefined, ++ deniedCommands: undefined, ++ } ++ ++ render( ++ ++ ++ , ++ ) ++ ++ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() ++ }) ++ ++ it("should handle pattern change when moving from denied to allowed", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const allowButton = screen.getByText("Allow rm") ++ fireEvent.click(allowButton) ++ ++ // rm should be removed from denied and added to allowed ++ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm"]) ++ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([]) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm"] }) ++ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) ++ }) ++ ++ describe("integration with CommandPatternSelector", () => { ++ it("should extract patterns from complex commands with multiple operators", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const selector = screen.getByTestId("command-pattern-selector") ++ expect(selector).toBeInTheDocument() ++ expect(screen.getByText("npm")).toBeInTheDocument() ++ expect(screen.getByText("npm install")).toBeInTheDocument() ++ expect(screen.getByText("npm test")).toBeInTheDocument() ++ expect(screen.getByText("echo")).toBeInTheDocument() ++ }) ++ ++ it("should handle commands with malformed suggestions gracefully", () => { ++ const commandWithMalformedSuggestions = `npm install ++Output: ++Suggested patterns: npm, , npm install, ++Other output here` ++ ++ render( ++ ++ icon} ++ title={Run Command} ++ /> ++ , ++ ) ++ ++ const selector = screen.getByTestId("command-pattern-selector") ++ expect(selector).toBeInTheDocument() ++ // Should still show valid patterns ++ expect(screen.getAllByText("npm")[0]).toBeInTheDocument() ++ expect(screen.getAllByText("npm install")[0]).toBeInTheDocument() ++ }) ++ ++ it("should handle commands with subshells by not including them in patterns", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const selector = screen.getByTestId("command-pattern-selector") ++ expect(selector).toBeInTheDocument() ++ expect(screen.getByText("echo")).toBeInTheDocument() ++ expect(screen.getByText("git")).toBeInTheDocument() ++ expect(screen.getByText("git status")).toBeInTheDocument() ++ // Should not include subshell content ++ expect(screen.queryByText("whoami")).not.toBeInTheDocument() ++ }) ++ ++ it("should handle commands with backtick subshells", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const selector = screen.getByTestId("command-pattern-selector") ++ expect(selector).toBeInTheDocument() ++ expect(screen.getByText("git")).toBeInTheDocument() ++ expect(screen.getByText("git commit")).toBeInTheDocument() ++ // Should not include subshell content ++ expect(screen.queryByText("date")).not.toBeInTheDocument() ++ }) ++ ++ it("should handle pattern changes for commands with special characters", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ const selector = screen.getByTestId("command-pattern-selector") ++ expect(selector).toBeInTheDocument() ++ expect(screen.getByText("cd")).toBeInTheDocument() ++ expect(screen.getByText("npm")).toBeInTheDocument() ++ expect(screen.getByText("npm start")).toBeInTheDocument() ++ }) ++ ++ it("should handle commands with mixed content including output and suggestions", () => { ++ const commandWithMixedContent = `npm test ++Output: ++Running tests... ++✓ Test 1 passed ++✓ Test 2 passed ++ ++Suggested patterns: npm, npm test, npm run ++- npm ++- npm test ++- npm run test` ++ ++ render( ++ ++ icon} ++ title={Run Command} ++ /> ++ , ++ ) ++ ++ const selector = screen.getByTestId("command-pattern-selector") ++ expect(selector).toBeInTheDocument() ++ // Should show patterns only from the actual command, not from AI suggestions ++ expect(screen.getAllByText("npm")[0]).toBeInTheDocument() ++ expect(screen.getAllByText("npm test")[0]).toBeInTheDocument() ++ // "npm run" should NOT be in the patterns since it's only in the AI suggestions ++ expect(screen.queryByText("npm run")).not.toBeInTheDocument() ++ }) ++ ++ it("should update both allowed and denied lists when patterns conflict", () => { ++ const conflictState = { ++ ...mockExtensionState, ++ allowedCommands: ["git"], ++ deniedCommands: ["git push"], ++ } ++ ++ render( ++ ++ ++ , ++ ) ++ ++ // Click to allow "git push" ++ const allowButton = screen.getByText("Allow git push") ++ fireEvent.click(allowButton) ++ ++ // Should add to allowed and remove from denied ++ expect(conflictState.setAllowedCommands).toHaveBeenCalledWith(["git", "git push"]) ++ expect(conflictState.setDeniedCommands).toHaveBeenCalledWith([]) ++ }) ++ ++ it("should handle commands that cannot be parsed and fallback gracefully", () => { ++ // Test with a command that might cause parsing issues ++ const unparsableCommand = "echo 'test with unclosed quote" ++ ++ render( ++ ++ ++ , ++ ) ++ ++ // Should still render the command ++ expect(screen.getByTestId("code-block")).toHaveTextContent("echo 'test with unclosed quote") ++ ++ // Should show pattern selector with at least the main command ++ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() ++ expect(screen.getByText("echo")).toBeInTheDocument() ++ }) ++ ++ it("should handle empty or whitespace-only commands", () => { ++ render( ++ ++ ++ , ++ ) ++ ++ // Should render without errors ++ expect(screen.getByTestId("code-block")).toBeInTheDocument() ++ ++ // Should not show pattern selector for empty commands ++ expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() ++ }) ++ ++ it("should handle commands with only output and no command prefix", () => { ++ const outputOnly = `Some output without a command ++Multiple lines of output ++Without any command prefix` ++ ++ render( ++ ++ ++ , ++ ) ++ ++ // Should treat the entire text as command when no prefix is found ++ const codeBlock = screen.getByTestId("code-block") ++ // The mock CodeBlock component renders text content without preserving newlines ++ expect(codeBlock.textContent).toContain("Some output without a command") ++ expect(codeBlock.textContent).toContain("Multiple lines of output") ++ expect(codeBlock.textContent).toContain("Without any command prefix") ++ }) ++ ++ it("should handle fallback case where parsed command equals original text", () => { ++ // This tests the case where parseCommandAndOutput returns command === text ++ // which happens when there's no output separator or command prefix ++ const plainCommand = "docker build ." ++ ++ render( ++ ++ ++ , ++ ) ++ ++ // Should render the command ++ expect(screen.getByTestId("code-block")).toHaveTextContent("docker build .") ++ ++ // Should show pattern selector with extracted patterns ++ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() ++ expect(screen.getByText("docker")).toBeInTheDocument() ++ expect(screen.getByText("docker build")).toBeInTheDocument() ++ ++ // Verify no output is shown (since command === text means no output) ++ const codeBlocks = screen.getAllByTestId("code-block") ++ expect(codeBlocks).toHaveLength(1) // Only the command block, no output block ++ }) ++ ++ it("should not extract patterns from command output numbers", () => { ++ // This tests the specific bug where "0 total" from wc output was being extracted as a command ++ const commandWithNumericOutput = `wc -l *.go *.java ++Output: ++ 10 file1.go ++ 20 file2.go ++ 15 Main.java ++ 45 total` ++ ++ render( ++ ++ ++ , ++ ) ++ ++ // Should render the command and output ++ const codeBlocks = screen.getAllByTestId("code-block") ++ expect(codeBlocks[0]).toHaveTextContent("wc -l *.go *.java") ++ ++ // Should show pattern selector ++ const selector = screen.getByTestId("command-pattern-selector") ++ expect(selector).toBeInTheDocument() ++ ++ // Should only extract "wc" from the actual command ++ expect(screen.getByText("wc")).toBeInTheDocument() ++ ++ // Should NOT extract numeric patterns from output like "45 total" ++ expect(screen.queryByText("45")).not.toBeInTheDocument() ++ expect(screen.queryByText("total")).not.toBeInTheDocument() ++ expect(screen.queryByText("45 total")).not.toBeInTheDocument() ++ }) ++ ++ it("should handle the edge case of 0 total in output", () => { ++ // This is the exact case from the bug report ++ const commandWithZeroTotal = `wc -l *.go *.java ++Output: ++ 0 total` ++ ++ render( ++ ++ ++ , ++ ) ++ ++ // Should show pattern selector ++ const selector = screen.getByTestId("command-pattern-selector") ++ expect(selector).toBeInTheDocument() ++ ++ // Should only extract "wc" from the actual command ++ // Check within the pattern selector specifically ++ const patternTexts = Array.from(selector.querySelectorAll("span")).map((el) => el.textContent) ++ ++ // Should have "wc" as a pattern ++ expect(patternTexts).toContain("wc") ++ ++ // Should NOT have "0", "total", or "0 total" as patterns ++ expect(patternTexts).not.toContain("0") ++ expect(patternTexts).not.toContain("total") ++ expect(patternTexts).not.toContain("0 total") ++ ++ // The output should still be displayed in the code block ++ const codeBlocks = screen.getAllByTestId("code-block") ++ expect(codeBlocks.length).toBeGreaterThan(1) ++ expect(codeBlocks[1]).toHaveTextContent("0 total") ++ }) ++ }) ++}) +diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +new file mode 100644 +index 00000000000..4dd69e3969a +--- /dev/null ++++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +@@ -0,0 +1,252 @@ ++import React from "react" ++import { render, screen, fireEvent } from "@testing-library/react" ++import { describe, it, expect, vi, beforeEach } from "vitest" ++import { CommandPatternSelector } from "../CommandPatternSelector" ++import { CommandPattern } from "../../../utils/commandPatterns" ++ ++// Mock react-i18next ++vi.mock("react-i18next", () => ({ ++ useTranslation: () => ({ ++ t: (key: string) => key, ++ }), ++ Trans: ({ i18nKey, components }: any) => { ++ if (i18nKey === "chat:commandExecution.commandManagementDescription") { ++ return ( ++ ++ Manage command permissions: Click ✓ to allow auto-execution, ✗ to deny execution. Patterns can be ++ toggled on/off or removed from lists. {components.settingsLink} ++ ++ ) ++ } ++ return {i18nKey} ++ }, ++})) ++ ++// Mock VSCodeLink ++vi.mock("@vscode/webview-ui-toolkit/react", () => ({ ++ VSCodeLink: ({ children, onClick }: any) => ( ++ ++ {children || "View all settings"} ++ ++ ), ++})) ++ ++// Mock StandardTooltip ++vi.mock("../../ui/standard-tooltip", () => ({ ++ StandardTooltip: ({ children, content }: any) => ( ++
++ {children} ++ {/* Render the content to make it testable */} ++
{content}
++
++ ), ++})) ++ ++// Mock window.postMessage ++const mockPostMessage = vi.fn() ++window.postMessage = mockPostMessage ++ ++describe("CommandPatternSelector", () => { ++ const mockPatterns: CommandPattern[] = [ ++ { pattern: "npm", description: "npm commands" }, ++ { pattern: "npm install", description: "npm install commands" }, ++ { pattern: "git", description: "git commands" }, ++ ] ++ ++ const defaultProps = { ++ patterns: mockPatterns, ++ allowedCommands: ["npm"], ++ deniedCommands: ["git"], ++ onAllowPatternChange: vi.fn(), ++ onDenyPatternChange: vi.fn(), ++ } ++ ++ beforeEach(() => { ++ vi.clearAllMocks() ++ }) ++ ++ it("should render collapsed by default", () => { ++ render() ++ ++ expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument() ++ expect(screen.queryByText("npm commands")).not.toBeInTheDocument() ++ }) ++ ++ it("should expand when clicked", () => { ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ // Check for the patterns themselves ++ expect(screen.getByText("npm")).toBeInTheDocument() ++ expect(screen.getByText("npm install")).toBeInTheDocument() ++ expect(screen.getByText("git")).toBeInTheDocument() ++ ++ // Check for the descriptions ++ expect(screen.getByText("- npm commands")).toBeInTheDocument() ++ expect(screen.getByText("- npm install commands")).toBeInTheDocument() ++ expect(screen.getByText("- git commands")).toBeInTheDocument() ++ }) ++ ++ it("should collapse when clicked again", () => { ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ const collapseButton = screen.getByRole("button", { name: "chat:commandExecution.collapseManagement" }) ++ fireEvent.click(collapseButton) ++ ++ expect(screen.queryByText("npm commands")).not.toBeInTheDocument() ++ }) ++ ++ it("should show correct status for patterns", () => { ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ // Check that npm has allowed styling (green) ++ const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0] ++ expect(npmAllowButton).toHaveClass("bg-green-500/20") ++ ++ // Check that git has denied styling (red) ++ const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0] ++ expect(gitDenyButton).toHaveClass("bg-red-500/20") ++ }) ++ ++ it("should call onAllowPatternChange when allow button is clicked", () => { ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ // Find all allow buttons with the "add to allowed" label ++ const allowButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToAllowed" }) ++ ++ // The second one should be for npm install (first is npm which is already allowed) ++ fireEvent.click(allowButtons[0]) ++ ++ expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm install") ++ }) ++ ++ it("should call onDenyPatternChange when deny button is clicked", () => { ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ // Find all deny buttons with the "add to denied" label ++ const denyButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToDenied" }) ++ ++ // The second one should be for npm install (first is npm, third is git which is already denied) ++ fireEvent.click(denyButtons[1]) ++ ++ expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("npm install") ++ }) ++ ++ it("should toggle allowed pattern when clicked", () => { ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ // Find the allow button for npm (which is already allowed) ++ const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0] ++ fireEvent.click(npmAllowButton) ++ ++ expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm") ++ }) ++ ++ it("should toggle denied pattern when clicked", () => { ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ // Find the deny button for git (which is already denied) ++ const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0] ++ fireEvent.click(gitDenyButton) ++ ++ expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("git") ++ }) ++ ++ it("should have tooltip with settings link", () => { ++ const { container } = render() ++ ++ // The info icon should have a tooltip ++ const tooltipWrapper = container.querySelector('[title="tooltip"]') ++ expect(tooltipWrapper).toBeTruthy() ++ ++ // The tooltip content includes a settings link (mocked as VSCodeLink) ++ // It's rendered in a hidden div for testing purposes ++ const settingsLink = container.querySelector('a[href="#"]') ++ expect(settingsLink).toBeTruthy() ++ expect(settingsLink?.textContent).toBe("View all settings") ++ ++ // Test that clicking the link posts the correct message ++ if (settingsLink) { ++ fireEvent.click(settingsLink) ++ ++ expect(mockPostMessage).toHaveBeenCalledWith( ++ { ++ type: "action", ++ action: "settingsButtonClicked", ++ values: { section: "autoApprove" }, ++ }, ++ "*", ++ ) ++ } ++ }) ++ ++ it("should render with empty patterns", () => { ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ // The expanded view should exist but be empty since there are no patterns ++ const expandedContent = screen ++ .getByRole("button", { name: "chat:commandExecution.collapseManagement" }) ++ .parentElement?.querySelector(".px-3.pb-3") ++ expect(expandedContent).toBeInTheDocument() ++ expect(expandedContent?.children.length).toBe(0) ++ }) ++ ++ it("should render patterns without descriptions", () => { ++ const patternsWithoutDesc: CommandPattern[] = [{ pattern: "custom-command" }] ++ ++ render() ++ ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ expect(screen.getByText("custom-command")).toBeInTheDocument() ++ }) ++ ++ it("should always show info icon with tooltip", () => { ++ const { container } = render() ++ ++ // Info icon should always be visible (not just when expanded) ++ // Look for the Info icon which is wrapped in StandardTooltip ++ const infoIcon = container.querySelector(".ml-1") ++ expect(infoIcon).toBeTruthy() ++ }) ++ ++ it("should apply correct classes for chevron rotation", () => { ++ const { container } = render() ++ ++ // Initially collapsed - chevron should be rotated ++ let chevron = container.querySelector(".size-3.transition-transform") ++ expect(chevron).toHaveClass("-rotate-90") ++ ++ // Click to expand ++ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) ++ fireEvent.click(expandButton) ++ ++ // When expanded - chevron should not be rotated ++ chevron = container.querySelector(".size-3.transition-transform") ++ expect(chevron).toHaveClass("rotate-0") ++ }) ++}) +diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json +index 4c24d69f087..8f1b7dc34c7 100644 +--- a/webview-ui/src/i18n/locales/ca/chat.json ++++ b/webview-ui/src/i18n/locales/ca/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo ha vist noms de definicions de codi font utilitzats en aquest directori (fora de l'espai de treball):" + }, + "commandOutput": "Sortida de l'ordre", ++ "commandExecution": { ++ "running": "Executant", ++ "pid": "PID: {{pid}}", ++ "exited": "Finalitzat ({{exitCode}})", ++ "manageCommands": "Gestiona els permisos de les ordres", ++ "commandManagementDescription": "Gestiona els permisos de les ordres: Fes clic a ✓ per permetre l'execució automàtica, ✗ per denegar l'execució. Els patrons es poden activar/desactivar o eliminar de les llistes. Mostra tots els paràmetres", ++ "addToAllowed": "Afegeix a la llista de permesos", ++ "removeFromAllowed": "Elimina de la llista de permesos", ++ "addToDenied": "Afegeix a la llista de denegats", ++ "removeFromDenied": "Elimina de la llista de denegats", ++ "abortCommand": "Interromp l'execució de l'ordre", ++ "expandOutput": "Amplia la sortida", ++ "collapseOutput": "Redueix la sortida", ++ "expandManagement": "Amplia la secció de gestió d'ordres", ++ "collapseManagement": "Redueix la secció de gestió d'ordres" ++ }, + "response": "Resposta", + "arguments": "Arguments", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json +index 8f09fab831b..87f40edf2b8 100644 +--- a/webview-ui/src/i18n/locales/de/chat.json ++++ b/webview-ui/src/i18n/locales/de/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo hat Quellcode-Definitionsnamen in diesem Verzeichnis (außerhalb des Arbeitsbereichs) angezeigt:" + }, + "commandOutput": "Befehlsausgabe", ++ "commandExecution": { ++ "running": "Wird ausgeführt", ++ "pid": "PID: {{pid}}", ++ "exited": "Beendet ({{exitCode}})", ++ "manageCommands": "Befehlsberechtigungen verwalten", ++ "commandManagementDescription": "Befehlsberechtigungen verwalten: Klicke auf ✓, um die automatische Ausführung zu erlauben, ✗, um die Ausführung zu verweigern. Muster können ein-/ausgeschaltet oder aus Listen entfernt werden. Alle Einstellungen anzeigen", ++ "addToAllowed": "Zur Liste der erlaubten Befehle hinzufügen", ++ "removeFromAllowed": "Von der Liste der erlaubten Befehle entfernen", ++ "addToDenied": "Zur Liste der verweigerten Befehle hinzufügen", ++ "removeFromDenied": "Von der Liste der verweigerten Befehle entfernen", ++ "abortCommand": "Befehlsausführung abbrechen", ++ "expandOutput": "Ausgabe erweitern", ++ "collapseOutput": "Ausgabe einklappen", ++ "expandManagement": "Befehlsverwaltungsbereich erweitern", ++ "collapseManagement": "Befehlsverwaltungsbereich einklappen" ++ }, + "response": "Antwort", + "arguments": "Argumente", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json +index 53e529d4e45..c1ead772ea1 100644 +--- a/webview-ui/src/i18n/locales/en/chat.json ++++ b/webview-ui/src/i18n/locales/en/chat.json +@@ -208,6 +208,22 @@ + "resultTooltip": "Similarity score: {{score}} (click to open file)" + }, + "commandOutput": "Command Output", ++ "commandExecution": { ++ "running": "Running", ++ "pid": "PID: {{pid}}", ++ "exited": "Exited ({{exitCode}})", ++ "manageCommands": "Manage Command Permissions", ++ "commandManagementDescription": "Manage command permissions: Click ✓ to allow auto-execution, ✗ to deny execution. Patterns can be toggled on/off or removed from lists. View all settings", ++ "addToAllowed": "Add to allowed list", ++ "removeFromAllowed": "Remove from allowed list", ++ "addToDenied": "Add to denied list", ++ "removeFromDenied": "Remove from denied list", ++ "abortCommand": "Abort command execution", ++ "expandOutput": "Expand output", ++ "collapseOutput": "Collapse output", ++ "expandManagement": "Expand command management section", ++ "collapseManagement": "Collapse command management section" ++ }, + "response": "Response", + "arguments": "Arguments", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json +index bb84baa555a..5a749085201 100644 +--- a/webview-ui/src/i18n/locales/es/chat.json ++++ b/webview-ui/src/i18n/locales/es/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo vio nombres de definiciones de código fuente utilizados en este directorio (fuera del espacio de trabajo):" + }, + "commandOutput": "Salida del comando", ++ "commandExecution": { ++ "running": "Ejecutando", ++ "pid": "PID: {{pid}}", ++ "exited": "Finalizado ({{exitCode}})", ++ "manageCommands": "Gestionar permisos de comandos", ++ "commandManagementDescription": "Gestionar permisos de comandos: Haz clic en ✓ para permitir la ejecución automática, ✗ para denegar la ejecución. Los patrones se pueden activar/desactivar o eliminar de las listas. Ver todos los ajustes", ++ "addToAllowed": "Añadir a la lista de permitidos", ++ "removeFromAllowed": "Eliminar de la lista de permitidos", ++ "addToDenied": "Añadir a la lista de denegados", ++ "removeFromDenied": "Eliminar de la lista de denegados", ++ "abortCommand": "Abortar ejecución del comando", ++ "expandOutput": "Expandir salida", ++ "collapseOutput": "Contraer salida", ++ "expandManagement": "Expandir sección de gestión de comandos", ++ "collapseManagement": "Contraer sección de gestión de comandos" ++ }, + "response": "Respuesta", + "arguments": "Argumentos", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json +index 70bd6011dd7..e81f1a9606f 100644 +--- a/webview-ui/src/i18n/locales/fr/chat.json ++++ b/webview-ui/src/i18n/locales/fr/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo a vu les noms de définitions de code source utilisés dans ce répertoire (hors espace de travail) :" + }, + "commandOutput": "Sortie de commande", ++ "commandExecution": { ++ "running": "En cours d'exécution", ++ "pid": "PID : {{pid}}", ++ "exited": "Terminé ({{exitCode}})", ++ "manageCommands": "Gérer les autorisations de commande", ++ "commandManagementDescription": "Gérer les autorisations de commande : Cliquez sur ✓ pour autoriser l'exécution automatique, ✗ pour refuser l'exécution. Les modèles peuvent être activés/désactivés ou supprimés des listes. Voir tous les paramètres", ++ "addToAllowed": "Ajouter à la liste autorisée", ++ "removeFromAllowed": "Retirer de la liste autorisée", ++ "addToDenied": "Ajouter à la liste refusée", ++ "removeFromDenied": "Retirer de la liste refusée", ++ "abortCommand": "Abandonner l'exécution de la commande", ++ "expandOutput": "Développer la sortie", ++ "collapseOutput": "Réduire la sortie", ++ "expandManagement": "Développer la section de gestion des commandes", ++ "collapseManagement": "Réduire la section de gestion des commandes" ++ }, + "response": "Réponse", + "arguments": "Arguments", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json +index 0fa95c27089..2ea44c4fbce 100644 +--- a/webview-ui/src/i18n/locales/hi/chat.json ++++ b/webview-ui/src/i18n/locales/hi/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo ने इस निर्देशिका (कार्यक्षेत्र के बाहर) में उपयोग किए गए सोर्स कोड परिभाषा नामों को देखा:" + }, + "commandOutput": "कमांड आउटपुट", ++ "commandExecution": { ++ "running": "चलाया जा रहा है", ++ "pid": "पीआईडी: {{pid}}", ++ "exited": "बाहर निकल गया ({{exitCode}})", ++ "manageCommands": "कमांड अनुमतियाँ प्रबंधित करें", ++ "commandManagementDescription": "कमांड अनुमतियों का प्रबंधन करें: स्वतः-निष्पादन की अनुमति देने के लिए ✓ पर क्लिक करें, निष्पादन से इनकार करने के लिए ✗ पर क्लिक करें। पैटर्न को चालू/बंद किया जा सकता है या सूचियों से हटाया जा सकता है। सभी सेटिंग्स देखें", ++ "addToAllowed": "अनुमत सूची में जोड़ें", ++ "removeFromAllowed": "अनुमत सूची से हटाएं", ++ "addToDenied": "अस्वीकृत सूची में जोड़ें", ++ "removeFromDenied": "अस्वीकृत सूची से हटाएं", ++ "abortCommand": "कमांड निष्पादन रद्द करें", ++ "expandOutput": "आउटपुट का विस्तार करें", ++ "collapseOutput": "आउटपुट संक्षिप्त करें", ++ "expandManagement": "कमांड प्रबंधन अनुभाग का विस्तार करें", ++ "collapseManagement": "कमांड प्रबंधन अनुभाग संक्षिप्त करें" ++ }, + "response": "प्रतिक्रिया", + "arguments": "आर्ग्युमेंट्स", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json +index f8e2a5cb0ec..994d8a9e56f 100644 +--- a/webview-ui/src/i18n/locales/id/chat.json ++++ b/webview-ui/src/i18n/locales/id/chat.json +@@ -214,6 +214,22 @@ + "resultTooltip": "Skor kemiripan: {{score}} (klik untuk membuka file)" + }, + "commandOutput": "Output Perintah", ++ "commandExecution": { ++ "running": "Menjalankan", ++ "pid": "PID: {{pid}}", ++ "exited": "Keluar ({{exitCode}})", ++ "manageCommands": "Kelola Izin Perintah", ++ "commandManagementDescription": "Kelola izin perintah: Klik ✓ untuk mengizinkan eksekusi otomatis, ✗ untuk menolak eksekusi. Pola dapat diaktifkan/dinonaktifkan atau dihapus dari daftar. Lihat semua pengaturan", ++ "addToAllowed": "Tambahkan ke daftar yang diizinkan", ++ "removeFromAllowed": "Hapus dari daftar yang diizinkan", ++ "addToDenied": "Tambahkan ke daftar yang ditolak", ++ "removeFromDenied": "Hapus dari daftar yang ditolak", ++ "abortCommand": "Batalkan eksekusi perintah", ++ "expandOutput": "Perluas output", ++ "collapseOutput": "Ciutkan output", ++ "expandManagement": "Perluas bagian manajemen perintah", ++ "collapseManagement": "Ciutkan bagian manajemen perintah" ++ }, + "response": "Respons", + "arguments": "Argumen", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json +index bea63c047a6..5b6c7d43bdf 100644 +--- a/webview-ui/src/i18n/locales/it/chat.json ++++ b/webview-ui/src/i18n/locales/it/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo ha visualizzato i nomi delle definizioni di codice sorgente utilizzate in questa directory (fuori dall'area di lavoro):" + }, + "commandOutput": "Output del comando", ++ "commandExecution": { ++ "running": "In esecuzione", ++ "pid": "PID: {{pid}}", ++ "exited": "Terminato ({{exitCode}})", ++ "manageCommands": "Gestisci autorizzazioni comandi", ++ "commandManagementDescription": "Gestisci le autorizzazioni dei comandi: fai clic su ✓ per consentire l'esecuzione automatica, ✗ per negare l'esecuzione. I pattern possono essere attivati/disattivati o rimossi dagli elenchi. Visualizza tutte le impostazioni", ++ "addToAllowed": "Aggiungi all'elenco consentiti", ++ "removeFromAllowed": "Rimuovi dall'elenco consentiti", ++ "addToDenied": "Aggiungi all'elenco negati", ++ "removeFromDenied": "Rimuovi dall'elenco negati", ++ "abortCommand": "Interrompi esecuzione comando", ++ "expandOutput": "Espandi output", ++ "collapseOutput": "Comprimi output", ++ "expandManagement": "Espandi la sezione di gestione dei comandi", ++ "collapseManagement": "Comprimi la sezione di gestione dei comandi" ++ }, + "response": "Risposta", + "arguments": "Argomenti", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json +index ca6443b3d81..77f4148a11d 100644 +--- a/webview-ui/src/i18n/locales/ja/chat.json ++++ b/webview-ui/src/i18n/locales/ja/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)で使用されているソースコード定義名を表示しました:" + }, + "commandOutput": "コマンド出力", ++ "commandExecution": { ++ "running": "実行中", ++ "pid": "PID: {{pid}}", ++ "exited": "終了しました ({{exitCode}})", ++ "manageCommands": "コマンド権限の管理", ++ "commandManagementDescription": "コマンドの権限を管理します:✓ をクリックして自動実行を許可し、✗ をクリックして実行を拒否します。パターンはオン/オフの切り替えやリストからの削除が可能です。すべての設定を表示", ++ "addToAllowed": "許可リストに追加", ++ "removeFromAllowed": "許可リストから削除", ++ "addToDenied": "拒否リストに追加", ++ "removeFromDenied": "拒否リストから削除", ++ "abortCommand": "コマンドの実行を中止", ++ "expandOutput": "出力を展開", ++ "collapseOutput": "出力を折りたたむ", ++ "expandManagement": "コマンド管理セクションを展開", ++ "collapseManagement": "コマンド管理セクションを折りたたむ" ++ }, + "response": "応答", + "arguments": "引数", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json +index 7e2c4467cd6..7cae0e469b8 100644 +--- a/webview-ui/src/i18n/locales/ko/chat.json ++++ b/webview-ui/src/i18n/locales/ko/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)에서 사용된 소스 코드 정의 이름을 보았습니다:" + }, + "commandOutput": "명령 출력", ++ "commandExecution": { ++ "running": "실행 중", ++ "pid": "PID: {{pid}}", ++ "exited": "종료됨 ({{exitCode}})", ++ "manageCommands": "명령 권한 관리", ++ "commandManagementDescription": "명령 권한 관리: 자동 실행을 허용하려면 ✓를 클릭하고 실행을 거부하려면 ✗를 클릭하십시오. 패턴은 켜거나 끄거나 목록에서 제거할 수 있습니다. 모든 설정 보기", ++ "addToAllowed": "허용 목록에 추가", ++ "removeFromAllowed": "허용 목록에서 제거", ++ "addToDenied": "거부 목록에 추가", ++ "removeFromDenied": "거부 목록에서 제거", ++ "abortCommand": "명령 실행 중단", ++ "expandOutput": "출력 확장", ++ "collapseOutput": "출력 축소", ++ "expandManagement": "명령 관리 섹션 확장", ++ "collapseManagement": "명령 관리 섹션 축소" ++ }, + "response": "응답", + "arguments": "인수", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json +index e123b5e8f2a..e263b8800ee 100644 +--- a/webview-ui/src/i18n/locales/nl/chat.json ++++ b/webview-ui/src/i18n/locales/nl/chat.json +@@ -187,6 +187,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo heeft broncode-definitienamen bekeken die in deze map (buiten werkruimte) worden gebruikt:" + }, + "commandOutput": "Commando-uitvoer", ++ "commandExecution": { ++ "running": "Lopend", ++ "pid": "PID: {{pid}}", ++ "exited": "Afgesloten ({{exitCode}})", ++ "manageCommands": "Beheer Commando Toestemmingen", ++ "commandManagementDescription": "Beheer commando toestemmingen: Klik op ✓ om automatische uitvoering toe te staan, ✗ om uitvoering te weigeren. Patronen kunnen worden in- of uitgeschakeld of uit lijsten worden verwijderd. Bekijk alle instellingen", ++ "addToAllowed": "Toevoegen aan toegestane lijst", ++ "removeFromAllowed": "Verwijderen van toegestane lijst", ++ "addToDenied": "Toevoegen aan geweigerde lijst", ++ "removeFromDenied": "Verwijderen van geweigerde lijst", ++ "abortCommand": "Commando-uitvoering afbreken", ++ "expandOutput": "Uitvoer uitvouwen", ++ "collapseOutput": "Uitvoer samenvouwen", ++ "expandManagement": "Beheersectie voor commando's uitvouwen", ++ "collapseManagement": "Beheersectie voor commando's samenvouwen" ++ }, + "response": "Antwoord", + "arguments": "Argumenten", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json +index f772256b102..a80f5351392 100644 +--- a/webview-ui/src/i18n/locales/pl/chat.json ++++ b/webview-ui/src/i18n/locales/pl/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo zobaczył nazwy definicji kodu źródłowego używane w tym katalogu (poza obszarem roboczym):" + }, + "commandOutput": "Wyjście polecenia", ++ "commandExecution": { ++ "running": "Wykonywanie", ++ "pid": "PID: {{pid}}", ++ "exited": "Zakończono ({{exitCode}})", ++ "manageCommands": "Zarządzaj uprawnieniami poleceń", ++ "commandManagementDescription": "Zarządzaj uprawnieniami poleceń: Kliknij ✓, aby zezwolić na automatyczne wykonanie, ✗, aby odmówić wykonania. Wzorce można włączać/wyłączać lub usuwać z listy. Zobacz wszystkie ustawienia", ++ "addToAllowed": "Dodaj do listy dozwolonych", ++ "removeFromAllowed": "Usuń z listy dozwolonych", ++ "addToDenied": "Dodaj do listy odrzuconych", ++ "removeFromDenied": "Usuń z listy odrzuconych", ++ "abortCommand": "Przerwij wykonywanie polecenia", ++ "expandOutput": "Rozwiń wyjście", ++ "collapseOutput": "Zwiń wyjście", ++ "expandManagement": "Rozwiń sekcję zarządzania poleceniami", ++ "collapseManagement": "Zwiń sekcję zarządzania poleceniami" ++ }, + "response": "Odpowiedź", + "arguments": "Argumenty", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json +index 08eb496d0a9..e1473e101d2 100644 +--- a/webview-ui/src/i18n/locales/pt-BR/chat.json ++++ b/webview-ui/src/i18n/locales/pt-BR/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo visualizou nomes de definição de código-fonte usados neste diretório (fora do espaço de trabalho):" + }, + "commandOutput": "Saída do comando", ++ "commandExecution": { ++ "running": "Executando", ++ "pid": "PID: {{pid}}", ++ "exited": "Encerrado ({{exitCode}})", ++ "manageCommands": "Gerenciar Permissões de Comando", ++ "commandManagementDescription": "Gerencie as permissões de comando: Clique em ✓ para permitir a execução automática, ✗ para negar a execução. Os padrões podem ser ativados/desativados ou removidos das listas. Ver todas as configurações", ++ "addToAllowed": "Adicionar à lista de permitidos", ++ "removeFromAllowed": "Remover da lista de permitidos", ++ "addToDenied": "Adicionar à lista de negados", ++ "removeFromDenied": "Remover da lista de negados", ++ "abortCommand": "Abortar execução do comando", ++ "expandOutput": "Expandir saída", ++ "collapseOutput": "Recolher saída", ++ "expandManagement": "Expandir seção de gerenciamento de comandos", ++ "collapseManagement": "Recolher seção de gerenciamento de comandos" ++ }, + "response": "Resposta", + "arguments": "Argumentos", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json +index 07e05015051..7e68295101f 100644 +--- a/webview-ui/src/i18n/locales/ru/chat.json ++++ b/webview-ui/src/i18n/locales/ru/chat.json +@@ -187,6 +187,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo просмотрел имена определений исходного кода в этой директории (вне рабочего пространства):" + }, + "commandOutput": "Вывод команды", ++ "commandExecution": { ++ "running": "Выполняется", ++ "pid": "PID: {{pid}}", ++ "exited": "Завершено ({{exitCode}})", ++ "manageCommands": "Управление разрешениями команд", ++ "commandManagementDescription": "Управляйте разрешениями команд: Нажмите ✓, чтобы разрешить автоматическое выполнение, ✗, чтобы запретить выполнение. Шаблоны можно включать/выключать или удалять из списков. Просмотреть все настройки", ++ "addToAllowed": "Добавить в список разрешенных", ++ "removeFromAllowed": "Удалить из списка разрешенных", ++ "addToDenied": "Добавить в список запрещенных", ++ "removeFromDenied": "Удалить из списка запрещенных", ++ "abortCommand": "Прервать выполнение команды", ++ "expandOutput": "Развернуть вывод", ++ "collapseOutput": "Свернуть вывод", ++ "expandManagement": "Развернуть раздел управления командами", ++ "collapseManagement": "Свернуть раздел управления командами" ++ }, + "response": "Ответ", + "arguments": "Аргументы", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json +index ee16f56f72b..c797287b717 100644 +--- a/webview-ui/src/i18n/locales/tr/chat.json ++++ b/webview-ui/src/i18n/locales/tr/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo bu dizinde (çalışma alanı dışında) kullanılan kaynak kod tanımlama isimlerini görüntüledi:" + }, + "commandOutput": "Komut Çıktısı", ++ "commandExecution": { ++ "running": "Çalışıyor", ++ "pid": "PID: {{pid}}", ++ "exited": "Çıkıldı ({{exitCode}})", ++ "manageCommands": "Komut İzinlerini Yönet", ++ "commandManagementDescription": "Komut izinlerini yönetin: Otomatik yürütmeye izin vermek için ✓'e, yürütmeyi reddetmek için ✗'e tıklayın. Desenler açılıp kapatılabilir veya listelerden kaldırılabilir. Tüm ayarları görüntüle", ++ "addToAllowed": "İzin verilenler listesine ekle", ++ "removeFromAllowed": "İzin verilenler listesinden kaldır", ++ "addToDenied": "Reddedilenler listesine ekle", ++ "removeFromDenied": "Reddedilenler listesinden kaldır", ++ "abortCommand": "Komut yürütmeyi iptal et", ++ "expandOutput": "Çıktıyı genişlet", ++ "collapseOutput": "Çıktıyı daralt", ++ "expandManagement": "Komut yönetimi bölümünü genişlet", ++ "collapseManagement": "Komut yönetimi bölümünü daralt" ++ }, + "response": "Yanıt", + "arguments": "Argümanlar", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json +index e56f63a91e2..3e08f0d6ffa 100644 +--- a/webview-ui/src/i18n/locales/vi/chat.json ++++ b/webview-ui/src/i18n/locales/vi/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo đã xem tên định nghĩa mã nguồn được sử dụng trong thư mục này (ngoài không gian làm việc):" + }, + "commandOutput": "Kết quả lệnh", ++ "commandExecution": { ++ "running": "Đang chạy", ++ "pid": "PID: {{pid}}", ++ "exited": "Đã thoát ({{exitCode}})", ++ "manageCommands": "Quản lý quyền lệnh", ++ "commandManagementDescription": "Quản lý quyền lệnh: Nhấp vào ✓ để cho phép tự động thực thi, ✗ để từ chối thực thi. Các mẫu có thể được bật/tắt hoặc xóa khỏi danh sách. Xem tất cả cài đặt", ++ "addToAllowed": "Thêm vào danh sách cho phép", ++ "removeFromAllowed": "Xóa khỏi danh sách cho phép", ++ "addToDenied": "Thêm vào danh sách từ chối", ++ "removeFromDenied": "Xóa khỏi danh sách từ chối", ++ "abortCommand": "Hủy bỏ thực thi lệnh", ++ "expandOutput": "Mở rộng kết quả", ++ "collapseOutput": "Thu gọn kết quả", ++ "expandManagement": "Mở rộng phần quản lý lệnh", ++ "collapseManagement": "Thu gọn phần quản lý lệnh" ++ }, + "response": "Phản hồi", + "arguments": "Tham số", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json +index d98dbe6f051..280a2844055 100644 +--- a/webview-ui/src/i18n/locales/zh-CN/chat.json ++++ b/webview-ui/src/i18n/locales/zh-CN/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo已查看此目录中使用的源代码定义名称(工作区外):" + }, + "commandOutput": "命令输出", ++ "commandExecution": { ++ "running": "正在运行", ++ "pid": "PID: {{pid}}", ++ "exited": "已退出 ({{exitCode}})", ++ "manageCommands": "管理命令权限", ++ "commandManagementDescription": "管理命令权限:点击 ✓ 允许自动执行,点击 ✗ 拒绝执行。可以打开/关闭模式或从列表中删除。查看所有设置", ++ "addToAllowed": "添加到允许列表", ++ "removeFromAllowed": "从允许列表中删除", ++ "addToDenied": "添加到拒绝列表", ++ "removeFromDenied": "从拒绝列表中删除", ++ "abortCommand": "中止命令执行", ++ "expandOutput": "展开输出", ++ "collapseOutput": "折叠输出", ++ "expandManagement": "展开命令管理部分", ++ "collapseManagement": "折叠命令管理部分" ++ }, + "response": "响应", + "arguments": "参数", + "mcp": { +diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json +index e5dcd13a428..658acc5cd0c 100644 +--- a/webview-ui/src/i18n/locales/zh-TW/chat.json ++++ b/webview-ui/src/i18n/locales/zh-TW/chat.json +@@ -192,6 +192,22 @@ + "didViewDefinitionsOutsideWorkspace": "Roo 已檢視此目錄(工作區外)中使用的原始碼定義名稱:" + }, + "commandOutput": "命令輸出", ++ "commandExecution": { ++ "running": "正在執行", ++ "pid": "PID: {{pid}}", ++ "exited": "已退出 ({{exitCode}})", ++ "manageCommands": "管理命令權限", ++ "commandManagementDescription": "管理命令權限:點擊 ✓ 允許自動執行,點擊 ✗ 拒絕執行。可以開啟/關閉模式或從清單中刪除。檢視所有設定", ++ "addToAllowed": "新增至允許清單", ++ "removeFromAllowed": "從允許清單中移除", ++ "addToDenied": "新增至拒絕清單", ++ "removeFromDenied": "從拒絕清單中移除", ++ "abortCommand": "中止命令執行", ++ "expandOutput": "展開輸出", ++ "collapseOutput": "折疊輸出", ++ "expandManagement": "展開命令管理部分", ++ "collapseManagement": "折疊命令管理部分" ++ }, + "response": "回應", + "arguments": "參數", + "mcp": { +diff --git a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts +new file mode 100644 +index 00000000000..92feda0412f +--- /dev/null ++++ b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts +@@ -0,0 +1,501 @@ ++import { describe, it, expect } from "vitest" ++import { ++ extractCommandPatterns, ++ getPatternDescription, ++ parseCommandAndOutput, ++ detectSecurityIssues, ++} from "../commandPatterns" ++ ++describe("extractCommandPatterns", () => { ++ it("should extract simple command", () => { ++ const patterns = extractCommandPatterns("ls") ++ expect(patterns).toEqual(["ls"]) ++ }) ++ ++ it("should extract command with arguments", () => { ++ const patterns = extractCommandPatterns("npm install express") ++ expect(patterns).toEqual(["npm", "npm install", "npm install express"]) ++ }) ++ ++ it("should handle piped commands", () => { ++ const patterns = extractCommandPatterns("ls -la | grep test") ++ expect(patterns).toContain("ls") ++ expect(patterns).toContain("grep") ++ expect(patterns).toContain("grep test") ++ }) ++ ++ it("should handle chained commands with &&", () => { ++ const patterns = extractCommandPatterns("npm install && npm run build") ++ expect(patterns).toContain("npm") ++ expect(patterns).toContain("npm install") ++ expect(patterns).toContain("npm run") ++ expect(patterns).toContain("npm run build") ++ }) ++ ++ it("should handle chained commands with ||", () => { ++ const patterns = extractCommandPatterns("npm test || npm run test:ci") ++ expect(patterns).toContain("npm") ++ expect(patterns).toContain("npm test") ++ expect(patterns).toContain("npm run") ++ expect(patterns).toContain("npm run test:ci") ++ }) ++ ++ it("should handle semicolon separated commands", () => { ++ const patterns = extractCommandPatterns("cd src; npm install") ++ expect(patterns).toContain("cd") ++ expect(patterns).toContain("cd src") ++ expect(patterns).toContain("npm") ++ expect(patterns).toContain("npm install") ++ }) ++ ++ it("should stop at flags", () => { ++ const patterns = extractCommandPatterns('git commit -m "test message"') ++ expect(patterns).toContain("git") ++ expect(patterns).toContain("git commit") ++ expect(patterns).not.toContain("git commit -m") ++ }) ++ ++ it("should stop at paths with slashes", () => { ++ const patterns = extractCommandPatterns("cd /usr/local/bin") ++ expect(patterns).toContain("cd") ++ expect(patterns).not.toContain("cd /usr/local/bin") ++ }) ++ ++ it("should handle empty or null input", () => { ++ expect(extractCommandPatterns("")).toEqual([]) ++ expect(extractCommandPatterns(" ")).toEqual([]) ++ expect(extractCommandPatterns(null as any)).toEqual([]) ++ expect(extractCommandPatterns(undefined as any)).toEqual([]) ++ }) ++ ++ it("should handle complex command with multiple operators", () => { ++ const patterns = extractCommandPatterns('npm install && npm test | grep success || echo "failed"') ++ expect(patterns).toContain("npm") ++ expect(patterns).toContain("npm install") ++ expect(patterns).toContain("npm test") ++ expect(patterns).toContain("grep") ++ expect(patterns).toContain("grep success") ++ expect(patterns).toContain("echo") ++ }) ++ ++ it("should handle malformed commands gracefully", () => { ++ const patterns = extractCommandPatterns("npm install && ") ++ expect(patterns).toContain("npm") ++ expect(patterns).toContain("npm install") ++ }) ++ ++ it("should extract main command even if parsing fails", () => { ++ // Create a command that might cause parsing issues ++ const patterns = extractCommandPatterns('echo "unclosed quote') ++ expect(patterns).toContain("echo") ++ }) ++ ++ it("should handle commands with special characters in arguments", () => { ++ const patterns = extractCommandPatterns("git add .") ++ expect(patterns).toContain("git") ++ expect(patterns).toContain("git add") ++ expect(patterns).not.toContain("git add .") ++ }) ++ ++ it("should return sorted patterns", () => { ++ const patterns = extractCommandPatterns("npm run build && git push") ++ expect(patterns).toEqual([...patterns].sort()) ++ }) ++ ++ it("should handle numeric input like '0 total'", () => { ++ const patterns = extractCommandPatterns("0 total") ++ // Should return empty array since "0" is not a valid command ++ expect(patterns).toEqual([]) ++ }) ++ ++ it("should handle pure numeric commands", () => { ++ const patterns = extractCommandPatterns("0") ++ // Should return empty array since pure numbers are not valid commands ++ expect(patterns).toEqual([]) ++ }) ++}) ++ ++describe("getPatternDescription", () => { ++ it("should return pattern followed by commands", () => { ++ expect(getPatternDescription("cd")).toBe("cd commands") ++ expect(getPatternDescription("npm")).toBe("npm commands") ++ expect(getPatternDescription("npm install")).toBe("npm install commands") ++ expect(getPatternDescription("git")).toBe("git commands") ++ expect(getPatternDescription("git push")).toBe("git push commands") ++ expect(getPatternDescription("python")).toBe("python commands") ++ }) ++ ++ it("should handle any command pattern", () => { ++ expect(getPatternDescription("unknowncommand")).toBe("unknowncommand commands") ++ expect(getPatternDescription("custom-tool")).toBe("custom-tool commands") ++ }) ++ ++ it("should handle package managers", () => { ++ expect(getPatternDescription("yarn")).toBe("yarn commands") ++ expect(getPatternDescription("pnpm")).toBe("pnpm commands") ++ expect(getPatternDescription("bun")).toBe("bun commands") ++ }) ++ ++ it("should handle build tools", () => { ++ expect(getPatternDescription("make")).toBe("make commands") ++ expect(getPatternDescription("cmake")).toBe("cmake commands") ++ expect(getPatternDescription("cargo")).toBe("cargo commands") ++ expect(getPatternDescription("go build")).toBe("go build commands") ++ }) ++}) ++ ++describe("parseCommandAndOutput", () => { ++ it("should handle command with $ prefix without Output: separator", () => { ++ const text = "$ npm install\nInstalling packages..." ++ const result = parseCommandAndOutput(text) ++ // Without Output: separator, the entire text is treated as command ++ expect(result.command).toBe("$ npm install\nInstalling packages...") ++ expect(result.output).toBe("") ++ }) ++ ++ it("should handle command with ❯ prefix without Output: separator", () => { ++ const text = "❯ git status\nOn branch main" ++ const result = parseCommandAndOutput(text) ++ // Without Output: separator, the entire text is treated as command ++ expect(result.command).toBe("❯ git status\nOn branch main") ++ expect(result.output).toBe("") ++ }) ++ ++ it("should handle command with > prefix without Output: separator", () => { ++ const text = "> echo hello\nhello" ++ const result = parseCommandAndOutput(text) ++ // Without Output: separator, the entire text is treated as command ++ expect(result.command).toBe("> echo hello\nhello") ++ expect(result.output).toBe("") ++ }) ++ ++ it("should return original text if no command prefix found", () => { ++ const text = "npm install" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("npm install") ++ expect(result.output).toBe("") ++ }) ++ ++ it("should extract AI suggestions from output with Output: separator", () => { ++ const text = "npm install\nOutput:\nSuggested patterns: npm, npm install, npm run" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("npm install") ++ expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) ++ }) ++ ++ it("should extract suggestions with different formats", () => { ++ const text = "git push\nOutput:\nCommand patterns: git, git push" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("git push") ++ expect(result.suggestions).toEqual(["git", "git push"]) ++ }) ++ ++ it('should extract suggestions from "you can allow" format', () => { ++ const text = "docker run\nOutput:\nYou can allow: docker, docker run" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("docker run") ++ expect(result.suggestions).toEqual(["docker", "docker run"]) ++ }) ++ ++ it("should extract suggestions from bullet points", () => { ++ const text = `npm test ++Output: ++Output here... ++- npm ++- npm test ++- npm run` ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("npm test") ++ expect(result.suggestions).toContain("npm") ++ expect(result.suggestions).toContain("npm test") ++ expect(result.suggestions).toContain("npm run") ++ }) ++ ++ it("should extract suggestions from various bullet formats", () => { ++ const text = `command ++Output: ++• npm ++* git ++- docker ++▪ python` ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("command") ++ expect(result.suggestions).toContain("npm") ++ expect(result.suggestions).toContain("git") ++ expect(result.suggestions).toContain("docker") ++ expect(result.suggestions).toContain("python") ++ }) ++ ++ it("should extract suggestions with backticks", () => { ++ const text = "npm install\nOutput:\n- `npm`\n- `npm install`" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("npm install") ++ expect(result.suggestions).toContain("npm") ++ expect(result.suggestions).toContain("npm install") ++ }) ++ ++ it("should handle empty text", () => { ++ const result = parseCommandAndOutput("") ++ expect(result.command).toBe("") ++ expect(result.output).toBe("") ++ expect(result.suggestions).toEqual([]) ++ }) ++ ++ it("should handle multiline commands without Output: separator", () => { ++ const text = `$ npm install \\ ++ express \\ ++ mongoose ++Installing...` ++ const result = parseCommandAndOutput(text) ++ // Without Output: separator, entire text is treated as command ++ expect(result.command).toBe(text) ++ expect(result.output).toBe("") ++ }) ++ ++ it("should include all suggestions from comma-separated list with Output: separator", () => { ++ const text = "test\nOutput:\nSuggested patterns: npm, npm install, npm run" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("test") ++ expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) ++ }) ++ ++ it("should handle case variations in suggestion patterns", () => { ++ const text = "test\nOutput:\nSuggested Patterns: npm, git\nCommand Patterns: docker" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("test") ++ // Now it should accumulate all suggestions ++ expect(result.suggestions).toContain("npm") ++ expect(result.suggestions).toContain("git") ++ expect(result.suggestions).toContain("docker") ++ }) ++ ++ it("should handle text already split by Output:", () => { ++ const text = "npm install && cd backend\nOutput:\ngithub-pr-contributors-tracker@1.0.0 prepare" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("npm install && cd backend") ++ expect(result.output).toBe("github-pr-contributors-tracker@1.0.0 prepare") ++ }) ++ ++ it("should preserve original command when Output: separator is present", () => { ++ const text = "npm install\nOutput:\n$ npm install\nInstalling packages..." ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("npm install") ++ expect(result.output).toBe("$ npm install\nInstalling packages...") ++ }) ++ ++ it("should handle Output: separator with no output", () => { ++ const text = "ls -la\nOutput:" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("ls -la") ++ expect(result.output).toBe("") ++ }) ++ ++ it("should handle Output: separator with whitespace", () => { ++ const text = "git status\nOutput: \n On branch main " ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("git status") ++ expect(result.output).toBe("On branch main") ++ }) ++ ++ it("should only use first Output: occurrence as separator", () => { ++ const text = 'echo "test"\nOutput:\nFirst output\nOutput: Second output' ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe('echo "test"') ++ expect(result.output).toBe("First output\nOutput: Second output") ++ }) ++ ++ it("should handle output with numbers at the start of lines", () => { ++ const text = `wc -l *.go *.java ++Output: ++25 hello_world.go ++316 HelloWorld.java ++341 total` ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("wc -l *.go *.java") ++ expect(result.output).toBe("25 hello_world.go\n316 HelloWorld.java\n341 total") ++ expect(result.suggestions).toEqual([]) ++ }) ++ ++ it("should handle edge case where text starts with Output:", () => { ++ const text = "Output:\nSome output without a command" ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("") ++ expect(result.output).toBe("Some output without a command") ++ }) ++ ++ it("should not be confused by Output: appearing in the middle of output", () => { ++ const text = `echo "Output: test" ++Output: ++Output: test` ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe('echo "Output: test"') ++ expect(result.output).toBe("Output: test") ++ }) ++ ++ it("should handle commands without shell prompt when Output: separator is present", () => { ++ const text = `npm install ++Output: ++Installing packages...` ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("npm install") ++ expect(result.output).toBe("Installing packages...") ++ }) ++ ++ it("should not parse shell prompts from output when Output: separator exists", () => { ++ const text = `ls -la ++Output: ++$ total 341 ++drwxr-xr-x 10 user staff 320 Jan 22 12:00 . ++drwxr-xr-x 20 user staff 640 Jan 22 11:00 ..` ++ const result = parseCommandAndOutput(text) ++ expect(result.command).toBe("ls -la") ++ expect(result.output).toContain("$ total 341") ++ expect(result.output).toContain("drwxr-xr-x") ++ }) ++}) ++ ++describe("detectSecurityIssues", () => { ++ it("should detect subshell execution with $()", () => { ++ const warnings = detectSecurityIssues("echo $(malicious)") ++ expect(warnings).toHaveLength(1) ++ expect(warnings[0].type).toBe("subshell") ++ expect(warnings[0].message).toContain("subshell execution") ++ }) ++ ++ it("should detect subshell execution with backticks", () => { ++ const warnings = detectSecurityIssues("echo `malicious`") ++ expect(warnings).toHaveLength(1) ++ expect(warnings[0].type).toBe("subshell") ++ expect(warnings[0].message).toContain("subshell execution") ++ }) ++ ++ it("should detect nested subshells", () => { ++ const warnings = detectSecurityIssues("echo $(echo $(date))") ++ expect(warnings).toHaveLength(1) ++ expect(warnings[0].type).toBe("subshell") ++ }) ++ ++ it("should detect subshells in complex commands", () => { ++ const warnings = detectSecurityIssues("npm install && echo $(whoami) || git push") ++ expect(warnings).toHaveLength(1) ++ expect(warnings[0].type).toBe("subshell") ++ }) ++ ++ it("should not detect issues in safe commands", () => { ++ const warnings = detectSecurityIssues("npm install express") ++ expect(warnings).toHaveLength(0) ++ }) ++ ++ it("should handle empty commands", () => { ++ const warnings = detectSecurityIssues("") ++ expect(warnings).toHaveLength(0) ++ }) ++ ++ it("should detect multiple subshell patterns", () => { ++ const warnings = detectSecurityIssues("echo $(date) && echo `whoami`") ++ expect(warnings).toHaveLength(1) // Should still be 1 warning for subshell presence ++ expect(warnings[0].type).toBe("subshell") ++ }) ++ ++ it("should detect subshells in quoted strings", () => { ++ const warnings = detectSecurityIssues('echo "Current user: $(whoami)"') ++ expect(warnings).toHaveLength(1) ++ expect(warnings[0].type).toBe("subshell") ++ }) ++}) ++ ++describe("security integration with extractCommandPatterns", () => { ++ it("should not include subshell content in patterns", () => { ++ const patterns = extractCommandPatterns("echo $(malicious)") ++ expect(patterns).toContain("echo") ++ expect(patterns).not.toContain("$(malicious)") ++ expect(patterns).not.toContain("malicious") ++ }) ++ ++ it("should handle commands with subshells properly", () => { ++ const patterns = extractCommandPatterns("npm install && echo $(whoami)") ++ expect(patterns).toContain("npm") ++ expect(patterns).toContain("npm install") ++ expect(patterns).toContain("echo") ++ expect(patterns).not.toContain("whoami") ++ }) ++ ++ it("should extract patterns from commands with backtick subshells", () => { ++ const patterns = extractCommandPatterns("git commit -m `date`") ++ expect(patterns).toContain("git") ++ expect(patterns).toContain("git commit") ++ expect(patterns).not.toContain("date") ++ }) ++}) ++ ++describe("integration: parseCommandAndOutput with extractCommandPatterns", () => { ++ it("should not extract patterns from output text", () => { ++ const text = `wc -l *.go *.java ++Output: ++wc: *.go: open: No such file or directory ++wc: *.java: open: No such file or directory ++0 total` ++ const { command } = parseCommandAndOutput(text) ++ const patterns = extractCommandPatterns(command) ++ ++ // Should only extract patterns from the command, not the output ++ expect(patterns).toContain("wc") ++ expect(patterns).not.toContain("0") ++ expect(patterns).not.toContain("total") ++ expect(patterns).not.toContain("0 total") ++ }) ++ ++ it("should handle the specific wc command case", () => { ++ const text = `wc -l *.go *.java ++Output: ++25 hello_world.go ++316 HelloWorld.java ++341 total` ++ const { command } = parseCommandAndOutput(text) ++ const patterns = extractCommandPatterns(command) ++ ++ // Should only extract "wc" from the command ++ expect(patterns).toEqual(["wc"]) ++ expect(patterns).not.toContain("341") ++ expect(patterns).not.toContain("total") ++ expect(patterns).not.toContain("341 total") ++ }) ++ ++ it("should handle wc command with error output", () => { ++ const text = `wc -l *.go *.java ++Output: ++wc: *.go: open: No such file or directory ++wc: *.java: open: No such file or directory ++0 total` ++ const { command, output } = parseCommandAndOutput(text) ++ const patterns = extractCommandPatterns(command) ++ ++ // Should only extract "wc" from the command ++ expect(command).toBe("wc -l *.go *.java") ++ expect(output).toContain("0 total") ++ expect(patterns).toEqual(["wc"]) ++ expect(patterns).not.toContain("0") ++ expect(patterns).not.toContain("total") ++ expect(patterns).not.toContain("0 total") ++ }) ++ ++ it("should handle case where only output line is provided", () => { ++ // This simulates if somehow only "0 total" is passed as the text ++ const text = "0 total" ++ const { command } = parseCommandAndOutput(text) ++ const patterns = extractCommandPatterns(command) ++ ++ // In this case, the entire text is treated as command ++ expect(command).toBe("0 total") ++ // But "0 total" is not a valid command pattern (starts with number) ++ expect(patterns).toEqual([]) ++ }) ++ ++ it("should handle commands without output separator", () => { ++ const text = "npm install" ++ const { command } = parseCommandAndOutput(text) ++ const patterns = extractCommandPatterns(command) ++ ++ expect(patterns).toEqual(["npm", "npm install"]) ++ }) ++}) +diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts +new file mode 100644 +index 00000000000..0265ad61bb5 +--- /dev/null ++++ b/webview-ui/src/utils/commandPatterns.ts +@@ -0,0 +1,177 @@ ++import { parse } from "shell-quote" ++ ++export interface CommandPattern { ++ pattern: string ++ description?: string ++} ++ ++export interface SecurityWarning { ++ type: "subshell" | "injection" ++ message: string ++} ++ ++function processCommand(cmd: string[], patterns: Set): void { ++ if (!cmd.length || typeof cmd[0] !== "string") return ++ ++ const mainCmd = cmd[0] ++ ++ // Skip if it's just a number (like "0" from "0 total") ++ if (/^\d+$/.test(mainCmd)) return ++ ++ // Skip common output patterns that aren't commands ++ const skipWords = ["total", "error", "warning", "failed", "success", "done"] ++ if (skipWords.includes(mainCmd.toLowerCase())) return ++ ++ patterns.add(mainCmd) ++ ++ const breakingExps = [/^-/, /[\\/.~]/] ++ ++ for (let i = 1; i < cmd.length; i++) { ++ const arg = cmd[i] ++ ++ if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break ++ ++ const pattern = cmd.slice(0, i + 1).join(" ") ++ patterns.add(pattern) ++ } ++} ++ ++function extractPatterns(cmdStr: string): Set { ++ const patterns = new Set() ++ ++ const parsed = parse(cmdStr) ++ ++ const commandSeparators = new Set(["|", "&&", "||", ";"]) ++ let current: string[] = [] ++ for (const token of parsed) { ++ if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { ++ if (current.length) processCommand(current, patterns) ++ current = [] ++ } else { ++ current.push(String(token)) ++ } ++ } ++ ++ if (current.length) processCommand(current, patterns) ++ ++ return patterns ++} ++ ++export function extractCommandPatterns(command: string): string[] { ++ if (!command?.trim()) return [] ++ ++ // First, check if the command contains subshells and remove them ++ // This is important for security - we don't want to extract patterns from subshell contents ++ const cleanedCommand = command ++ .replace(/\$\([^)]*\)/g, "") // Remove $() subshells ++ .replace(/`[^`]*`/g, "") // Remove backtick subshells ++ ++ const patterns = extractPatterns(cleanedCommand) ++ ++ return Array.from(patterns).sort() ++} ++ ++export function detectSecurityIssues(command: string): SecurityWarning[] { ++ const warnings: SecurityWarning[] = [] ++ ++ // Check for subshell execution attempts ++ if (command.includes("$(") || command.includes("`")) { ++ warnings.push({ ++ type: "subshell", ++ message: "Command contains subshell execution which could bypass restrictions", ++ }) ++ } ++ ++ return warnings ++} ++ ++/** ++ * Get a human-readable description for a command pattern. ++ * Simply returns the pattern followed by "commands". ++ */ ++export function getPatternDescription(pattern: string): string { ++ return `${pattern} commands` ++} ++ ++export function parseCommandAndOutput(text: string): { ++ command: string ++ output: string ++ suggestions: string[] ++} { ++ // Default result ++ const result = { ++ command: text, ++ output: "", ++ suggestions: [] as string[], ++ } ++ ++ // First check if the text already has been split by COMMAND_OUTPUT_STRING ++ // This happens when the command has already been executed and we have the output ++ const outputSeparator = "Output:" ++ const outputIndex = text.indexOf(`\n${outputSeparator}`) ++ ++ if (outputIndex !== -1) { ++ // Text is already split into command and output ++ // The command is everything before the output separator ++ result.command = text.slice(0, outputIndex).trim() ++ // The output is everything after the output separator ++ // We need to skip the newline and "Output:" text ++ const afterNewline = outputIndex + 1 // Skip the newline ++ const afterSeparator = afterNewline + outputSeparator.length // Skip "Output:" ++ // Check if there's a colon and potential space after it ++ let startOfOutput = afterSeparator ++ if (text[afterSeparator] === "\n") { ++ startOfOutput = afterSeparator + 1 // Skip additional newline after "Output:" ++ } ++ result.output = text.slice(startOfOutput).trim() ++ } else if (text.indexOf(outputSeparator) === 0) { ++ // Edge case: text starts with "Output:" (no command) ++ result.command = "" ++ result.output = text.slice(outputSeparator.length).trim() ++ } else { ++ // No output separator found, the entire text is the command ++ result.command = text.trim() ++ result.output = "" ++ } ++ ++ // Look for AI suggestions in the output ++ // These might be in a format like: ++ // "Suggested patterns: npm, npm install, npm run" ++ // or as a list ++ const suggestionPatterns = [ ++ /Suggested patterns?:\s*(.+?)(?:\n|$)/i, ++ /Command patterns?:\s*(.+?)(?:\n|$)/i, ++ /You (?:can|may|might) (?:want to )?(?:allow|add):\s*(.+?)(?:\n|$)/i, ++ ] ++ ++ for (const pattern of suggestionPatterns) { ++ const match = result.output.match(pattern) ++ if (match) { ++ // Split by common delimiters and clean up ++ const suggestions = match[1] ++ .split(/[,;]/) ++ .map((s) => s.trim()) ++ .filter((s) => s) // Allow multi-word patterns like "npm install" ++ ++ if (suggestions.length > 0) { ++ // Add to existing suggestions instead of replacing ++ result.suggestions.push(...suggestions) ++ } ++ } ++ } ++ ++ // Remove duplicates ++ result.suggestions = Array.from(new Set(result.suggestions)) ++ ++ // Also look for bullet points or numbered lists ++ // const listPattern = /^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/gm ++ const lines = result.output.split("\n") ++ for (const line of lines) { ++ const match = line.match(/^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/) ++ if (match && match[1] && !result.suggestions.includes(match[1])) { ++ result.suggestions.push(match[1]) ++ } ++ } ++ ++ return result ++} diff --git a/.roo/temp/pr-5798/review-context.json b/.roo/temp/pr-5798/review-context.json new file mode 100644 index 00000000000..e728ab7c1fc --- /dev/null +++ b/.roo/temp/pr-5798/review-context.json @@ -0,0 +1,56 @@ +{ + "prNumber": "5798", + "repository": "RooCodeInc/Roo-Code", + "reviewStartTime": "2025-01-23T17:13:13.085Z", + "calledByMode": null, + "prMetadata": { + "title": "feat: Add terminal command permissions UI to chat interface (#5480)", + "author": "hannesrudolph", + "state": "OPEN", + "baseRefName": "main", + "headRefName": "feat/issue-5480-command-permissions-ui", + "additions": 2015, + "deletions": 24, + "changedFiles": 24 + }, + "linkedIssue": { + "number": 5480 + }, + "existingComments": [], + "existingReviews": [], + "filesChanged": [ + "webview-ui/src/components/chat/CommandExecution.tsx", + "webview-ui/src/components/chat/CommandPatternSelector.tsx", + "webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx", + "webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx", + "webview-ui/src/i18n/locales/ca/chat.json", + "webview-ui/src/i18n/locales/de/chat.json", + "webview-ui/src/i18n/locales/en/chat.json", + "webview-ui/src/i18n/locales/es/chat.json", + "webview-ui/src/i18n/locales/fr/chat.json", + "webview-ui/src/i18n/locales/hi/chat.json", + "webview-ui/src/i18n/locales/id/chat.json", + "webview-ui/src/i18n/locales/it/chat.json", + "webview-ui/src/i18n/locales/ja/chat.json", + "webview-ui/src/i18n/locales/ko/chat.json", + "webview-ui/src/i18n/locales/nl/chat.json", + "webview-ui/src/i18n/locales/pl/chat.json", + "webview-ui/src/i18n/locales/pt-BR/chat.json", + "webview-ui/src/i18n/locales/ru/chat.json", + "webview-ui/src/i18n/locales/tr/chat.json", + "webview-ui/src/i18n/locales/vi/chat.json", + "webview-ui/src/i18n/locales/zh-CN/chat.json", + "webview-ui/src/i18n/locales/zh-TW/chat.json", + "webview-ui/src/utils/__tests__/commandPatterns.spec.ts", + "webview-ui/src/utils/commandPatterns.ts" + ], + "delegatedTasks": [], + "findings": { + "critical": [], + "patterns": [], + "redundancy": [], + "architecture": [], + "tests": [] + }, + "reviewStatus": "analyzing" +} diff --git a/.roo/temp/pr-5798/reviews.json b/.roo/temp/pr-5798/reviews.json new file mode 100644 index 00000000000..d2ea6b787dd --- /dev/null +++ b/.roo/temp/pr-5798/reviews.json @@ -0,0 +1,79 @@ +[ + { + "author": { "login": "copilot-pull-request-reviewer" }, + "authorAssociation": "NONE", + "body": "## Pull Request Overview\n\nThis PR adds an interactive terminal command permissions UI to the chat interface, allowing users to view, allow, or deny specific command patterns directly from the chat.\n\n- Introduces `commandPatterns.ts` for extracting command patterns, generating descriptions, and parsing command/output text.\n- Adds a `CommandPatternSelector` component and integrates it into `CommandExecution` to toggle allowed/denied patterns with state synchronization.\n- Updates translation JSON files across all locales to include new `commandExecution` keys.\n\n### Reviewed Changes\n\nCopilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.\n\n\u003cdetails\u003e\n\u003csummary\u003eShow a summary per file\u003c/summary\u003e\n\n| File | Description |\r\n| ------------------------------------------------- | -------------------------------------------------------------------------------------------- |\r\n| webview-ui/src/utils/commandPatterns.ts | Adds utilities for command pattern extraction, description lookup, and parsing command/output |\r\n| webview-ui/src/utils/__tests__/commandPatterns.spec.ts | Adds unit tests covering pattern extraction, descriptions, and parsing logic |\r\n| webview-ui/src/components/chat/CommandPatternSelector.tsx | Implements the UI component for toggling command permission patterns |\r\n| webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx | Adds interaction and accessibility tests for `CommandPatternSelector` |\r\n| webview-ui/src/components/chat/CommandExecution.tsx | Integrates the selector into command blocks and syncs state with the extension |\r\n| webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx | Adds tests for command execution rendering and permission changes |\r\n| webview-ui/src/i18n/locales/*/chat.json | Updates all locale files with new translation keys for `commandExecution` UI |\n\u003c/details\u003e\n\n\n\n\u003cdetails\u003e\n\u003csummary\u003eComments suppressed due to low confidence (3)\u003c/summary\u003e\n\n**webview-ui/src/components/chat/CommandExecution.tsx:61**\n* [nitpick] The state variable `showSuggestions` never changes. Consider renaming it to reflect that it’s a constant flag or removing the useState hook entirely if it isn’t meant to update.\n```\n\tconst [showSuggestions] = useState(true)\n```\n**webview-ui/src/components/chat/CommandExecution.tsx:48**\n* The fallback path where `enhanced.command === text` isn’t covered by any tests. Add a unit test to verify the fallback parser branch behaves as expected.\n```\n\t\tif (enhanced.command \u0026\u0026 enhanced.command !== text) {\n```\n**webview-ui/src/components/chat/CommandExecution.tsx:52**\n* parseCommandAndOutput is not imported in this file, so the fallback call will be undefined. Either import it properly or replace this call with parseCommandAndOutputUtil.\n```\n\t\tconst original = parseCommandAndOutput(text)\n```\n\u003c/details\u003e\n\n", + "commit": { "oid": "c4a9670e9733ab32fb5d17c1036a9cd649770233" }, + "id": "PRR_kwDONIq5lM60cWiN", + "includesCreatedEdit": false, + "reactionGroups": [], + "state": "COMMENTED", + "submittedAt": "2025-07-17T00:30:45Z" + }, + { + "author": { "login": "ellipsis-dev" }, + "authorAssociation": "NONE", + "body": "", + "commit": { "oid": "c4a9670e9733ab32fb5d17c1036a9cd649770233" }, + "id": "PRR_kwDONIq5lM60cWrH", + "includesCreatedEdit": false, + "reactionGroups": [], + "state": "COMMENTED", + "submittedAt": "2025-07-17T00:31:06Z" + }, + { + "author": { "login": "copilot-pull-request-reviewer" }, + "authorAssociation": "NONE", + "body": "## Pull Request Overview\n\nThis PR adds a command permissions UI to the chat interface that allows users to manage terminal command permissions directly when viewing command execution results. The implementation includes pattern extraction capabilities for complex shell commands and integrates seamlessly with the existing VSCode extension state.\n\n- Enhanced command execution interface with collapsible permission management section\n- Pattern extraction utility that handles complex shell syntax including pipes, chains, and subshells\n- Comprehensive translation support for 17 languages\n\n### Reviewed Changes\n\nCopilot reviewed 24 out of 24 changed files in this pull request and generated 4 comments.\n\n\u003cdetails\u003e\n\u003csummary\u003eShow a summary per file\u003c/summary\u003e\n\n| File | Description |\r\n| ---- | ----------- |\r\n| `webview-ui/src/utils/commandPatterns.ts` | Core pattern extraction logic with shell parsing and security validation |\r\n| `webview-ui/src/components/chat/CommandPatternSelector.tsx` | UI component for managing command permissions with toggle buttons |\r\n| `webview-ui/src/components/chat/CommandExecution.tsx` | Integration of pattern selector into existing command execution component |\r\n| Translation files | Added command execution UI strings across 17 supported languages |\r\n| Test files | Comprehensive test coverage for pattern extraction and UI components |\n\u003c/details\u003e\n\n\n\n\u003cdetails\u003e\n\u003csummary\u003eComments suppressed due to low confidence (1)\u003c/summary\u003e\n\n**webview-ui/src/utils/commandPatterns.ts:54**\n* [nitpick] The variable name 'breakingExps' is ambiguous. Consider renaming to 'stopPatterns' or 'terminatingPatterns' to better describe its purpose.\n```\n\t// Patterns that indicate we should stop looking for subcommands\n```\n\u003c/details\u003e\n\n", + "commit": { "oid": "6463e8f7c70df56cff3f25e836b084c895b6312f" }, + "id": "PRR_kwDONIq5lM60o8Sj", + "includesCreatedEdit": false, + "reactionGroups": [], + "state": "COMMENTED", + "submittedAt": "2025-07-17T18:47:03Z" + }, + { + "author": { "login": "daniel-lxs" }, + "authorAssociation": "COLLABORATOR", + "body": "@roomote-agent Use the switch_mode tool to switch to pr-fixer and address the reviews on this PR, commit the changes to this PR, do not create a new one", + "commit": { "oid": "94efb1a123b6f89e1ff9c6be25a3dc547b6c4ce9" }, + "id": "PRR_kwDONIq5lM61IWGu", + "includesCreatedEdit": false, + "reactionGroups": [], + "state": "COMMENTED", + "submittedAt": "2025-07-21T16:37:50Z" + }, + { + "author": { "login": "ellipsis-dev" }, + "authorAssociation": "NONE", + "body": "", + "commit": { "oid": "586cc157daa49059e6880384343cd01167f8855d" }, + "id": "PRR_kwDONIq5lM61ge8E", + "includesCreatedEdit": false, + "reactionGroups": [], + "state": "COMMENTED", + "submittedAt": "2025-07-23T00:07:42Z" + }, + { + "author": { "login": "daniel-lxs" }, + "authorAssociation": "COLLABORATOR", + "body": "I am seeing this on certain commands \n\n\u003cimg width=\"425\" height=\"408\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/47c39e19-f410-46e7-b889-e7dcafdac3ce\" /\u003e\n\nNot sure if this is intended but I imagine for longer commands this list might become too large", + "commit": { "oid": "586cc157daa49059e6880384343cd01167f8855d" }, + "id": "PRR_kwDONIq5lM61p9QB", + "includesCreatedEdit": false, + "reactionGroups": [], + "state": "COMMENTED", + "submittedAt": "2025-07-23T14:28:42Z" + }, + { + "author": { "login": "ellipsis-dev" }, + "authorAssociation": "NONE", + "body": "", + "commit": { "oid": "b358c958bff8d817e4f848bc1683a2adff45c283" }, + "id": "PRR_kwDONIq5lM61qdnL", + "includesCreatedEdit": false, + "reactionGroups": [], + "state": "COMMENTED", + "submittedAt": "2025-07-23T14:49:38Z" + } +] diff --git a/.roo/temp/pr-5798/test-analysis.md b/.roo/temp/pr-5798/test-analysis.md new file mode 100644 index 00000000000..83899593d58 --- /dev/null +++ b/.roo/temp/pr-5798/test-analysis.md @@ -0,0 +1,166 @@ +## Test Analysis for PR #5798 + +### Test Organization + +#### File Location and Structure + +The test files are properly organized following the project's conventions: + +- **Component tests**: Located in `webview-ui/src/components/chat/__tests__/` alongside the components they test +- **Utility tests**: Located in `webview-ui/src/utils/__tests__/` alongside the utility modules +- **Naming convention**: All test files use the `.spec.ts` or `.spec.tsx` extension, consistent with the project standard + +#### Test File Sizes + +- `CommandExecution.spec.tsx`: 591 lines - This is quite large and could benefit from splitting into smaller, more focused test files +- `CommandPatternSelector.spec.tsx`: 252 lines - Reasonable size for a component test +- `commandPatterns.spec.ts`: 501 lines - Large but acceptable given the complexity of the utility being tested + +### Coverage Assessment + +#### CommandExecution.spec.tsx + +**Strengths:** + +- Comprehensive coverage of command parsing scenarios +- Tests for edge cases like empty commands, malformed input, and special characters +- Good coverage of pattern extraction and security features +- Tests integration with CommandPatternSelector component +- Covers state management and event handling + +**Areas for Improvement:** + +- Missing tests for error boundaries and error states +- Could add more tests for accessibility features +- No performance-related tests (e.g., handling very long commands) + +#### CommandPatternSelector.spec.tsx + +**Strengths:** + +- Tests all major UI interactions (expand/collapse, button clicks) +- Covers tooltip and internationalization features +- Tests state management for allowed/denied commands +- Good coverage of edge cases (empty patterns, duplicate prevention) + +**Areas for Improvement:** + +- Missing tests for keyboard navigation +- No tests for focus management +- Could add tests for screen reader announcements + +#### commandPatterns.spec.ts + +**Strengths:** + +- Excellent coverage of command parsing logic +- Comprehensive tests for pattern extraction +- Good coverage of security features (subshell detection) +- Tests for various command formats and edge cases +- Integration tests between different utility functions + +**Gaps:** + +- No tests for performance with extremely long or complex commands +- Missing tests for Unicode and special character handling in commands + +### Pattern Consistency + +#### Testing Framework Usage + +All test files consistently use: + +- Vitest as the testing framework (`describe`, `it`, `expect`, `vi`) +- React Testing Library for component tests (`render`, `screen`, `fireEvent`) +- Proper setup and teardown with `beforeEach` and `vi.clearAllMocks()` + +#### Mock Patterns + +The tests follow consistent mocking patterns: + +```typescript +// Component mocks +vi.mock("../../../utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Module mocks with actual implementation +vi.mock("../../../utils/commandPatterns", async () => { + const actual = await vi.importActual( + "../../../utils/commandPatterns", + ) + return { + ...actual, + // specific overrides + } +}) +``` + +#### Test Structure + +Tests follow a consistent structure: + +1. Arrange - Set up test data and mocks +2. Act - Perform the action being tested +3. Assert - Verify the expected outcome + +### Comparison with Existing Tests + +#### Alignment with Project Standards + +Comparing with existing tests like `HistoryView.spec.tsx` and `SettingsView.spec.tsx`: + +**Consistent Patterns:** + +- Use of `data-testid` for element selection +- Mock setup at the top of test files +- Context provider wrappers for components that need them +- Clear test descriptions using BDD-style language + +**Deviations:** + +- The new tests use more inline mock components, while existing tests tend to use more complete mock implementations +- Some existing tests use `@/utils/test-utils` for rendering, while the new tests import directly from `@testing-library/react` + +### Recommendations + +#### 1. Test File Organization + +- Consider splitting `CommandExecution.spec.tsx` into smaller files: + - `CommandExecution.rendering.spec.tsx` - UI rendering tests + - `CommandExecution.patterns.spec.tsx` - Pattern extraction tests + - `CommandExecution.integration.spec.tsx` - Integration with other components + +#### 2. Test Naming Conventions + +- Standardize test descriptions to follow the pattern: "should [expected behavior] when [condition]" +- Group related tests using nested `describe` blocks more consistently + +#### 3. Mock Improvements + +- Create shared mock utilities for commonly mocked modules (vscode, i18n) +- Use mock factories to reduce duplication across test files + +#### 4. Coverage Enhancements + +- Add tests for error states and error boundaries +- Include accessibility tests using `@testing-library/jest-dom` matchers +- Add performance tests for handling large inputs +- Test keyboard navigation and focus management + +#### 5. Test Data Management + +- Extract test data into separate fixtures or factories +- Create builders for complex test objects to improve maintainability + +#### 6. Integration with CI/CD + +- Ensure these tests are included in the test coverage reports +- Add performance benchmarks for critical paths +- Consider adding visual regression tests for UI components + +### Conclusion + +The test files in PR #5798 demonstrate good testing practices with comprehensive coverage of the new command pattern functionality. While there are areas for improvement, particularly around test organization and accessibility testing, the tests provide solid coverage of the core functionality and edge cases. The patterns used are largely consistent with the existing codebase, making the tests maintainable and easy to understand. diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 4ffbd32d881..a0efabeb4d4 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -1,6 +1,6 @@ import { useCallback, useState, memo, useMemo } from "react" import { useEvent } from "react-use" -import { ChevronDown, Skull } from "lucide-react" +import { ChevronDown, Skull, AlertTriangle } from "lucide-react" import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types" @@ -18,6 +18,7 @@ import { getPatternDescription, parseCommandAndOutput, CommandPattern, + detectSecurityIssues, } from "../../utils/commandPatterns" interface CommandExecutionProps { @@ -46,8 +47,9 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) const [streamingOutput, setStreamingOutput] = useState("") const [status, setStatus] = useState(null) - // Show suggestions is always enabled for command pattern management - const SHOW_SUGGESTIONS = true + // Show suggestions when user has command restrictions enabled (has denied commands) + // This provides a better UX by only showing the pattern selector when it's relevant + const showCommandSuggestions = deniedCommands.length > 0 || allowedCommands.length > 0 // The command's output can either come from the text associated with the // task message (this is the case for completed commands) or from the @@ -72,6 +74,11 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec return patterns }, [command]) + // Detect security issues in the command + const securityWarnings = useMemo(() => { + return detectSecurityIssues(command) + }, [command]) + // Handle pattern changes const handleAllowPatternChange = (pattern: string) => { const isAllowed = allowedCommands.includes(pattern) @@ -182,9 +189,24 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
+ {securityWarnings.length > 0 && ( +
+
+ +
+
Security Warning
+ {securityWarnings.map((warning, index) => ( +
+ {warning.message} +
+ ))} +
+
+
+ )}
- {SHOW_SUGGESTIONS && commandPatterns.length > 0 && ( + {showCommandSuggestions && commandPatterns.length > 0 && ( { + const noRestrictionsState = { + ...mockExtensionState, + allowedCommands: [], + deniedCommands: [], + } + + render( + + + , + ) + + // Should not show pattern selector when no restrictions are configured + expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() + }) + + it("should show pattern selector when command restrictions are configured", () => { + // Default mockExtensionState has allowedCommands: ["npm"] and deniedCommands: ["rm"] + render( + + + , + ) + + // Should show pattern selector when restrictions are configured + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + }) + it("should expand output when terminal shell integration is disabled", () => { const disabledState = { ...mockExtensionState, @@ -288,7 +317,8 @@ Output here` , ) - expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + // When both are undefined (which defaults to empty arrays), pattern selector should not show + expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() }) it("should handle pattern change when moving from denied to allowed", () => { @@ -364,6 +394,18 @@ Other output here` expect(screen.queryByText("whoami")).not.toBeInTheDocument() }) + it("should display security warning for commands with subshells", () => { + render( + + + , + ) + + // Should show security warning + expect(screen.getByText("Security Warning")).toBeInTheDocument() + expect(screen.getByText(/subshell execution/)).toBeInTheDocument() + }) + it("should handle commands with backtick subshells", () => { render( diff --git a/webview-ui/src/utils/__tests__/command-parser.spec.ts b/webview-ui/src/utils/__tests__/command-parser.spec.ts new file mode 100644 index 00000000000..4063e7a1ac9 --- /dev/null +++ b/webview-ui/src/utils/__tests__/command-parser.spec.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest" +import { parseCommandString, extractPatternsFromCommand, detectCommandSecurityIssues } from "../command-parser" + +describe("parseCommandString", () => { + it("should parse simple command", () => { + const result = parseCommandString("ls -la") + expect(result.subCommands).toEqual(["ls -la"]) + expect(result.hasSubshells).toBe(false) + expect(result.subshellCommands).toEqual([]) + }) + + it("should parse command with && operator", () => { + const result = parseCommandString("npm install && npm test") + expect(result.subCommands).toEqual(["npm install", "npm test"]) + expect(result.hasSubshells).toBe(false) + }) + + it("should parse command with || operator", () => { + const result = parseCommandString("npm test || npm run test:ci") + expect(result.subCommands).toEqual(["npm test", "npm run test:ci"]) + expect(result.hasSubshells).toBe(false) + }) + + it("should parse command with pipe", () => { + const result = parseCommandString("ls -la | grep test") + expect(result.subCommands).toEqual(["ls -la", "grep test"]) + expect(result.hasSubshells).toBe(false) + }) + + it("should detect and extract subshells with $()", () => { + const result = parseCommandString("echo $(date)") + expect(result.subCommands).toEqual(["echo", "date"]) + expect(result.hasSubshells).toBe(true) + expect(result.subshellCommands).toEqual(["date"]) + }) + + it("should detect and extract subshells with backticks", () => { + const result = parseCommandString("echo `whoami`") + expect(result.subCommands).toEqual(["echo", "whoami"]) + expect(result.hasSubshells).toBe(true) + expect(result.subshellCommands).toEqual(["whoami"]) + }) + + it("should handle PowerShell redirections", () => { + const result = parseCommandString("command 2>&1") + expect(result.subCommands).toEqual(["command 2>&1"]) + expect(result.hasSubshells).toBe(false) + }) + + it("should handle quoted strings", () => { + const result = parseCommandString('echo "hello world"') + expect(result.subCommands).toEqual(['echo "hello world"']) + expect(result.hasSubshells).toBe(false) + }) + + it("should handle array indexing expressions", () => { + const result = parseCommandString("echo ${array[0]}") + expect(result.subCommands).toEqual(["echo ${array[0]}"]) + expect(result.hasSubshells).toBe(false) + }) + + it("should handle empty command", () => { + const result = parseCommandString("") + expect(result.subCommands).toEqual([]) + expect(result.hasSubshells).toBe(false) + expect(result.subshellCommands).toEqual([]) + }) + + it("should handle complex command with multiple operators", () => { + const result = parseCommandString("npm install && npm test | grep success || echo 'failed'") + expect(result.subCommands).toEqual(["npm install", "npm test", "grep success", "echo failed"]) + expect(result.hasSubshells).toBe(false) + }) +}) + +describe("extractPatternsFromCommand", () => { + it("should extract simple command pattern", () => { + const patterns = extractPatternsFromCommand("ls") + expect(patterns).toEqual(["ls"]) + }) + + it("should extract command with arguments", () => { + const patterns = extractPatternsFromCommand("npm install express") + expect(patterns).toEqual(["npm", "npm install", "npm install express"]) + }) + + it("should stop at flags", () => { + const patterns = extractPatternsFromCommand("git commit -m 'test'") + expect(patterns).toEqual(["git", "git commit"]) + }) + + it("should stop at paths", () => { + const patterns = extractPatternsFromCommand("cd /usr/local/bin") + expect(patterns).toEqual(["cd"]) + }) + + it("should handle piped commands", () => { + const patterns = extractPatternsFromCommand("ls -la | grep test") + expect(patterns).toContain("ls") + expect(patterns).toContain("grep") + expect(patterns).toContain("grep test") + }) + + it("should remove subshells before extracting patterns", () => { + const patterns = extractPatternsFromCommand("echo $(malicious)") + expect(patterns).toEqual(["echo"]) + expect(patterns).not.toContain("malicious") + }) + + it("should skip numeric commands", () => { + const patterns = extractPatternsFromCommand("0 total") + expect(patterns).toEqual([]) + }) + + it("should skip common output words", () => { + const patterns = extractPatternsFromCommand("error") + expect(patterns).toEqual([]) + }) + + it("should handle empty command", () => { + const patterns = extractPatternsFromCommand("") + expect(patterns).toEqual([]) + }) + + it("should return sorted patterns", () => { + const patterns = extractPatternsFromCommand("npm run build") + expect(patterns).toEqual(["npm", "npm run", "npm run build"]) + }) +}) + +describe("detectCommandSecurityIssues", () => { + it("should detect subshell with $()", () => { + const warnings = detectCommandSecurityIssues("echo $(malicious)") + expect(warnings).toHaveLength(1) + expect(warnings[0].type).toBe("subshell") + expect(warnings[0].message).toContain("subshell execution") + }) + + it("should detect subshell with backticks", () => { + const warnings = detectCommandSecurityIssues("echo `malicious`") + expect(warnings).toHaveLength(1) + expect(warnings[0].type).toBe("subshell") + expect(warnings[0].message).toContain("subshell execution") + }) + + it("should detect multiple subshell patterns", () => { + const warnings = detectCommandSecurityIssues("echo $(date) && echo `whoami`") + expect(warnings).toHaveLength(1) // Still one warning for subshell presence + expect(warnings[0].type).toBe("subshell") + }) + + it("should not detect issues in safe commands", () => { + const warnings = detectCommandSecurityIssues("npm install express") + expect(warnings).toHaveLength(0) + }) + + it("should handle empty command", () => { + const warnings = detectCommandSecurityIssues("") + expect(warnings).toHaveLength(0) + }) +}) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts new file mode 100644 index 00000000000..1384fff3d3c --- /dev/null +++ b/webview-ui/src/utils/command-parser.ts @@ -0,0 +1,215 @@ +import { parse } from "shell-quote" + +type ShellToken = string | { op: string } | { command: string } + +/** + * Shared command parsing utility that consolidates parsing logic + * from both command-validation.ts and commandPatterns.ts + */ + +/** + * Parse a command string and handle special cases like subshells, + * redirections, and quoted strings. + * + * @param command - The command string to parse + * @returns Object containing parsed information + */ +export function parseCommandString(command: string): { + subCommands: string[] + hasSubshells: boolean + subshellCommands: string[] +} { + if (!command?.trim()) { + return { + subCommands: [], + hasSubshells: false, + subshellCommands: [], + } + } + + // Storage for replaced content + const redirections: string[] = [] + const subshells: string[] = [] + const quotes: string[] = [] + const arrayIndexing: string[] = [] + + // First handle PowerShell redirections by temporarily replacing them + let processedCommand = command.replace(/\d*>&\d*/g, (match) => { + redirections.push(match) + return `__REDIR_${redirections.length - 1}__` + }) + + // Handle array indexing expressions: ${array[...]} pattern and partial expressions + processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => { + arrayIndexing.push(match) + return `__ARRAY_${arrayIndexing.length - 1}__` + }) + + // Then handle subshell commands - store them for security analysis + const hasSubshells = command.includes("$(") || command.includes("`") + + processedCommand = processedCommand + .replace(/\$\((.*?)\)/g, (_, inner) => { + const trimmedInner = inner.trim() + subshells.push(trimmedInner) + return `__SUBSH_${subshells.length - 1}__` + }) + .replace(/`(.*?)`/g, (_, inner) => { + const trimmedInner = inner.trim() + subshells.push(trimmedInner) + return `__SUBSH_${subshells.length - 1}__` + }) + + // Then handle quoted strings + processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { + quotes.push(match) + return `__QUOTE_${quotes.length - 1}__` + }) + + const tokens = parse(processedCommand) as ShellToken[] + const commands: string[] = [] + let currentCommand: string[] = [] + + for (const token of tokens) { + if (typeof token === "object" && "op" in token) { + // Chain operator - split command + if (["&&", "||", ";", "|"].includes(token.op)) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + } else { + // Other operators (>, &) are part of the command + currentCommand.push(token.op) + } + } else if (typeof token === "string") { + // Check if it's a subshell placeholder + const subshellMatch = token.match(/__SUBSH_(\d+)__/) + if (subshellMatch) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + commands.push(subshells[parseInt(subshellMatch[1])]) + } else { + currentCommand.push(token) + } + } + } + + // Add any remaining command + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + } + + // Restore quotes, redirections, and array indexing + const restoredCommands = commands.map((cmd) => { + let result = cmd + // Restore quotes + result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) + // Restore redirections + result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) + // Restore array indexing expressions + result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) + return result + }) + + return { + subCommands: restoredCommands, + hasSubshells, + subshellCommands: subshells, + } +} + +/** + * Extract command patterns for permission management. + * This is a simplified version that focuses on extracting + * the main command and its subcommands for pattern matching. + * + * @param command - The command string to extract patterns from + * @returns Array of command patterns + */ +export function extractPatternsFromCommand(command: string): string[] { + if (!command?.trim()) return [] + + // First, remove subshells for security - we don't want to extract patterns from subshell contents + const cleanedCommand = command + .replace(/\$\([^)]*\)/g, "") // Remove $() subshells + .replace(/`[^`]*`/g, "") // Remove backtick subshells + + const patterns = new Set() + const parsed = parse(cleanedCommand) as ShellToken[] + + const commandSeparators = new Set(["|", "&&", "||", ";"]) + let current: string[] = [] + + for (const token of parsed) { + if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { + if (current.length) processCommandForPatterns(current, patterns) + current = [] + } else { + current.push(String(token)) + } + } + + if (current.length) processCommandForPatterns(current, patterns) + + return Array.from(patterns).sort() +} + +/** + * Process a single command to extract patterns + */ +function processCommandForPatterns(cmd: string[], patterns: Set): void { + if (!cmd.length || typeof cmd[0] !== "string") return + + const mainCmd = cmd[0] + + // Skip if it's just a number (like "0" from "0 total") + if (/^\d+$/.test(mainCmd)) return + + // Skip common output patterns that aren't commands + const skipWords = ["total", "error", "warning", "failed", "success", "done"] + if (skipWords.includes(mainCmd.toLowerCase())) return + + patterns.add(mainCmd) + + const breakingExps = [/^-/, /[\\/.~]/] + + for (let i = 1; i < cmd.length; i++) { + const arg = cmd[i] + + if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break + + const pattern = cmd.slice(0, i + 1).join(" ") + patterns.add(pattern) + } +} + +/** + * Security analysis for commands + */ +export interface SecurityWarning { + type: "subshell" | "injection" + message: string +} + +/** + * Detect security issues in a command + * + * @param command - The command to analyze + * @returns Array of security warnings + */ +export function detectCommandSecurityIssues(command: string): SecurityWarning[] { + const warnings: SecurityWarning[] = [] + + // Check for subshell execution attempts + if (command.includes("$(") || command.includes("`")) { + warnings.push({ + type: "subshell", + message: "Command contains subshell execution which could bypass restrictions", + }) + } + + return warnings +} diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts index b403d41d8c2..2e69fe8b740 100644 --- a/webview-ui/src/utils/command-validation.ts +++ b/webview-ui/src/utils/command-validation.ts @@ -1,6 +1,4 @@ -import { parse } from "shell-quote" - -type ShellToken = string | { op: string } | { command: string } +import { parseCommandString } from "./command-parser" /** * # Command Denylist Feature - Longest Prefix Match Strategy @@ -70,185 +68,8 @@ type ShellToken = string | { op: string } | { command: string } * - Newlines as command separators */ export function parseCommand(command: string): string[] { - if (!command?.trim()) return [] - - // Split by newlines first (handle different line ending formats) - // This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac) - const lines = command.split(/\r\n|\r|\n/) - const allCommands: string[] = [] - - for (const line of lines) { - // Skip empty lines - if (!line.trim()) continue - - // Process each line through the existing parsing logic - const lineCommands = parseCommandLine(line) - allCommands.push(...lineCommands) - } - - return allCommands -} - -/** - * Parse a single line of commands (internal helper function) - */ -function parseCommandLine(command: string): string[] { - if (!command?.trim()) return [] - - // Storage for replaced content - const redirections: string[] = [] - const subshells: string[] = [] - const quotes: string[] = [] - const arrayIndexing: string[] = [] - const arithmeticExpressions: string[] = [] - const variables: string[] = [] - const parameterExpansions: string[] = [] - const processSubstitutions: string[] = [] - - // First handle PowerShell redirections by temporarily replacing them - let processedCommand = command.replace(/\d*>&\d*/g, (match) => { - redirections.push(match) - return `__REDIR_${redirections.length - 1}__` - }) - - // Handle arithmetic expressions: $((...)) pattern - // Match the entire arithmetic expression including nested parentheses - processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => { - arithmeticExpressions.push(match) - return `__ARITH_${arithmeticExpressions.length - 1}__` - }) - - // Handle parameter expansions: ${...} patterns (including array indexing) - // This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc. - processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => { - parameterExpansions.push(match) - return `__PARAM_${parameterExpansions.length - 1}__` - }) - - // Handle process substitutions: <(...) and >(...) - processedCommand = processedCommand.replace(/[<>]\([^)]+\)/g, (match) => { - processSubstitutions.push(match) - return `__PROCSUB_${processSubstitutions.length - 1}__` - }) - - // Handle simple variable references: $varname pattern - // This prevents shell-quote from splitting $count into separate tokens - processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => { - variables.push(match) - return `__VAR_${variables.length - 1}__` - }) - - // Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9 - processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => { - variables.push(match) - return `__VAR_${variables.length - 1}__` - }) - - // Then handle subshell commands - processedCommand = processedCommand - .replace(/\$\((.*?)\)/g, (_, inner) => { - subshells.push(inner.trim()) - return `__SUBSH_${subshells.length - 1}__` - }) - .replace(/`(.*?)`/g, (_, inner) => { - subshells.push(inner.trim()) - return `__SUBSH_${subshells.length - 1}__` - }) - - // Then handle quoted strings - processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { - quotes.push(match) - return `__QUOTE_${quotes.length - 1}__` - }) - - let tokens: ShellToken[] - try { - tokens = parse(processedCommand) as ShellToken[] - } catch (error: any) { - // If shell-quote fails to parse, fall back to simple splitting - console.warn("shell-quote parse error:", error.message, "for command:", processedCommand) - - // Simple fallback: split by common operators - const fallbackCommands = processedCommand - .split(/(?:&&|\|\||;|\|)/) - .map((cmd) => cmd.trim()) - .filter((cmd) => cmd.length > 0) - - // Restore all placeholders for each command - return fallbackCommands.map((cmd) => { - let result = cmd - // Restore quotes - result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) - // Restore redirections - result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) - // Restore array indexing expressions - result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) - // Restore arithmetic expressions - result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) - // Restore parameter expansions - result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) - // Restore process substitutions - result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)]) - // Restore variable references - result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) - return result - }) - } - - const commands: string[] = [] - let currentCommand: string[] = [] - - for (const token of tokens) { - if (typeof token === "object" && "op" in token) { - // Chain operator - split command - if (["&&", "||", ";", "|"].includes(token.op)) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } - } else { - // Other operators (>, &) are part of the command - currentCommand.push(token.op) - } - } else if (typeof token === "string") { - // Check if it's a subshell placeholder - const subshellMatch = token.match(/__SUBSH_(\d+)__/) - if (subshellMatch) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } - commands.push(subshells[parseInt(subshellMatch[1])]) - } else { - currentCommand.push(token) - } - } - } - - // Add any remaining command - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - } - - // Restore quotes and redirections - return commands.map((cmd) => { - let result = cmd - // Restore quotes - result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) - // Restore redirections - result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) - // Restore array indexing expressions - result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) - // Restore arithmetic expressions - result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) - // Restore parameter expansions - result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) - // Restore process substitutions - result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)]) - // Restore variable references - result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) - return result - }) + const { subCommands } = parseCommandString(command) + return subCommands } /** diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts index 0265ad61bb5..4ae05b17c88 100644 --- a/webview-ui/src/utils/commandPatterns.ts +++ b/webview-ui/src/utils/commandPatterns.ts @@ -1,88 +1,19 @@ -import { parse } from "shell-quote" +import { extractPatternsFromCommand, detectCommandSecurityIssues, SecurityWarning } from "./command-parser" export interface CommandPattern { pattern: string description?: string } -export interface SecurityWarning { - type: "subshell" | "injection" - message: string -} - -function processCommand(cmd: string[], patterns: Set): void { - if (!cmd.length || typeof cmd[0] !== "string") return - - const mainCmd = cmd[0] - - // Skip if it's just a number (like "0" from "0 total") - if (/^\d+$/.test(mainCmd)) return - - // Skip common output patterns that aren't commands - const skipWords = ["total", "error", "warning", "failed", "success", "done"] - if (skipWords.includes(mainCmd.toLowerCase())) return - - patterns.add(mainCmd) - - const breakingExps = [/^-/, /[\\/.~]/] - - for (let i = 1; i < cmd.length; i++) { - const arg = cmd[i] - - if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break - - const pattern = cmd.slice(0, i + 1).join(" ") - patterns.add(pattern) - } -} - -function extractPatterns(cmdStr: string): Set { - const patterns = new Set() - - const parsed = parse(cmdStr) - - const commandSeparators = new Set(["|", "&&", "||", ";"]) - let current: string[] = [] - for (const token of parsed) { - if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { - if (current.length) processCommand(current, patterns) - current = [] - } else { - current.push(String(token)) - } - } - - if (current.length) processCommand(current, patterns) - - return patterns -} +// Re-export SecurityWarning type from command-parser +export type { SecurityWarning } export function extractCommandPatterns(command: string): string[] { - if (!command?.trim()) return [] - - // First, check if the command contains subshells and remove them - // This is important for security - we don't want to extract patterns from subshell contents - const cleanedCommand = command - .replace(/\$\([^)]*\)/g, "") // Remove $() subshells - .replace(/`[^`]*`/g, "") // Remove backtick subshells - - const patterns = extractPatterns(cleanedCommand) - - return Array.from(patterns).sort() + return extractPatternsFromCommand(command) } export function detectSecurityIssues(command: string): SecurityWarning[] { - const warnings: SecurityWarning[] = [] - - // Check for subshell execution attempts - if (command.includes("$(") || command.includes("`")) { - warnings.push({ - type: "subshell", - message: "Command contains subshell execution which could bypass restrictions", - }) - } - - return warnings + return detectCommandSecurityIssues(command) } /** From 55d0ff3f696090d1c60774a2ab2a46955620e7bd Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 23 Jul 2025 11:53:51 -0600 Subject: [PATCH 11/31] chore: remove temporary review files --- .roo/temp/pr-5798/architecture-review.md | 128 -- .roo/temp/pr-5798/comments.json | 184 -- .roo/temp/pr-5798/final-review.md | 93 - .roo/temp/pr-5798/pattern-analysis.md | 118 -- .roo/temp/pr-5798/pr-metadata.json | 47 - .roo/temp/pr-5798/pr.diff | 2314 ---------------------- .roo/temp/pr-5798/review-context.json | 56 - .roo/temp/pr-5798/reviews.json | 79 - .roo/temp/pr-5798/test-analysis.md | 166 -- 9 files changed, 3185 deletions(-) delete mode 100644 .roo/temp/pr-5798/architecture-review.md delete mode 100644 .roo/temp/pr-5798/comments.json delete mode 100644 .roo/temp/pr-5798/final-review.md delete mode 100644 .roo/temp/pr-5798/pattern-analysis.md delete mode 100644 .roo/temp/pr-5798/pr-metadata.json delete mode 100644 .roo/temp/pr-5798/pr.diff delete mode 100644 .roo/temp/pr-5798/review-context.json delete mode 100644 .roo/temp/pr-5798/reviews.json delete mode 100644 .roo/temp/pr-5798/test-analysis.md diff --git a/.roo/temp/pr-5798/architecture-review.md b/.roo/temp/pr-5798/architecture-review.md deleted file mode 100644 index 211eed1dad4..00000000000 --- a/.roo/temp/pr-5798/architecture-review.md +++ /dev/null @@ -1,128 +0,0 @@ -## Architecture Review for PR #5798 - -### Module Boundaries - -**✅ GOOD: Clear separation of concerns** - -- The command permission UI logic is properly separated into dedicated components: - - `CommandExecution.tsx` - Handles command execution display and permission management - - `CommandPatternSelector.tsx` - UI component for pattern selection - - `commandPatterns.ts` - Business logic for pattern extraction and validation - -**✅ GOOD: Proper layering** - -- UI components (`CommandExecution`, `CommandPatternSelector`) depend on utility functions (`commandPatterns.ts`) -- State management flows through proper channels (ExtensionStateContext → Components → VSCode messages) -- No circular dependencies detected - -**⚠️ CONCERN: Overlapping responsibilities** - -- Both `command-validation.ts` and `commandPatterns.ts` handle command parsing -- `command-validation.ts` uses shell-quote for validation logic -- `commandPatterns.ts` also uses shell-quote for pattern extraction -- This creates potential for divergent parsing behavior - -### Dependency Analysis - -**✅ GOOD: Appropriate dependency choice** - -- `shell-quote` (v1.8.2) is a well-established library for shell command parsing -- Already used in `command-validation.ts`, so no new dependency introduced -- Lightweight and focused on a single responsibility - -**⚠️ CONCERN: Dependency duplication** - -- Both runtime dependencies and devDependencies include shell-quote types -- Consider if `@types/shell-quote` should only be in devDependencies - -### Architectural Concerns - -**❌ ISSUE: Inconsistent command parsing** - -- Two separate parsing implementations: - 1. `parseCommand()` in `command-validation.ts` - Complex parsing with subshell handling - 2. `parse()` usage in `commandPatterns.ts` - Simpler pattern extraction -- Risk of commands being parsed differently for validation vs. pattern extraction - -**✅ GOOD: State synchronization** - -- Proper flow: UI → ExtensionState → VSCode messages → Backend persistence -- Uses established patterns for state updates (`setAllowedCommands`, `setDeniedCommands`) -- Backend properly validates and sanitizes command arrays - -**⚠️ CONCERN: Security considerations** - -- `commandPatterns.ts` removes subshells before pattern extraction (good) -- However, the security warning detection (`detectSecurityIssues`) is not used in the UI -- Pattern extraction might miss edge cases that the validation logic catches - -**✅ GOOD: Internationalization support** - -- All UI strings use i18n keys -- 17 translation files updated consistently -- Follows established i18n patterns - -### Impact on System Architecture - -**Integration with existing permission system:** - -- ✅ Properly integrates with existing `allowedCommands` and `deniedCommands` state -- ✅ Uses the same validation logic (`getCommandDecision`) for auto-approval/denial -- ✅ Maintains backward compatibility with existing permission settings - -**UI/UX consistency:** - -- ✅ Follows existing UI patterns (VSCode toolkit components, Tailwind styling) -- ✅ Integrates seamlessly into the command execution flow -- ✅ Provides immediate visual feedback for permission states - -**Performance considerations:** - -- ✅ Pattern extraction is memoized with `useMemo` -- ✅ No unnecessary re-renders (proper React optimization) -- ⚠️ Pattern extraction runs on every command - consider caching for repeated commands - -### Consistency with Architectural Patterns - -**✅ GOOD: Follows established patterns** - -- Component structure matches other chat components -- State management through context follows app conventions -- Message passing to extension follows established patterns - -**✅ GOOD: Test coverage** - -- Comprehensive unit tests for both components and utilities -- Tests cover edge cases and user interactions -- Follows existing test patterns - -### Recommendations - -1. **Consolidate command parsing logic** - - - Extract common parsing logic into a shared utility - - Ensure `command-validation.ts` and `commandPatterns.ts` use the same parser - - This prevents divergent behavior between validation and pattern extraction - -2. **Add pattern caching** - - - Cache extracted patterns for recently executed commands - - Reduces redundant parsing operations - -3. **Enhance security integration** - - - Use `detectSecurityIssues` from `commandPatterns.ts` to show warnings in UI - - Ensure pattern extraction doesn't bypass security checks - -4. **Consider extracting pattern management** - - - Create a dedicated service/manager for command patterns - - Would centralize pattern extraction, caching, and persistence - -5. **Add integration tests** - - Test the full flow: UI interaction → state update → backend persistence - - Ensure pattern extraction and validation remain synchronized - -### Overall Assessment - -The PR demonstrates good architectural practices with clear module boundaries and proper separation of concerns. The main architectural concern is the duplication of command parsing logic, which could lead to inconsistent behavior. The integration with the existing permission system is well-designed and maintains backward compatibility. With the recommended improvements, particularly consolidating the parsing logic, this feature would be a solid addition to the codebase. diff --git a/.roo/temp/pr-5798/comments.json b/.roo/temp/pr-5798/comments.json deleted file mode 100644 index 414a2ee7167..00000000000 --- a/.roo/temp/pr-5798/comments.json +++ /dev/null @@ -1,184 +0,0 @@ -[ - { - "author": { "login": "delve-auditor" }, - "authorAssociation": "NONE", - "body": "✅ **No security or compliance issues detected.** Reviewed everything up to 47259df9547fe38e0b49d7fcb6e3eef84223212a.\n\n\n\u003cdetails\u003e\n\u003csummary\u003eSecurity Overview\u003c/summary\u003e\n\n- 🔎 **Scanned files:** 24 changed file(s)\n\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003eDetected Code Changes\u003c/summary\u003e\n\nThe diff is too large to display a summary of code changes.\n\n\u003c/details\u003e\n\nReply to this PR with `@delve-auditor` followed by a description of what change you want and we'll auto-submit a change to this PR to implement it.", - "createdAt": "2025-07-17T00:30:34Z", - "id": "IC_kwDONIq5lM63sr_k", - "includesCreatedEdit": true, - "isMinimized": false, - "minimizedReason": "", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3081945060", - "viewerDidAuthor": false - }, - { - "author": { "login": "daniel-lxs" }, - "authorAssociation": "COLLABORATOR", - "body": "@roomote-agent Use the switch_mode tool to switch to pr-fixer and address the reviews on this PR, commit the changes to this PR, do not create a new one", - "createdAt": "2025-07-21T16:38:47Z", - "id": "IC_kwDONIq5lM64oHNJ", - "includesCreatedEdit": false, - "isMinimized": false, - "minimizedReason": "", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097523017", - "viewerDidAuthor": false - }, - { - "author": { "login": "roomote" }, - "authorAssociation": "NONE", - "body": "Hi @daniel-lxs! I see your request to address the reviews on this PR. Ill now switch to pr-fixer mode and work on addressing the feedback from the code reviews. Ill commit the changes directly to this PR branch as requested.", - "createdAt": "2025-07-21T17:03:24Z", - "id": "IC_kwDONIq5lM64odzj", - "includesCreatedEdit": false, - "isMinimized": true, - "minimizedReason": "RESOLVED", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097615587", - "viewerDidAuthor": false - }, - { - "author": { "login": "roomote" }, - "authorAssociation": "NONE", - "body": "Hi @daniel-lxs! I've successfully addressed all the code review feedback on this PR:\n\n✅ **Fixed Issues:**\n1. Replaced the unused `showSuggestions` state variable with a `SHOW_SUGGESTIONS` constant\n2. Fixed the `parseCommandAndOutput` import name consistency issue\n3. Added test coverage for the fallback case where `enhanced.command === text`\n\n✅ **All CI checks are passing** - The changes have been committed and pushed to the PR branch.\n\nThe PR is now ready for re-review. Thanks for your patience!", - "createdAt": "2025-07-21T17:27:39Z", - "id": "IC_kwDONIq5lM64our9", - "includesCreatedEdit": false, - "isMinimized": true, - "minimizedReason": "RESOLVED", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097684733", - "viewerDidAuthor": false - }, - { - "author": { "login": "daniel-lxs" }, - "authorAssociation": "COLLABORATOR", - "body": "@roomote-agent please also address the review by Copilot by updating this PR, do not create a new PR", - "createdAt": "2025-07-21T18:20:20Z", - "id": "IC_kwDONIq5lM64pm7T", - "includesCreatedEdit": false, - "isMinimized": true, - "minimizedReason": "RESOLVED", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097915091", - "viewerDidAuthor": false - }, - { - "author": { "login": "roomote" }, - "authorAssociation": "NONE", - "body": "Hi @daniel-lxs! I see your request to address the Copilot review feedback. I'll work on fixing the following issues identified by Copilot:\n\n1. Convert the unused `showSuggestions` state variable to a constant\n2. Fix the `parseCommandAndOutput` import issue\n3. Add test coverage for the fallback case where `enhanced.command === text`\n\nI'll update this PR with the fixes shortly.", - "createdAt": "2025-07-21T18:22:14Z", - "id": "IC_kwDONIq5lM64poKm", - "includesCreatedEdit": false, - "isMinimized": true, - "minimizedReason": "RESOLVED", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097920166", - "viewerDidAuthor": false - }, - { - "author": { "login": "roomote" }, - "authorAssociation": "NONE", - "body": "Hi @daniel-lxs! I've reviewed the Copilot feedback and found that all the issues have already been addressed in previous commits:\n\n✅ **Issue 1**: The `showSuggestions` state variable has been converted to a constant `SHOW_SUGGESTIONS` (fixed in commit 94efb1a12)\n✅ **Issue 2**: The `parseCommandAndOutput` function is properly imported from `commandPatterns.ts` and used correctly\n✅ **Issue 3**: Test coverage for the fallback case already exists in the test file (lines 467-489)\n\nAll CI checks are passing. The PR appears to be ready for review. The Copilot review seems to be based on an older version of the code before these fixes were applied.", - "createdAt": "2025-07-21T18:23:18Z", - "id": "IC_kwDONIq5lM64po0H", - "includesCreatedEdit": false, - "isMinimized": true, - "minimizedReason": "RESOLVED", - "reactionGroups": [{ "content": "THUMBS_UP", "users": { "totalCount": 1 } }], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3097922823", - "viewerDidAuthor": false - }, - { - "author": { "login": "daniel-lxs" }, - "authorAssociation": "COLLABORATOR", - "body": "I am seeing some odd behavior with certain commands:\r\n\u003cimg width=\"413\" height=\"244\" alt=\"image\" src=\"https://github.com/user-attachments/assets/e76efbd8-40a6-4d6a-86f4-7c2298db92b8\" /\u003e\r\n\u003cimg width=\"411\" height=\"204\" alt=\"image\" src=\"https://github.com/user-attachments/assets/8d422a2e-e547-499c-86eb-a04aac3feefd\" /\u003e\r\n\r\nNot sure what is going on but it seems like for the first image the \"341\" appeared when the result of the command was returned, replacing the correct command prefix.\r\n\r\nShould the command be parsed from the actual tool call rather than the terminal context?\r\n\r\n", - "createdAt": "2025-07-21T22:05:57Z", - "id": "IC_kwDONIq5lM64vjai", - "includesCreatedEdit": false, - "isMinimized": true, - "minimizedReason": "RESOLVED", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3099473570", - "viewerDidAuthor": false - }, - { - "author": { "login": "hannesrudolph" }, - "authorAssociation": "MEMBER", - "body": "## Critical Issues Found\r\n\r\nThis PR duplicates significant existing functionality and introduces architectural concerns that need to be addressed:\r\n\r\n### 1. Major Code Redundancy\r\n\r\n- **Pattern Extraction**: The new `extractCommandPatterns()` duplicates the existing `parseCommand()` function with inconsistent behavior\r\n", - "createdAt": "2025-07-22T23:55:44Z", - "id": "IC_kwDONIq5lM65FUFH", - "includesCreatedEdit": true, - "isMinimized": false, - "minimizedReason": "", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3105177927", - "viewerDidAuthor": true - }, - { - "author": { "login": "hannesrudolph" }, - "authorAssociation": "MEMBER", - "body": "@roomote-agent the parser should be simplified to ```import { parse } from 'shell-quote';\r\n\r\nfunction extractPatterns(cmdStr) {\r\n const patterns = new Set();\r\n \r\n const parsed = parse(cmdStr);\r\n \r\n const commandSeparators = new Set(['|', '\u0026\u0026', '||', ';']);\r\n let current = [];\r\n for (const token of parsed) {\r\n if (typeof token === 'object' \u0026\u0026 token.op \u0026\u0026 commandSeparators.has(token.op)) {\r\n if (current.length) processCommand(current, patterns);\r\n current = [];\r\n } else {\r\n current.push(token);\r\n }\r\n }\r\n\r\n if (current.length) processCommand(current, patterns);\r\n \r\n return patterns;\r\n}``` and the filter should be simplified to ```function processCommand(cmd, patterns) {\r\n if (!cmd.length || typeof cmd[0] !== 'string') return;\r\n \r\n const mainCmd = cmd[0];\r\n patterns.add(mainCmd);\r\n \r\n const breakingExps = [ /^-/, /[\\\\/:.~ ]/, ];\r\n \r\n for (let i = 1; i \u003c cmd.length; i++) {\r\n const arg = cmd[i];\r\n\r\n if (typeof arg !== 'string' || breakingExps.some(re =\u003e re.test(arg))) break;\r\n \r\n const pattern = cmd.slice(0, i + 1).join(' ');\r\n patterns.add(pattern);\r\n }\r\n}```. That should be combined with the shell-quote library. ", - "createdAt": "2025-07-23T14:37:00Z", - "id": "IC_kwDONIq5lM65To9T", - "includesCreatedEdit": false, - "isMinimized": false, - "minimizedReason": "", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3108933459", - "viewerDidAuthor": true - }, - { - "author": { "login": "roomote" }, - "authorAssociation": "NONE", - "body": "Hi @hannesrudolph, I see your request to simplify the parser and filter functions using shell-quote. I'll implement these changes now. Working on it!", - "createdAt": "2025-07-23T14:38:54Z", - "id": "IC_kwDONIq5lM65TqxD", - "includesCreatedEdit": false, - "isMinimized": false, - "minimizedReason": "", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3108940867", - "viewerDidAuthor": false - }, - { - "author": { "login": "roomote" }, - "authorAssociation": "NONE", - "body": "Hi @hannesrudolph, I've implemented the simplified parser as requested! \n\n## Changes made:\n- ✅ Replaced custom parsing logic with the `shell-quote` library\n- ✅ Implemented the simplified `extractPatterns` function exactly as specified\n- ✅ Implemented the simplified `processCommand` helper function exactly as specified\n- ✅ Removed dependency on `parseCommand` from command-validation\n- ✅ All 60 existing tests pass without modification\n\n## Key improvements:\n- The parser now properly handles complex shell syntax using the battle-tested `shell-quote` library\n- The code is more concise and easier to understand\n- The breaking expressions have been simplified to `[ /^-/, /[\\\\/:.~ ]/ ]` as requested\n\nThe changes have been pushed to the PR branch and CI checks are currently running. The implementation maintains full backward compatibility while simplifying the codebase significantly.", - "createdAt": "2025-07-23T14:47:04Z", - "id": "IC_kwDONIq5lM65TyCz", - "includesCreatedEdit": false, - "isMinimized": false, - "minimizedReason": "", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3108970675", - "viewerDidAuthor": false - }, - { - "author": { "login": "hannesrudolph" }, - "authorAssociation": "MEMBER", - "body": "@roomote-agent run pr-reviewer mode on this and then fix the critical issues with pr-fixer mode", - "createdAt": "2025-07-23T16:25:44Z", - "id": "IC_kwDONIq5lM65VJx8", - "includesCreatedEdit": false, - "isMinimized": false, - "minimizedReason": "", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3109330044", - "viewerDidAuthor": true - }, - { - "author": { "login": "roomote" }, - "authorAssociation": "NONE", - "body": "Hi @hannesrudolph, I see your request. I will now run pr-reviewer mode on this PR to identify critical issues, then switch to pr-fixer mode to address them. Starting the review process now...", - "createdAt": "2025-07-23T16:30:01Z", - "id": "IC_kwDONIq5lM65VMvl", - "includesCreatedEdit": false, - "isMinimized": false, - "minimizedReason": "", - "reactionGroups": [], - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798#issuecomment-3109342181", - "viewerDidAuthor": false - } -] diff --git a/.roo/temp/pr-5798/final-review.md b/.roo/temp/pr-5798/final-review.md deleted file mode 100644 index 531898adbeb..00000000000 --- a/.roo/temp/pr-5798/final-review.md +++ /dev/null @@ -1,93 +0,0 @@ -# PR Review Summary for #5798: Add terminal command permissions UI to chat interface - -## Executive Summary - -This PR implements a well-designed UI component for managing terminal command permissions directly from the chat interface. The implementation demonstrates good code quality, follows established patterns, and includes comprehensive test coverage. However, there are critical architectural concerns that should be addressed before merging. - -## Critical Issues (Must Fix) - -### 1. **Duplicate Command Parsing Logic** 🔴 - -The most significant issue is the duplication of command parsing logic between `command-validation.ts` and `commandPatterns.ts`. Both files use the `shell-quote` library but implement parsing differently, which could lead to: - -- Inconsistent behavior between validation and pattern extraction -- Security vulnerabilities if patterns bypass validation logic -- Maintenance burden with two implementations to keep in sync - -**Recommendation**: Consolidate the parsing logic into a shared utility to ensure consistency. - -### 2. **Unused Security Features** 🔴 - -The `detectSecurityIssues` function in `commandPatterns.ts` is implemented but not utilized in the UI, missing an opportunity to warn users about potentially dangerous commands. - -**Recommendation**: Integrate security warnings into the UI to alert users about subshell execution attempts. - -## Pattern Inconsistencies - -### 1. **Hardcoded Configuration** 🟡 - -The `SHOW_SUGGESTIONS = true` constant in `CommandExecution.tsx` should be configurable through extension settings rather than hardcoded. - -### 2. **Large Test Files** 🟡 - -`CommandExecution.spec.tsx` at 591 lines is too large and should be split into focused test modules for better maintainability. - -### 3. **Minor Style Inconsistencies** 🟡 - -Some inline styles are used where Tailwind classes would be more appropriate, breaking from the established pattern. - -## Redundancy Findings - -✅ **No significant redundancy found**. The implementation properly reuses existing components and utilities where appropriate. The pattern extraction logic is centralized in `commandPatterns.ts` and used consistently. - -## Architecture Concerns - -### 1. **Performance Optimization Opportunity** 🟡 - -Pattern extraction runs on every command without caching. For frequently used commands, this could impact performance. - -**Recommendation**: Implement caching for extracted patterns to improve performance. - -### 2. **Module Organization** 🟡 - -Consider creating a dedicated pattern management service to centralize pattern extraction, caching, and persistence logic. - -## Test Coverage Issues - -### 1. **Missing Test Scenarios** 🟡 - -- No error boundary tests -- Missing accessibility tests (keyboard navigation, screen reader) -- No performance tests for handling large commands - -### 2. **Test Organization** 🟡 - -Test files could benefit from better organization using shared mock utilities and test data fixtures. - -## Minor Suggestions - -1. **Documentation**: Add JSDoc comments to exported interfaces and document the command pattern extraction algorithm -2. **Type Safety**: Consider moving `@types/shell-quote` to devDependencies only -3. **Integration Tests**: Add tests for the full flow from UI interaction to backend persistence -4. **i18n**: All translations are properly implemented ✅ - -## Positive Findings - -- ✅ Excellent separation of concerns between UI and business logic -- ✅ Comprehensive test coverage (61 tests) -- ✅ Proper state synchronization with VSCode extension -- ✅ Good accessibility implementation with ARIA attributes -- ✅ Follows established UI patterns and component structure -- ✅ Backward compatible with existing permission system -- ✅ All 17 language translations included - -## Recommendation - -**APPROVE WITH CHANGES**: This PR demonstrates high-quality implementation with good patterns and test coverage. However, the critical issue of duplicate command parsing logic must be addressed before merging to prevent potential security issues and maintenance problems. Once the parsing logic is consolidated and security warnings are integrated into the UI, this will be an excellent addition to the codebase. - -## Priority Actions - -1. **High Priority**: Consolidate command parsing logic between `command-validation.ts` and `commandPatterns.ts` -2. **High Priority**: Integrate `detectSecurityIssues` warnings into the UI -3. **Medium Priority**: Make `SHOW_SUGGESTIONS` configurable -4. **Low Priority**: Split large test files and add missing test scenarios diff --git a/.roo/temp/pr-5798/pattern-analysis.md b/.roo/temp/pr-5798/pattern-analysis.md deleted file mode 100644 index 515288a6a92..00000000000 --- a/.roo/temp/pr-5798/pattern-analysis.md +++ /dev/null @@ -1,118 +0,0 @@ -## Pattern Analysis for PR #5798 - -### Similar Existing Implementations - -1. **Permission/Toggle Components** - - - [`AutoApproveToggle`](webview-ui/src/components/settings/AutoApproveToggle.tsx:108) - Uses toggle buttons for permissions - - [`TelemetryBanner`](webview-ui/src/components/common/TelemetryBanner.tsx:74) - Allow/Deny pattern with buttons - - [`McpToolRow`](webview-ui/src/components/mcp/McpToolRow.tsx:71) - Always Allow checkbox pattern - -2. **Expandable/Collapsible UI Components** - - - [`AutoApproveMenu`](webview-ui/src/components/chat/AutoApproveMenu.tsx:18) - Uses `isExpanded` state with chevron - - [`ContextCondenseRow`](webview-ui/src/components/chat/ContextCondenseRow.tsx:12) - Similar expand/collapse pattern - - [`CodeAccordian`](webview-ui/src/components/common/CodeAccordian.tsx:15) - Accordion pattern with `onToggleExpand` - -3. **Command/Pattern Management** - - [`AutoApproveSettings`](webview-ui/src/components/settings/AutoApproveSettings.tsx:145) - Manages allowed/denied commands - - [`McpView`](webview-ui/src/components/mcp/McpView.tsx:200) - Server management with enable/disable - -### Established Patterns - -1. **State Management Pattern** - - - Use `useState` for local UI state (expand/collapse) - - Props include arrays for allowed/denied items - - Callbacks follow `onXxxChange` naming convention - -2. **UI Interaction Patterns** - - - Chevron icons rotate based on expanded state: `rotate-0` when expanded, `-rotate-90` when collapsed - - Use `cn()` utility for conditional classes - - Buttons use icon components from lucide-react - -3. **Component Structure** - - - Props interfaces clearly defined with TypeScript - - Memoization used for performance (`memo`, `useMemo`, `useCallback`) - - Consistent use of `aria-` attributes for accessibility - -4. **Testing Patterns** - - Mock dependencies at module level - - Use `data-testid` for test selectors - - Test both UI interactions and callback invocations - - Mock translations return the key for easier testing - -### Pattern Deviations - -1. **CommandPatternSelector Implementation** - - - ✅ Follows expand/collapse pattern correctly - - ✅ Uses proper chevron rotation classes - - ✅ Implements accessibility attributes - - ⚠️ Uses inline styles in some places where classes could be used - -2. **CommandExecution Implementation** - - ✅ Properly extracts patterns using utility functions - - ✅ Follows memoization patterns - - ⚠️ Has a hardcoded `SHOW_SUGGESTIONS = true` constant that could be configurable - -### Redundancy Findings - -1. **Pattern Extraction Logic** - - - The new `extractCommandPatterns` utility properly centralizes pattern extraction - - No redundant implementations found - other components use different pattern matching - -2. **UI Components** - - - No direct redundancy with existing components - - The allow/deny button pattern is similar to other components but serves a specific purpose - -3. **State Management** - - Uses existing `useExtensionState` for allowed/denied commands - - No redundant state management - -### Organization Issues - -1. **File Organization** - - - ✅ Components properly placed in `webview-ui/src/components/chat/` - - ✅ Utilities in `webview-ui/src/utils/` - - ✅ Tests follow `__tests__` convention - -2. **Import Organization** - - - ✅ Imports are well-organized - - ✅ Uses path aliases (`@src/`, `@roo/`) - -3. **Code Structure** - - ✅ Clear separation of concerns - - ✅ Proper TypeScript interfaces - - ⚠️ Some test files are quite large (591 lines for CommandExecution.spec.tsx) - -### Recommendations - -1. **Consider Configuration** - - - Make `SHOW_SUGGESTIONS` configurable rather than hardcoded - - Could be part of extension settings - -2. **Test File Size** - - - Consider splitting large test files into smaller, focused test suites - - Group related tests into separate files - -3. **Consistency Improvements** - - - Replace inline styles with Tailwind classes where possible - - Ensure all tooltips use `StandardTooltip` component consistently - -4. **Pattern Documentation** - - Consider adding JSDoc comments to exported interfaces - - Document the command pattern extraction algorithm - -### Conclusion - -The PR follows established patterns well and integrates cleanly with the existing codebase. The implementation is consistent with similar components and properly organized. Minor improvements could be made around configurability and test organization, but overall the code quality is high and follows the project's conventions. diff --git a/.roo/temp/pr-5798/pr-metadata.json b/.roo/temp/pr-5798/pr-metadata.json deleted file mode 100644 index 285949daa0d..00000000000 --- a/.roo/temp/pr-5798/pr-metadata.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "additions": 2015, - "author": { "id": "MDQ6VXNlcjQ5MTAzMjQ3", "is_bot": false, "login": "hannesrudolph", "name": "Hannes Rudolph" }, - "baseRefName": "main", - "body": "\n\n### Related GitHub Issue\n\n\n\nCloses: #5480 \n\n### Roo Code Task Context (Optional)\n\n\n\nN/A\n\n### Description\n\n\n\nThis PR implements an interactive UI component for managing terminal command permissions directly from the chat interface. The implementation includes:\n\n**Key Implementation Details:**\n- Created `CommandPatternSelector` component that displays command patterns with allow/deny toggle buttons\n- Integrated pattern extraction using the `shell-quote` library to handle complex shell syntax (pipes, chains, redirects)\n- Added pattern extraction logic that generates human-readable descriptions for common command patterns\n- Implemented state synchronization between UI and VSCode extension state for persistence\n- Added comprehensive test coverage (61 tests) for all new components and utilities\n\n**Design Choices:**\n- Used collapsible UI section to avoid cluttering the command execution display\n- Implemented mutual exclusivity between allow/deny lists to prevent conflicts\n- Prioritized AI-suggested patterns when available, falling back to programmatic extraction\n- Used VSCode theme variables for consistent appearance across different themes\n\n**Translation Updates:**\n- Added new translation keys to all 17 supported languages for the command permissions UI\n- All translations follow the project's localization guidelines\n\n**Areas for Review Focus:**\n- Pattern extraction logic in `commandPatterns.ts` - ensures proper handling of complex shell commands\n- State management integration in `CommandExecution.tsx` - verifies proper synchronization\n- UI/UX of the `CommandPatternSelector` component - accessibility and usability\n\n### Test Procedure\n\n\n\n**Unit Tests Added:**\n- `commandPatterns.spec.ts` (32 tests) - Tests pattern extraction, AI suggestion parsing, and edge cases\n- `CommandPatternSelector.spec.tsx` (13 tests) - Tests UI interactions, state management, and accessibility\n- `CommandExecution.spec.tsx` (16 tests) - Tests integration and message posting\n\n**Manual Testing Steps:**\n1. Open Roo Code in VSCode\n2. Execute a terminal command in the chat (e.g., `npm install express`)\n3. Click \"Manage Command Permissions\" in the command execution block\n4. Verify patterns are extracted correctly (e.g., `npm`, `npm install`)\n5. Click ✓ to add a pattern to the allowed list\n6. Click ✗ to add a pattern to the denied list\n7. Toggle patterns between states and verify visual feedback\n8. Check that changes persist across sessions\n9. Verify integration with existing settings UI\n\n**Test Results:**\n- All 61 unit tests pass\n- Manual testing verified all acceptance criteria\n- Minor lint warnings found but don't affect functionality\n\n### Pre-Submission Checklist\n\n\n\n- [x] **Issue Linked**: This PR is linked to an approved GitHub Issue (see \"Related GitHub Issue\" above).\n- [x] **Scope**: My changes are focused on the linked issue (one major feature/fix per PR).\n- [x] **Self-Review**: I have performed a thorough self-review of my code.\n- [x] **Testing**: New and/or updated tests have been added to cover my changes (if applicable).\n- [x] **Documentation Impact**: I have considered if my changes require documentation updates (see \"Documentation Updates\" section below).\n- [x] **Contribution Guidelines**: I have read and agree to the [Contributor Guidelines](/CONTRIBUTING.md).\n\n### Screenshots / Videos\n\n\n\n[Screenshots to be added showing the command permissions UI in action]\n\n### Documentation Updates\n\n\n\n- [ ] No documentation updates are required.\n- [ ] Yes, documentation updates are required. (Please describe what needs to be updated or link to a PR in the docs repository).\n\n### Additional Notes\n\n\n\nThis feature enhances the user experience by providing a more intuitive way to manage command permissions without navigating to settings. The implementation follows all existing patterns in the codebase and maintains backward compatibility with the existing permission system.\n\n### Get in Touch\n\n\n\n[Your Discord username]\n\n\n\n----\n\n> [!IMPORTANT]\n> This PR adds a UI component for managing terminal command permissions in the chat interface, with pattern extraction, state synchronization, and comprehensive test coverage.\n> \n> - **Behavior**:\n> - Adds `CommandPatternSelector` component in `CommandExecution.tsx` for managing command permissions with allow/deny toggles.\n> - Integrates `shell-quote` for pattern extraction handling complex shell syntax.\n> - Synchronizes state between UI and VSCode extension for persistence.\n> - **Pattern Extraction**:\n> - Implements pattern extraction in `commandPatterns.ts` to generate descriptions for command patterns.\n> - Handles complex shell commands like pipes, chains, and redirects.\n> - **Testing**:\n> - Adds 61 tests across `commandPatterns.spec.ts`, `CommandPatternSelector.spec.tsx`, and `CommandExecution.spec.tsx`.\n> - **Translations**:\n> - Updates translation keys for 17 languages for the command permissions UI.\n> \n> This description was created by [\"Ellipsis\"](https://www.ellipsis.dev?ref=RooCodeInc%2FRoo-Code&utm_source=github&utm_medium=referral) for b358c958bff8d817e4f848bc1683a2adff45c283. You can [customize](https://app.ellipsis.dev/RooCodeInc/settings/summaries) this summary. It will automatically update as commits are pushed.\n\n\n", - "changedFiles": 24, - "deletions": 24, - "files": [ - { "path": "webview-ui/src/components/chat/CommandExecution.tsx", "additions": 76, "deletions": 24 }, - { "path": "webview-ui/src/components/chat/CommandPatternSelector.tsx", "additions": 130, "deletions": 0 }, - { - "path": "webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx", - "additions": 591, - "deletions": 0 - }, - { - "path": "webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx", - "additions": 252, - "deletions": 0 - }, - { "path": "webview-ui/src/i18n/locales/ca/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/de/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/en/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/es/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/fr/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/hi/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/id/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/it/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/ja/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/ko/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/nl/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/pl/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/pt-BR/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/ru/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/tr/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/vi/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/zh-CN/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/i18n/locales/zh-TW/chat.json", "additions": 16, "deletions": 0 }, - { "path": "webview-ui/src/utils/__tests__/commandPatterns.spec.ts", "additions": 501, "deletions": 0 }, - { "path": "webview-ui/src/utils/commandPatterns.ts", "additions": 177, "deletions": 0 } - ], - "headRefName": "feat/issue-5480-command-permissions-ui", - "number": 5798, - "state": "OPEN", - "title": "feat: Add terminal command permissions UI to chat interface (#5480)", - "url": "https://github.com/RooCodeInc/Roo-Code/pull/5798" -} diff --git a/.roo/temp/pr-5798/pr.diff b/.roo/temp/pr-5798/pr.diff deleted file mode 100644 index 734725df54b..00000000000 --- a/.roo/temp/pr-5798/pr.diff +++ /dev/null @@ -1,2314 +0,0 @@ -diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx -index 8c92ec7e7b6..4ffbd32d881 100644 ---- a/webview-ui/src/components/chat/CommandExecution.tsx -+++ b/webview-ui/src/components/chat/CommandExecution.tsx -@@ -6,13 +6,19 @@ import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/ - - import { ExtensionMessage } from "@roo/ExtensionMessage" - import { safeJsonParse } from "@roo/safeJsonParse" --import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences" - - import { vscode } from "@src/utils/vscode" - import { useExtensionState } from "@src/context/ExtensionStateContext" - import { cn } from "@src/lib/utils" - import { Button } from "@src/components/ui" - import CodeBlock from "../common/CodeBlock" -+import { CommandPatternSelector } from "./CommandPatternSelector" -+import { -+ extractCommandPatterns, -+ getPatternDescription, -+ parseCommandAndOutput, -+ CommandPattern, -+} from "../../utils/commandPatterns" - - interface CommandExecutionProps { - executionId: string -@@ -22,21 +28,73 @@ interface CommandExecutionProps { - } - - export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => { -- const { terminalShellIntegrationDisabled = false } = useExtensionState() -- -- const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text]) -+ const { -+ terminalShellIntegrationDisabled = false, -+ allowedCommands = [], -+ deniedCommands = [], -+ setAllowedCommands, -+ setDeniedCommands, -+ } = useExtensionState() -+ -+ const { command, output: parsedOutput } = useMemo(() => { -+ // Use the enhanced parser from commandPatterns -+ return parseCommandAndOutput(text || "") -+ }, [text]) - - // If we aren't opening the VSCode terminal for this command then we default - // to expanding the command execution output. - const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) - const [streamingOutput, setStreamingOutput] = useState("") - const [status, setStatus] = useState(null) -+ // Show suggestions is always enabled for command pattern management -+ const SHOW_SUGGESTIONS = true - - // The command's output can either come from the text associated with the - // task message (this is the case for completed commands) or from the - // streaming output (this is the case for running commands). - const output = streamingOutput || parsedOutput - -+ // Extract command patterns from the actual command that was executed -+ const commandPatterns = useMemo(() => { -+ const patterns: CommandPattern[] = [] -+ -+ // Always extract patterns from the actual command that was executed -+ // We don't use AI suggestions because the patterns should reflect -+ // what was actually executed, not what the AI thinks might be useful -+ const extractedPatterns = extractCommandPatterns(command) -+ extractedPatterns.forEach((pattern) => { -+ patterns.push({ -+ pattern, -+ description: getPatternDescription(pattern), -+ }) -+ }) -+ -+ return patterns -+ }, [command]) -+ -+ // Handle pattern changes -+ const handleAllowPatternChange = (pattern: string) => { -+ const isAllowed = allowedCommands.includes(pattern) -+ const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern] -+ const newDenied = deniedCommands.filter((p) => p !== pattern) -+ -+ setAllowedCommands(newAllowed) -+ setDeniedCommands(newDenied) -+ vscode.postMessage({ type: "allowedCommands", commands: newAllowed }) -+ vscode.postMessage({ type: "deniedCommands", commands: newDenied }) -+ } -+ -+ const handleDenyPatternChange = (pattern: string) => { -+ const isDenied = deniedCommands.includes(pattern) -+ const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern] -+ const newAllowed = allowedCommands.filter((p) => p !== pattern) -+ -+ setAllowedCommands(newAllowed) -+ setDeniedCommands(newDenied) -+ vscode.postMessage({ type: "allowedCommands", commands: newAllowed }) -+ vscode.postMessage({ type: "deniedCommands", commands: newDenied }) -+ } -+ - const onMessage = useCallback( - (event: MessageEvent) => { - const message: ExtensionMessage = event.data -@@ -121,9 +179,20 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec -
-
- --
-- -- -+
-+
-+ -+ -+
-+ {SHOW_SUGGESTIONS && commandPatterns.length > 0 && ( -+ -+ )} -
- - ) -@@ -142,20 +211,3 @@ const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; - ) - - const OutputContainer = memo(OutputContainerInternal) -- --const parseCommandAndOutput = (text: string | undefined) => { -- if (!text) { -- return { command: "", output: "" } -- } -- -- const index = text.indexOf(COMMAND_OUTPUT_STRING) -- -- if (index === -1) { -- return { command: text, output: "" } -- } -- -- return { -- command: text.slice(0, index), -- output: text.slice(index + COMMAND_OUTPUT_STRING.length), -- } --} -diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx -new file mode 100644 -index 00000000000..17799a8aecc ---- /dev/null -+++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx -@@ -0,0 +1,130 @@ -+import React, { useState } from "react" -+import { Check, ChevronDown, Info, X } from "lucide-react" -+import { cn } from "../../lib/utils" -+import { useTranslation, Trans } from "react-i18next" -+import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" -+import { CommandPattern } from "../../utils/commandPatterns" -+import { StandardTooltip } from "../ui/standard-tooltip" -+ -+interface CommandPatternSelectorProps { -+ patterns: CommandPattern[] -+ allowedCommands: string[] -+ deniedCommands: string[] -+ onAllowPatternChange: (pattern: string) => void -+ onDenyPatternChange: (pattern: string) => void -+} -+ -+export const CommandPatternSelector: React.FC = ({ -+ patterns, -+ allowedCommands, -+ deniedCommands, -+ onAllowPatternChange, -+ onDenyPatternChange, -+}) => { -+ const { t } = useTranslation() -+ const [isExpanded, setIsExpanded] = useState(false) -+ -+ const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => { -+ if (allowedCommands.includes(pattern)) return "allowed" -+ if (deniedCommands.includes(pattern)) return "denied" -+ return "none" -+ } -+ -+ return ( -+
-+ -+ -+ {isExpanded && ( -+
-+ {patterns.map((item, index) => { -+ const status = getPatternStatus(item.pattern) -+ return ( -+
-+
-+ {item.pattern} -+ {item.description && ( -+ -+ - {item.description} -+ -+ )} -+
-+
-+ -+ -+
-+
-+ ) -+ })} -+
-+ )} -+
-+ ) -+} -diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx -new file mode 100644 -index 00000000000..162ccd007cd ---- /dev/null -+++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx -@@ -0,0 +1,591 @@ -+import React from "react" -+import { render, screen, fireEvent } from "@testing-library/react" -+import { describe, it, expect, vi, beforeEach } from "vitest" -+import { CommandExecution } from "../CommandExecution" -+import { ExtensionStateContext } from "../../../context/ExtensionStateContext" -+ -+// Mock dependencies -+vi.mock("react-use", () => ({ -+ useEvent: vi.fn(), -+})) -+ -+import { vscode } from "../../../utils/vscode" -+ -+vi.mock("../../../utils/vscode", () => ({ -+ vscode: { -+ postMessage: vi.fn(), -+ }, -+})) -+ -+vi.mock("../../common/CodeBlock", () => ({ -+ default: ({ source }: { source: string }) =>
{source}
, -+})) -+ -+// Mock the commandPatterns module but use the actual implementation -+vi.mock("../../../utils/commandPatterns", async () => { -+ const actual = await vi.importActual( -+ "../../../utils/commandPatterns", -+ ) -+ return { -+ ...actual, -+ parseCommandAndOutput: actual.parseCommandAndOutput, -+ extractCommandPatterns: actual.extractCommandPatterns, -+ getPatternDescription: actual.getPatternDescription, -+ } -+}) -+ -+vi.mock("../CommandPatternSelector", () => ({ -+ CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => ( -+
-+ {patterns.map((p: any, i: number) => ( -+
-+ {p.pattern} -+ -+ -+
-+ ))} -+
-+ ), -+})) -+ -+// Mock ExtensionStateContext -+const mockExtensionState = { -+ terminalShellIntegrationDisabled: false, -+ allowedCommands: ["npm"], -+ deniedCommands: ["rm"], -+ setAllowedCommands: vi.fn(), -+ setDeniedCommands: vi.fn(), -+} -+ -+const ExtensionStateWrapper = ({ children }: { children: React.ReactNode }) => ( -+ {children} -+) -+ -+describe("CommandExecution", () => { -+ beforeEach(() => { -+ vi.clearAllMocks() -+ }) -+ -+ it("should render command without output", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ expect(screen.getByTestId("code-block")).toHaveTextContent("npm install") -+ }) -+ -+ it("should render command with output", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const codeBlocks = screen.getAllByTestId("code-block") -+ expect(codeBlocks[0]).toHaveTextContent("npm install") -+ }) -+ -+ it("should render with custom icon and title", () => { -+ const icon = 📦 -+ const title = Installing Dependencies -+ -+ render( -+ -+ -+ , -+ ) -+ -+ expect(screen.getByTestId("custom-icon")).toBeInTheDocument() -+ expect(screen.getByTestId("custom-title")).toBeInTheDocument() -+ }) -+ -+ it("should show command pattern selector for simple commands", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() -+ expect(screen.getByText("npm")).toBeInTheDocument() -+ expect(screen.getByText("npm install")).toBeInTheDocument() -+ }) -+ -+ it("should handle allow pattern change", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const allowButton = screen.getByText("Allow git") -+ fireEvent.click(allowButton) -+ -+ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git"]) -+ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git"] }) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) -+ }) -+ -+ it("should handle deny pattern change", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const denyButton = screen.getByText("Deny docker") -+ fireEvent.click(denyButton) -+ -+ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) -+ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker"]) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker"] }) -+ }) -+ -+ it("should toggle allowed pattern", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const allowButton = screen.getByText("Allow npm") -+ fireEvent.click(allowButton) -+ -+ // npm is already in allowedCommands, so it should be removed -+ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith([]) -+ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: [] }) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) -+ }) -+ -+ it("should toggle denied pattern", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const denyButton = screen.getByText("Deny rm") -+ fireEvent.click(denyButton) -+ -+ // rm is already in deniedCommands, so it should be removed -+ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) -+ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([]) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) -+ }) -+ -+ it("should parse command with Output: separator", () => { -+ const commandText = `npm install -+Output: -+Installing...` -+ -+ render( -+ -+ -+ , -+ ) -+ -+ const codeBlocks = screen.getAllByTestId("code-block") -+ expect(codeBlocks[0]).toHaveTextContent("npm install") -+ }) -+ -+ it("should parse command with AI suggestions", () => { -+ const commandText = `npm install -+Output: -+Suggested patterns: npm, npm install, npm run` -+ -+ render( -+ -+ -+ , -+ ) -+ -+ // First check that the command was parsed correctly -+ const codeBlocks = screen.getAllByTestId("code-block") -+ expect(codeBlocks[0]).toHaveTextContent("npm install") -+ expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run") -+ -+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() -+ // Check that only patterns from the actual command are extracted, not from AI suggestions -+ expect(screen.getByText("npm")).toBeInTheDocument() -+ expect(screen.getAllByText("npm install").length).toBeGreaterThan(0) -+ // "npm run" should NOT be in the patterns since it's only in the AI suggestions, not the actual command -+ expect(screen.queryByText("npm run")).not.toBeInTheDocument() -+ }) -+ -+ it("should handle commands with pipes", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() -+ expect(screen.getByText("ls")).toBeInTheDocument() -+ expect(screen.getByText("grep")).toBeInTheDocument() -+ }) -+ -+ it("should handle commands with && operator", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() -+ expect(screen.getByText("npm")).toBeInTheDocument() -+ expect(screen.getByText("npm install")).toBeInTheDocument() -+ expect(screen.getByText("npm test")).toBeInTheDocument() -+ }) -+ -+ it("should not show pattern selector for empty commands", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() -+ }) -+ -+ it("should expand output when terminal shell integration is disabled", () => { -+ const disabledState = { -+ ...mockExtensionState, -+ terminalShellIntegrationDisabled: true, -+ } -+ -+ const commandText = `npm install -+Output: -+Output here` -+ -+ render( -+ -+ -+ , -+ ) -+ -+ // Output should be visible when shell integration is disabled -+ const codeBlocks = screen.getAllByTestId("code-block") -+ expect(codeBlocks).toHaveLength(2) // Command and output blocks -+ expect(codeBlocks[1]).toHaveTextContent("Output here") -+ }) -+ -+ it("should handle undefined allowedCommands and deniedCommands", () => { -+ const stateWithUndefined = { -+ ...mockExtensionState, -+ allowedCommands: undefined, -+ deniedCommands: undefined, -+ } -+ -+ render( -+ -+ -+ , -+ ) -+ -+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() -+ }) -+ -+ it("should handle pattern change when moving from denied to allowed", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const allowButton = screen.getByText("Allow rm") -+ fireEvent.click(allowButton) -+ -+ // rm should be removed from denied and added to allowed -+ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm"]) -+ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([]) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm"] }) -+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) -+ }) -+ -+ describe("integration with CommandPatternSelector", () => { -+ it("should extract patterns from complex commands with multiple operators", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const selector = screen.getByTestId("command-pattern-selector") -+ expect(selector).toBeInTheDocument() -+ expect(screen.getByText("npm")).toBeInTheDocument() -+ expect(screen.getByText("npm install")).toBeInTheDocument() -+ expect(screen.getByText("npm test")).toBeInTheDocument() -+ expect(screen.getByText("echo")).toBeInTheDocument() -+ }) -+ -+ it("should handle commands with malformed suggestions gracefully", () => { -+ const commandWithMalformedSuggestions = `npm install -+Output: -+Suggested patterns: npm, , npm install, -+Other output here` -+ -+ render( -+ -+ icon} -+ title={Run Command} -+ /> -+ , -+ ) -+ -+ const selector = screen.getByTestId("command-pattern-selector") -+ expect(selector).toBeInTheDocument() -+ // Should still show valid patterns -+ expect(screen.getAllByText("npm")[0]).toBeInTheDocument() -+ expect(screen.getAllByText("npm install")[0]).toBeInTheDocument() -+ }) -+ -+ it("should handle commands with subshells by not including them in patterns", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const selector = screen.getByTestId("command-pattern-selector") -+ expect(selector).toBeInTheDocument() -+ expect(screen.getByText("echo")).toBeInTheDocument() -+ expect(screen.getByText("git")).toBeInTheDocument() -+ expect(screen.getByText("git status")).toBeInTheDocument() -+ // Should not include subshell content -+ expect(screen.queryByText("whoami")).not.toBeInTheDocument() -+ }) -+ -+ it("should handle commands with backtick subshells", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const selector = screen.getByTestId("command-pattern-selector") -+ expect(selector).toBeInTheDocument() -+ expect(screen.getByText("git")).toBeInTheDocument() -+ expect(screen.getByText("git commit")).toBeInTheDocument() -+ // Should not include subshell content -+ expect(screen.queryByText("date")).not.toBeInTheDocument() -+ }) -+ -+ it("should handle pattern changes for commands with special characters", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ const selector = screen.getByTestId("command-pattern-selector") -+ expect(selector).toBeInTheDocument() -+ expect(screen.getByText("cd")).toBeInTheDocument() -+ expect(screen.getByText("npm")).toBeInTheDocument() -+ expect(screen.getByText("npm start")).toBeInTheDocument() -+ }) -+ -+ it("should handle commands with mixed content including output and suggestions", () => { -+ const commandWithMixedContent = `npm test -+Output: -+Running tests... -+✓ Test 1 passed -+✓ Test 2 passed -+ -+Suggested patterns: npm, npm test, npm run -+- npm -+- npm test -+- npm run test` -+ -+ render( -+ -+ icon} -+ title={Run Command} -+ /> -+ , -+ ) -+ -+ const selector = screen.getByTestId("command-pattern-selector") -+ expect(selector).toBeInTheDocument() -+ // Should show patterns only from the actual command, not from AI suggestions -+ expect(screen.getAllByText("npm")[0]).toBeInTheDocument() -+ expect(screen.getAllByText("npm test")[0]).toBeInTheDocument() -+ // "npm run" should NOT be in the patterns since it's only in the AI suggestions -+ expect(screen.queryByText("npm run")).not.toBeInTheDocument() -+ }) -+ -+ it("should update both allowed and denied lists when patterns conflict", () => { -+ const conflictState = { -+ ...mockExtensionState, -+ allowedCommands: ["git"], -+ deniedCommands: ["git push"], -+ } -+ -+ render( -+ -+ -+ , -+ ) -+ -+ // Click to allow "git push" -+ const allowButton = screen.getByText("Allow git push") -+ fireEvent.click(allowButton) -+ -+ // Should add to allowed and remove from denied -+ expect(conflictState.setAllowedCommands).toHaveBeenCalledWith(["git", "git push"]) -+ expect(conflictState.setDeniedCommands).toHaveBeenCalledWith([]) -+ }) -+ -+ it("should handle commands that cannot be parsed and fallback gracefully", () => { -+ // Test with a command that might cause parsing issues -+ const unparsableCommand = "echo 'test with unclosed quote" -+ -+ render( -+ -+ -+ , -+ ) -+ -+ // Should still render the command -+ expect(screen.getByTestId("code-block")).toHaveTextContent("echo 'test with unclosed quote") -+ -+ // Should show pattern selector with at least the main command -+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() -+ expect(screen.getByText("echo")).toBeInTheDocument() -+ }) -+ -+ it("should handle empty or whitespace-only commands", () => { -+ render( -+ -+ -+ , -+ ) -+ -+ // Should render without errors -+ expect(screen.getByTestId("code-block")).toBeInTheDocument() -+ -+ // Should not show pattern selector for empty commands -+ expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() -+ }) -+ -+ it("should handle commands with only output and no command prefix", () => { -+ const outputOnly = `Some output without a command -+Multiple lines of output -+Without any command prefix` -+ -+ render( -+ -+ -+ , -+ ) -+ -+ // Should treat the entire text as command when no prefix is found -+ const codeBlock = screen.getByTestId("code-block") -+ // The mock CodeBlock component renders text content without preserving newlines -+ expect(codeBlock.textContent).toContain("Some output without a command") -+ expect(codeBlock.textContent).toContain("Multiple lines of output") -+ expect(codeBlock.textContent).toContain("Without any command prefix") -+ }) -+ -+ it("should handle fallback case where parsed command equals original text", () => { -+ // This tests the case where parseCommandAndOutput returns command === text -+ // which happens when there's no output separator or command prefix -+ const plainCommand = "docker build ." -+ -+ render( -+ -+ -+ , -+ ) -+ -+ // Should render the command -+ expect(screen.getByTestId("code-block")).toHaveTextContent("docker build .") -+ -+ // Should show pattern selector with extracted patterns -+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() -+ expect(screen.getByText("docker")).toBeInTheDocument() -+ expect(screen.getByText("docker build")).toBeInTheDocument() -+ -+ // Verify no output is shown (since command === text means no output) -+ const codeBlocks = screen.getAllByTestId("code-block") -+ expect(codeBlocks).toHaveLength(1) // Only the command block, no output block -+ }) -+ -+ it("should not extract patterns from command output numbers", () => { -+ // This tests the specific bug where "0 total" from wc output was being extracted as a command -+ const commandWithNumericOutput = `wc -l *.go *.java -+Output: -+ 10 file1.go -+ 20 file2.go -+ 15 Main.java -+ 45 total` -+ -+ render( -+ -+ -+ , -+ ) -+ -+ // Should render the command and output -+ const codeBlocks = screen.getAllByTestId("code-block") -+ expect(codeBlocks[0]).toHaveTextContent("wc -l *.go *.java") -+ -+ // Should show pattern selector -+ const selector = screen.getByTestId("command-pattern-selector") -+ expect(selector).toBeInTheDocument() -+ -+ // Should only extract "wc" from the actual command -+ expect(screen.getByText("wc")).toBeInTheDocument() -+ -+ // Should NOT extract numeric patterns from output like "45 total" -+ expect(screen.queryByText("45")).not.toBeInTheDocument() -+ expect(screen.queryByText("total")).not.toBeInTheDocument() -+ expect(screen.queryByText("45 total")).not.toBeInTheDocument() -+ }) -+ -+ it("should handle the edge case of 0 total in output", () => { -+ // This is the exact case from the bug report -+ const commandWithZeroTotal = `wc -l *.go *.java -+Output: -+ 0 total` -+ -+ render( -+ -+ -+ , -+ ) -+ -+ // Should show pattern selector -+ const selector = screen.getByTestId("command-pattern-selector") -+ expect(selector).toBeInTheDocument() -+ -+ // Should only extract "wc" from the actual command -+ // Check within the pattern selector specifically -+ const patternTexts = Array.from(selector.querySelectorAll("span")).map((el) => el.textContent) -+ -+ // Should have "wc" as a pattern -+ expect(patternTexts).toContain("wc") -+ -+ // Should NOT have "0", "total", or "0 total" as patterns -+ expect(patternTexts).not.toContain("0") -+ expect(patternTexts).not.toContain("total") -+ expect(patternTexts).not.toContain("0 total") -+ -+ // The output should still be displayed in the code block -+ const codeBlocks = screen.getAllByTestId("code-block") -+ expect(codeBlocks.length).toBeGreaterThan(1) -+ expect(codeBlocks[1]).toHaveTextContent("0 total") -+ }) -+ }) -+}) -diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx -new file mode 100644 -index 00000000000..4dd69e3969a ---- /dev/null -+++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx -@@ -0,0 +1,252 @@ -+import React from "react" -+import { render, screen, fireEvent } from "@testing-library/react" -+import { describe, it, expect, vi, beforeEach } from "vitest" -+import { CommandPatternSelector } from "../CommandPatternSelector" -+import { CommandPattern } from "../../../utils/commandPatterns" -+ -+// Mock react-i18next -+vi.mock("react-i18next", () => ({ -+ useTranslation: () => ({ -+ t: (key: string) => key, -+ }), -+ Trans: ({ i18nKey, components }: any) => { -+ if (i18nKey === "chat:commandExecution.commandManagementDescription") { -+ return ( -+ -+ Manage command permissions: Click ✓ to allow auto-execution, ✗ to deny execution. Patterns can be -+ toggled on/off or removed from lists. {components.settingsLink} -+ -+ ) -+ } -+ return {i18nKey} -+ }, -+})) -+ -+// Mock VSCodeLink -+vi.mock("@vscode/webview-ui-toolkit/react", () => ({ -+ VSCodeLink: ({ children, onClick }: any) => ( -+ -+ {children || "View all settings"} -+ -+ ), -+})) -+ -+// Mock StandardTooltip -+vi.mock("../../ui/standard-tooltip", () => ({ -+ StandardTooltip: ({ children, content }: any) => ( -+
-+ {children} -+ {/* Render the content to make it testable */} -+
{content}
-+
-+ ), -+})) -+ -+// Mock window.postMessage -+const mockPostMessage = vi.fn() -+window.postMessage = mockPostMessage -+ -+describe("CommandPatternSelector", () => { -+ const mockPatterns: CommandPattern[] = [ -+ { pattern: "npm", description: "npm commands" }, -+ { pattern: "npm install", description: "npm install commands" }, -+ { pattern: "git", description: "git commands" }, -+ ] -+ -+ const defaultProps = { -+ patterns: mockPatterns, -+ allowedCommands: ["npm"], -+ deniedCommands: ["git"], -+ onAllowPatternChange: vi.fn(), -+ onDenyPatternChange: vi.fn(), -+ } -+ -+ beforeEach(() => { -+ vi.clearAllMocks() -+ }) -+ -+ it("should render collapsed by default", () => { -+ render() -+ -+ expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument() -+ expect(screen.queryByText("npm commands")).not.toBeInTheDocument() -+ }) -+ -+ it("should expand when clicked", () => { -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ // Check for the patterns themselves -+ expect(screen.getByText("npm")).toBeInTheDocument() -+ expect(screen.getByText("npm install")).toBeInTheDocument() -+ expect(screen.getByText("git")).toBeInTheDocument() -+ -+ // Check for the descriptions -+ expect(screen.getByText("- npm commands")).toBeInTheDocument() -+ expect(screen.getByText("- npm install commands")).toBeInTheDocument() -+ expect(screen.getByText("- git commands")).toBeInTheDocument() -+ }) -+ -+ it("should collapse when clicked again", () => { -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ const collapseButton = screen.getByRole("button", { name: "chat:commandExecution.collapseManagement" }) -+ fireEvent.click(collapseButton) -+ -+ expect(screen.queryByText("npm commands")).not.toBeInTheDocument() -+ }) -+ -+ it("should show correct status for patterns", () => { -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ // Check that npm has allowed styling (green) -+ const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0] -+ expect(npmAllowButton).toHaveClass("bg-green-500/20") -+ -+ // Check that git has denied styling (red) -+ const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0] -+ expect(gitDenyButton).toHaveClass("bg-red-500/20") -+ }) -+ -+ it("should call onAllowPatternChange when allow button is clicked", () => { -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ // Find all allow buttons with the "add to allowed" label -+ const allowButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToAllowed" }) -+ -+ // The second one should be for npm install (first is npm which is already allowed) -+ fireEvent.click(allowButtons[0]) -+ -+ expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm install") -+ }) -+ -+ it("should call onDenyPatternChange when deny button is clicked", () => { -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ // Find all deny buttons with the "add to denied" label -+ const denyButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToDenied" }) -+ -+ // The second one should be for npm install (first is npm, third is git which is already denied) -+ fireEvent.click(denyButtons[1]) -+ -+ expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("npm install") -+ }) -+ -+ it("should toggle allowed pattern when clicked", () => { -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ // Find the allow button for npm (which is already allowed) -+ const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0] -+ fireEvent.click(npmAllowButton) -+ -+ expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm") -+ }) -+ -+ it("should toggle denied pattern when clicked", () => { -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ // Find the deny button for git (which is already denied) -+ const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0] -+ fireEvent.click(gitDenyButton) -+ -+ expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("git") -+ }) -+ -+ it("should have tooltip with settings link", () => { -+ const { container } = render() -+ -+ // The info icon should have a tooltip -+ const tooltipWrapper = container.querySelector('[title="tooltip"]') -+ expect(tooltipWrapper).toBeTruthy() -+ -+ // The tooltip content includes a settings link (mocked as VSCodeLink) -+ // It's rendered in a hidden div for testing purposes -+ const settingsLink = container.querySelector('a[href="#"]') -+ expect(settingsLink).toBeTruthy() -+ expect(settingsLink?.textContent).toBe("View all settings") -+ -+ // Test that clicking the link posts the correct message -+ if (settingsLink) { -+ fireEvent.click(settingsLink) -+ -+ expect(mockPostMessage).toHaveBeenCalledWith( -+ { -+ type: "action", -+ action: "settingsButtonClicked", -+ values: { section: "autoApprove" }, -+ }, -+ "*", -+ ) -+ } -+ }) -+ -+ it("should render with empty patterns", () => { -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ // The expanded view should exist but be empty since there are no patterns -+ const expandedContent = screen -+ .getByRole("button", { name: "chat:commandExecution.collapseManagement" }) -+ .parentElement?.querySelector(".px-3.pb-3") -+ expect(expandedContent).toBeInTheDocument() -+ expect(expandedContent?.children.length).toBe(0) -+ }) -+ -+ it("should render patterns without descriptions", () => { -+ const patternsWithoutDesc: CommandPattern[] = [{ pattern: "custom-command" }] -+ -+ render() -+ -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ expect(screen.getByText("custom-command")).toBeInTheDocument() -+ }) -+ -+ it("should always show info icon with tooltip", () => { -+ const { container } = render() -+ -+ // Info icon should always be visible (not just when expanded) -+ // Look for the Info icon which is wrapped in StandardTooltip -+ const infoIcon = container.querySelector(".ml-1") -+ expect(infoIcon).toBeTruthy() -+ }) -+ -+ it("should apply correct classes for chevron rotation", () => { -+ const { container } = render() -+ -+ // Initially collapsed - chevron should be rotated -+ let chevron = container.querySelector(".size-3.transition-transform") -+ expect(chevron).toHaveClass("-rotate-90") -+ -+ // Click to expand -+ const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) -+ fireEvent.click(expandButton) -+ -+ // When expanded - chevron should not be rotated -+ chevron = container.querySelector(".size-3.transition-transform") -+ expect(chevron).toHaveClass("rotate-0") -+ }) -+}) -diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json -index 4c24d69f087..8f1b7dc34c7 100644 ---- a/webview-ui/src/i18n/locales/ca/chat.json -+++ b/webview-ui/src/i18n/locales/ca/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo ha vist noms de definicions de codi font utilitzats en aquest directori (fora de l'espai de treball):" - }, - "commandOutput": "Sortida de l'ordre", -+ "commandExecution": { -+ "running": "Executant", -+ "pid": "PID: {{pid}}", -+ "exited": "Finalitzat ({{exitCode}})", -+ "manageCommands": "Gestiona els permisos de les ordres", -+ "commandManagementDescription": "Gestiona els permisos de les ordres: Fes clic a ✓ per permetre l'execució automàtica, ✗ per denegar l'execució. Els patrons es poden activar/desactivar o eliminar de les llistes. Mostra tots els paràmetres", -+ "addToAllowed": "Afegeix a la llista de permesos", -+ "removeFromAllowed": "Elimina de la llista de permesos", -+ "addToDenied": "Afegeix a la llista de denegats", -+ "removeFromDenied": "Elimina de la llista de denegats", -+ "abortCommand": "Interromp l'execució de l'ordre", -+ "expandOutput": "Amplia la sortida", -+ "collapseOutput": "Redueix la sortida", -+ "expandManagement": "Amplia la secció de gestió d'ordres", -+ "collapseManagement": "Redueix la secció de gestió d'ordres" -+ }, - "response": "Resposta", - "arguments": "Arguments", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json -index 8f09fab831b..87f40edf2b8 100644 ---- a/webview-ui/src/i18n/locales/de/chat.json -+++ b/webview-ui/src/i18n/locales/de/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo hat Quellcode-Definitionsnamen in diesem Verzeichnis (außerhalb des Arbeitsbereichs) angezeigt:" - }, - "commandOutput": "Befehlsausgabe", -+ "commandExecution": { -+ "running": "Wird ausgeführt", -+ "pid": "PID: {{pid}}", -+ "exited": "Beendet ({{exitCode}})", -+ "manageCommands": "Befehlsberechtigungen verwalten", -+ "commandManagementDescription": "Befehlsberechtigungen verwalten: Klicke auf ✓, um die automatische Ausführung zu erlauben, ✗, um die Ausführung zu verweigern. Muster können ein-/ausgeschaltet oder aus Listen entfernt werden. Alle Einstellungen anzeigen", -+ "addToAllowed": "Zur Liste der erlaubten Befehle hinzufügen", -+ "removeFromAllowed": "Von der Liste der erlaubten Befehle entfernen", -+ "addToDenied": "Zur Liste der verweigerten Befehle hinzufügen", -+ "removeFromDenied": "Von der Liste der verweigerten Befehle entfernen", -+ "abortCommand": "Befehlsausführung abbrechen", -+ "expandOutput": "Ausgabe erweitern", -+ "collapseOutput": "Ausgabe einklappen", -+ "expandManagement": "Befehlsverwaltungsbereich erweitern", -+ "collapseManagement": "Befehlsverwaltungsbereich einklappen" -+ }, - "response": "Antwort", - "arguments": "Argumente", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json -index 53e529d4e45..c1ead772ea1 100644 ---- a/webview-ui/src/i18n/locales/en/chat.json -+++ b/webview-ui/src/i18n/locales/en/chat.json -@@ -208,6 +208,22 @@ - "resultTooltip": "Similarity score: {{score}} (click to open file)" - }, - "commandOutput": "Command Output", -+ "commandExecution": { -+ "running": "Running", -+ "pid": "PID: {{pid}}", -+ "exited": "Exited ({{exitCode}})", -+ "manageCommands": "Manage Command Permissions", -+ "commandManagementDescription": "Manage command permissions: Click ✓ to allow auto-execution, ✗ to deny execution. Patterns can be toggled on/off or removed from lists. View all settings", -+ "addToAllowed": "Add to allowed list", -+ "removeFromAllowed": "Remove from allowed list", -+ "addToDenied": "Add to denied list", -+ "removeFromDenied": "Remove from denied list", -+ "abortCommand": "Abort command execution", -+ "expandOutput": "Expand output", -+ "collapseOutput": "Collapse output", -+ "expandManagement": "Expand command management section", -+ "collapseManagement": "Collapse command management section" -+ }, - "response": "Response", - "arguments": "Arguments", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json -index bb84baa555a..5a749085201 100644 ---- a/webview-ui/src/i18n/locales/es/chat.json -+++ b/webview-ui/src/i18n/locales/es/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo vio nombres de definiciones de código fuente utilizados en este directorio (fuera del espacio de trabajo):" - }, - "commandOutput": "Salida del comando", -+ "commandExecution": { -+ "running": "Ejecutando", -+ "pid": "PID: {{pid}}", -+ "exited": "Finalizado ({{exitCode}})", -+ "manageCommands": "Gestionar permisos de comandos", -+ "commandManagementDescription": "Gestionar permisos de comandos: Haz clic en ✓ para permitir la ejecución automática, ✗ para denegar la ejecución. Los patrones se pueden activar/desactivar o eliminar de las listas. Ver todos los ajustes", -+ "addToAllowed": "Añadir a la lista de permitidos", -+ "removeFromAllowed": "Eliminar de la lista de permitidos", -+ "addToDenied": "Añadir a la lista de denegados", -+ "removeFromDenied": "Eliminar de la lista de denegados", -+ "abortCommand": "Abortar ejecución del comando", -+ "expandOutput": "Expandir salida", -+ "collapseOutput": "Contraer salida", -+ "expandManagement": "Expandir sección de gestión de comandos", -+ "collapseManagement": "Contraer sección de gestión de comandos" -+ }, - "response": "Respuesta", - "arguments": "Argumentos", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json -index 70bd6011dd7..e81f1a9606f 100644 ---- a/webview-ui/src/i18n/locales/fr/chat.json -+++ b/webview-ui/src/i18n/locales/fr/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo a vu les noms de définitions de code source utilisés dans ce répertoire (hors espace de travail) :" - }, - "commandOutput": "Sortie de commande", -+ "commandExecution": { -+ "running": "En cours d'exécution", -+ "pid": "PID : {{pid}}", -+ "exited": "Terminé ({{exitCode}})", -+ "manageCommands": "Gérer les autorisations de commande", -+ "commandManagementDescription": "Gérer les autorisations de commande : Cliquez sur ✓ pour autoriser l'exécution automatique, ✗ pour refuser l'exécution. Les modèles peuvent être activés/désactivés ou supprimés des listes. Voir tous les paramètres", -+ "addToAllowed": "Ajouter à la liste autorisée", -+ "removeFromAllowed": "Retirer de la liste autorisée", -+ "addToDenied": "Ajouter à la liste refusée", -+ "removeFromDenied": "Retirer de la liste refusée", -+ "abortCommand": "Abandonner l'exécution de la commande", -+ "expandOutput": "Développer la sortie", -+ "collapseOutput": "Réduire la sortie", -+ "expandManagement": "Développer la section de gestion des commandes", -+ "collapseManagement": "Réduire la section de gestion des commandes" -+ }, - "response": "Réponse", - "arguments": "Arguments", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json -index 0fa95c27089..2ea44c4fbce 100644 ---- a/webview-ui/src/i18n/locales/hi/chat.json -+++ b/webview-ui/src/i18n/locales/hi/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo ने इस निर्देशिका (कार्यक्षेत्र के बाहर) में उपयोग किए गए सोर्स कोड परिभाषा नामों को देखा:" - }, - "commandOutput": "कमांड आउटपुट", -+ "commandExecution": { -+ "running": "चलाया जा रहा है", -+ "pid": "पीआईडी: {{pid}}", -+ "exited": "बाहर निकल गया ({{exitCode}})", -+ "manageCommands": "कमांड अनुमतियाँ प्रबंधित करें", -+ "commandManagementDescription": "कमांड अनुमतियों का प्रबंधन करें: स्वतः-निष्पादन की अनुमति देने के लिए ✓ पर क्लिक करें, निष्पादन से इनकार करने के लिए ✗ पर क्लिक करें। पैटर्न को चालू/बंद किया जा सकता है या सूचियों से हटाया जा सकता है। सभी सेटिंग्स देखें", -+ "addToAllowed": "अनुमत सूची में जोड़ें", -+ "removeFromAllowed": "अनुमत सूची से हटाएं", -+ "addToDenied": "अस्वीकृत सूची में जोड़ें", -+ "removeFromDenied": "अस्वीकृत सूची से हटाएं", -+ "abortCommand": "कमांड निष्पादन रद्द करें", -+ "expandOutput": "आउटपुट का विस्तार करें", -+ "collapseOutput": "आउटपुट संक्षिप्त करें", -+ "expandManagement": "कमांड प्रबंधन अनुभाग का विस्तार करें", -+ "collapseManagement": "कमांड प्रबंधन अनुभाग संक्षिप्त करें" -+ }, - "response": "प्रतिक्रिया", - "arguments": "आर्ग्युमेंट्स", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json -index f8e2a5cb0ec..994d8a9e56f 100644 ---- a/webview-ui/src/i18n/locales/id/chat.json -+++ b/webview-ui/src/i18n/locales/id/chat.json -@@ -214,6 +214,22 @@ - "resultTooltip": "Skor kemiripan: {{score}} (klik untuk membuka file)" - }, - "commandOutput": "Output Perintah", -+ "commandExecution": { -+ "running": "Menjalankan", -+ "pid": "PID: {{pid}}", -+ "exited": "Keluar ({{exitCode}})", -+ "manageCommands": "Kelola Izin Perintah", -+ "commandManagementDescription": "Kelola izin perintah: Klik ✓ untuk mengizinkan eksekusi otomatis, ✗ untuk menolak eksekusi. Pola dapat diaktifkan/dinonaktifkan atau dihapus dari daftar. Lihat semua pengaturan", -+ "addToAllowed": "Tambahkan ke daftar yang diizinkan", -+ "removeFromAllowed": "Hapus dari daftar yang diizinkan", -+ "addToDenied": "Tambahkan ke daftar yang ditolak", -+ "removeFromDenied": "Hapus dari daftar yang ditolak", -+ "abortCommand": "Batalkan eksekusi perintah", -+ "expandOutput": "Perluas output", -+ "collapseOutput": "Ciutkan output", -+ "expandManagement": "Perluas bagian manajemen perintah", -+ "collapseManagement": "Ciutkan bagian manajemen perintah" -+ }, - "response": "Respons", - "arguments": "Argumen", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json -index bea63c047a6..5b6c7d43bdf 100644 ---- a/webview-ui/src/i18n/locales/it/chat.json -+++ b/webview-ui/src/i18n/locales/it/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo ha visualizzato i nomi delle definizioni di codice sorgente utilizzate in questa directory (fuori dall'area di lavoro):" - }, - "commandOutput": "Output del comando", -+ "commandExecution": { -+ "running": "In esecuzione", -+ "pid": "PID: {{pid}}", -+ "exited": "Terminato ({{exitCode}})", -+ "manageCommands": "Gestisci autorizzazioni comandi", -+ "commandManagementDescription": "Gestisci le autorizzazioni dei comandi: fai clic su ✓ per consentire l'esecuzione automatica, ✗ per negare l'esecuzione. I pattern possono essere attivati/disattivati o rimossi dagli elenchi. Visualizza tutte le impostazioni", -+ "addToAllowed": "Aggiungi all'elenco consentiti", -+ "removeFromAllowed": "Rimuovi dall'elenco consentiti", -+ "addToDenied": "Aggiungi all'elenco negati", -+ "removeFromDenied": "Rimuovi dall'elenco negati", -+ "abortCommand": "Interrompi esecuzione comando", -+ "expandOutput": "Espandi output", -+ "collapseOutput": "Comprimi output", -+ "expandManagement": "Espandi la sezione di gestione dei comandi", -+ "collapseManagement": "Comprimi la sezione di gestione dei comandi" -+ }, - "response": "Risposta", - "arguments": "Argomenti", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json -index ca6443b3d81..77f4148a11d 100644 ---- a/webview-ui/src/i18n/locales/ja/chat.json -+++ b/webview-ui/src/i18n/locales/ja/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)で使用されているソースコード定義名を表示しました:" - }, - "commandOutput": "コマンド出力", -+ "commandExecution": { -+ "running": "実行中", -+ "pid": "PID: {{pid}}", -+ "exited": "終了しました ({{exitCode}})", -+ "manageCommands": "コマンド権限の管理", -+ "commandManagementDescription": "コマンドの権限を管理します:✓ をクリックして自動実行を許可し、✗ をクリックして実行を拒否します。パターンはオン/オフの切り替えやリストからの削除が可能です。すべての設定を表示", -+ "addToAllowed": "許可リストに追加", -+ "removeFromAllowed": "許可リストから削除", -+ "addToDenied": "拒否リストに追加", -+ "removeFromDenied": "拒否リストから削除", -+ "abortCommand": "コマンドの実行を中止", -+ "expandOutput": "出力を展開", -+ "collapseOutput": "出力を折りたたむ", -+ "expandManagement": "コマンド管理セクションを展開", -+ "collapseManagement": "コマンド管理セクションを折りたたむ" -+ }, - "response": "応答", - "arguments": "引数", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json -index 7e2c4467cd6..7cae0e469b8 100644 ---- a/webview-ui/src/i18n/locales/ko/chat.json -+++ b/webview-ui/src/i18n/locales/ko/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)에서 사용된 소스 코드 정의 이름을 보았습니다:" - }, - "commandOutput": "명령 출력", -+ "commandExecution": { -+ "running": "실행 중", -+ "pid": "PID: {{pid}}", -+ "exited": "종료됨 ({{exitCode}})", -+ "manageCommands": "명령 권한 관리", -+ "commandManagementDescription": "명령 권한 관리: 자동 실행을 허용하려면 ✓를 클릭하고 실행을 거부하려면 ✗를 클릭하십시오. 패턴은 켜거나 끄거나 목록에서 제거할 수 있습니다. 모든 설정 보기", -+ "addToAllowed": "허용 목록에 추가", -+ "removeFromAllowed": "허용 목록에서 제거", -+ "addToDenied": "거부 목록에 추가", -+ "removeFromDenied": "거부 목록에서 제거", -+ "abortCommand": "명령 실행 중단", -+ "expandOutput": "출력 확장", -+ "collapseOutput": "출력 축소", -+ "expandManagement": "명령 관리 섹션 확장", -+ "collapseManagement": "명령 관리 섹션 축소" -+ }, - "response": "응답", - "arguments": "인수", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json -index e123b5e8f2a..e263b8800ee 100644 ---- a/webview-ui/src/i18n/locales/nl/chat.json -+++ b/webview-ui/src/i18n/locales/nl/chat.json -@@ -187,6 +187,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo heeft broncode-definitienamen bekeken die in deze map (buiten werkruimte) worden gebruikt:" - }, - "commandOutput": "Commando-uitvoer", -+ "commandExecution": { -+ "running": "Lopend", -+ "pid": "PID: {{pid}}", -+ "exited": "Afgesloten ({{exitCode}})", -+ "manageCommands": "Beheer Commando Toestemmingen", -+ "commandManagementDescription": "Beheer commando toestemmingen: Klik op ✓ om automatische uitvoering toe te staan, ✗ om uitvoering te weigeren. Patronen kunnen worden in- of uitgeschakeld of uit lijsten worden verwijderd. Bekijk alle instellingen", -+ "addToAllowed": "Toevoegen aan toegestane lijst", -+ "removeFromAllowed": "Verwijderen van toegestane lijst", -+ "addToDenied": "Toevoegen aan geweigerde lijst", -+ "removeFromDenied": "Verwijderen van geweigerde lijst", -+ "abortCommand": "Commando-uitvoering afbreken", -+ "expandOutput": "Uitvoer uitvouwen", -+ "collapseOutput": "Uitvoer samenvouwen", -+ "expandManagement": "Beheersectie voor commando's uitvouwen", -+ "collapseManagement": "Beheersectie voor commando's samenvouwen" -+ }, - "response": "Antwoord", - "arguments": "Argumenten", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json -index f772256b102..a80f5351392 100644 ---- a/webview-ui/src/i18n/locales/pl/chat.json -+++ b/webview-ui/src/i18n/locales/pl/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo zobaczył nazwy definicji kodu źródłowego używane w tym katalogu (poza obszarem roboczym):" - }, - "commandOutput": "Wyjście polecenia", -+ "commandExecution": { -+ "running": "Wykonywanie", -+ "pid": "PID: {{pid}}", -+ "exited": "Zakończono ({{exitCode}})", -+ "manageCommands": "Zarządzaj uprawnieniami poleceń", -+ "commandManagementDescription": "Zarządzaj uprawnieniami poleceń: Kliknij ✓, aby zezwolić na automatyczne wykonanie, ✗, aby odmówić wykonania. Wzorce można włączać/wyłączać lub usuwać z listy. Zobacz wszystkie ustawienia", -+ "addToAllowed": "Dodaj do listy dozwolonych", -+ "removeFromAllowed": "Usuń z listy dozwolonych", -+ "addToDenied": "Dodaj do listy odrzuconych", -+ "removeFromDenied": "Usuń z listy odrzuconych", -+ "abortCommand": "Przerwij wykonywanie polecenia", -+ "expandOutput": "Rozwiń wyjście", -+ "collapseOutput": "Zwiń wyjście", -+ "expandManagement": "Rozwiń sekcję zarządzania poleceniami", -+ "collapseManagement": "Zwiń sekcję zarządzania poleceniami" -+ }, - "response": "Odpowiedź", - "arguments": "Argumenty", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json -index 08eb496d0a9..e1473e101d2 100644 ---- a/webview-ui/src/i18n/locales/pt-BR/chat.json -+++ b/webview-ui/src/i18n/locales/pt-BR/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo visualizou nomes de definição de código-fonte usados neste diretório (fora do espaço de trabalho):" - }, - "commandOutput": "Saída do comando", -+ "commandExecution": { -+ "running": "Executando", -+ "pid": "PID: {{pid}}", -+ "exited": "Encerrado ({{exitCode}})", -+ "manageCommands": "Gerenciar Permissões de Comando", -+ "commandManagementDescription": "Gerencie as permissões de comando: Clique em ✓ para permitir a execução automática, ✗ para negar a execução. Os padrões podem ser ativados/desativados ou removidos das listas. Ver todas as configurações", -+ "addToAllowed": "Adicionar à lista de permitidos", -+ "removeFromAllowed": "Remover da lista de permitidos", -+ "addToDenied": "Adicionar à lista de negados", -+ "removeFromDenied": "Remover da lista de negados", -+ "abortCommand": "Abortar execução do comando", -+ "expandOutput": "Expandir saída", -+ "collapseOutput": "Recolher saída", -+ "expandManagement": "Expandir seção de gerenciamento de comandos", -+ "collapseManagement": "Recolher seção de gerenciamento de comandos" -+ }, - "response": "Resposta", - "arguments": "Argumentos", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json -index 07e05015051..7e68295101f 100644 ---- a/webview-ui/src/i18n/locales/ru/chat.json -+++ b/webview-ui/src/i18n/locales/ru/chat.json -@@ -187,6 +187,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo просмотрел имена определений исходного кода в этой директории (вне рабочего пространства):" - }, - "commandOutput": "Вывод команды", -+ "commandExecution": { -+ "running": "Выполняется", -+ "pid": "PID: {{pid}}", -+ "exited": "Завершено ({{exitCode}})", -+ "manageCommands": "Управление разрешениями команд", -+ "commandManagementDescription": "Управляйте разрешениями команд: Нажмите ✓, чтобы разрешить автоматическое выполнение, ✗, чтобы запретить выполнение. Шаблоны можно включать/выключать или удалять из списков. Просмотреть все настройки", -+ "addToAllowed": "Добавить в список разрешенных", -+ "removeFromAllowed": "Удалить из списка разрешенных", -+ "addToDenied": "Добавить в список запрещенных", -+ "removeFromDenied": "Удалить из списка запрещенных", -+ "abortCommand": "Прервать выполнение команды", -+ "expandOutput": "Развернуть вывод", -+ "collapseOutput": "Свернуть вывод", -+ "expandManagement": "Развернуть раздел управления командами", -+ "collapseManagement": "Свернуть раздел управления командами" -+ }, - "response": "Ответ", - "arguments": "Аргументы", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json -index ee16f56f72b..c797287b717 100644 ---- a/webview-ui/src/i18n/locales/tr/chat.json -+++ b/webview-ui/src/i18n/locales/tr/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo bu dizinde (çalışma alanı dışında) kullanılan kaynak kod tanımlama isimlerini görüntüledi:" - }, - "commandOutput": "Komut Çıktısı", -+ "commandExecution": { -+ "running": "Çalışıyor", -+ "pid": "PID: {{pid}}", -+ "exited": "Çıkıldı ({{exitCode}})", -+ "manageCommands": "Komut İzinlerini Yönet", -+ "commandManagementDescription": "Komut izinlerini yönetin: Otomatik yürütmeye izin vermek için ✓'e, yürütmeyi reddetmek için ✗'e tıklayın. Desenler açılıp kapatılabilir veya listelerden kaldırılabilir. Tüm ayarları görüntüle", -+ "addToAllowed": "İzin verilenler listesine ekle", -+ "removeFromAllowed": "İzin verilenler listesinden kaldır", -+ "addToDenied": "Reddedilenler listesine ekle", -+ "removeFromDenied": "Reddedilenler listesinden kaldır", -+ "abortCommand": "Komut yürütmeyi iptal et", -+ "expandOutput": "Çıktıyı genişlet", -+ "collapseOutput": "Çıktıyı daralt", -+ "expandManagement": "Komut yönetimi bölümünü genişlet", -+ "collapseManagement": "Komut yönetimi bölümünü daralt" -+ }, - "response": "Yanıt", - "arguments": "Argümanlar", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json -index e56f63a91e2..3e08f0d6ffa 100644 ---- a/webview-ui/src/i18n/locales/vi/chat.json -+++ b/webview-ui/src/i18n/locales/vi/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo đã xem tên định nghĩa mã nguồn được sử dụng trong thư mục này (ngoài không gian làm việc):" - }, - "commandOutput": "Kết quả lệnh", -+ "commandExecution": { -+ "running": "Đang chạy", -+ "pid": "PID: {{pid}}", -+ "exited": "Đã thoát ({{exitCode}})", -+ "manageCommands": "Quản lý quyền lệnh", -+ "commandManagementDescription": "Quản lý quyền lệnh: Nhấp vào ✓ để cho phép tự động thực thi, ✗ để từ chối thực thi. Các mẫu có thể được bật/tắt hoặc xóa khỏi danh sách. Xem tất cả cài đặt", -+ "addToAllowed": "Thêm vào danh sách cho phép", -+ "removeFromAllowed": "Xóa khỏi danh sách cho phép", -+ "addToDenied": "Thêm vào danh sách từ chối", -+ "removeFromDenied": "Xóa khỏi danh sách từ chối", -+ "abortCommand": "Hủy bỏ thực thi lệnh", -+ "expandOutput": "Mở rộng kết quả", -+ "collapseOutput": "Thu gọn kết quả", -+ "expandManagement": "Mở rộng phần quản lý lệnh", -+ "collapseManagement": "Thu gọn phần quản lý lệnh" -+ }, - "response": "Phản hồi", - "arguments": "Tham số", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json -index d98dbe6f051..280a2844055 100644 ---- a/webview-ui/src/i18n/locales/zh-CN/chat.json -+++ b/webview-ui/src/i18n/locales/zh-CN/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo已查看此目录中使用的源代码定义名称(工作区外):" - }, - "commandOutput": "命令输出", -+ "commandExecution": { -+ "running": "正在运行", -+ "pid": "PID: {{pid}}", -+ "exited": "已退出 ({{exitCode}})", -+ "manageCommands": "管理命令权限", -+ "commandManagementDescription": "管理命令权限:点击 ✓ 允许自动执行,点击 ✗ 拒绝执行。可以打开/关闭模式或从列表中删除。查看所有设置", -+ "addToAllowed": "添加到允许列表", -+ "removeFromAllowed": "从允许列表中删除", -+ "addToDenied": "添加到拒绝列表", -+ "removeFromDenied": "从拒绝列表中删除", -+ "abortCommand": "中止命令执行", -+ "expandOutput": "展开输出", -+ "collapseOutput": "折叠输出", -+ "expandManagement": "展开命令管理部分", -+ "collapseManagement": "折叠命令管理部分" -+ }, - "response": "响应", - "arguments": "参数", - "mcp": { -diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json -index e5dcd13a428..658acc5cd0c 100644 ---- a/webview-ui/src/i18n/locales/zh-TW/chat.json -+++ b/webview-ui/src/i18n/locales/zh-TW/chat.json -@@ -192,6 +192,22 @@ - "didViewDefinitionsOutsideWorkspace": "Roo 已檢視此目錄(工作區外)中使用的原始碼定義名稱:" - }, - "commandOutput": "命令輸出", -+ "commandExecution": { -+ "running": "正在執行", -+ "pid": "PID: {{pid}}", -+ "exited": "已退出 ({{exitCode}})", -+ "manageCommands": "管理命令權限", -+ "commandManagementDescription": "管理命令權限:點擊 ✓ 允許自動執行,點擊 ✗ 拒絕執行。可以開啟/關閉模式或從清單中刪除。檢視所有設定", -+ "addToAllowed": "新增至允許清單", -+ "removeFromAllowed": "從允許清單中移除", -+ "addToDenied": "新增至拒絕清單", -+ "removeFromDenied": "從拒絕清單中移除", -+ "abortCommand": "中止命令執行", -+ "expandOutput": "展開輸出", -+ "collapseOutput": "折疊輸出", -+ "expandManagement": "展開命令管理部分", -+ "collapseManagement": "折疊命令管理部分" -+ }, - "response": "回應", - "arguments": "參數", - "mcp": { -diff --git a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts -new file mode 100644 -index 00000000000..92feda0412f ---- /dev/null -+++ b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts -@@ -0,0 +1,501 @@ -+import { describe, it, expect } from "vitest" -+import { -+ extractCommandPatterns, -+ getPatternDescription, -+ parseCommandAndOutput, -+ detectSecurityIssues, -+} from "../commandPatterns" -+ -+describe("extractCommandPatterns", () => { -+ it("should extract simple command", () => { -+ const patterns = extractCommandPatterns("ls") -+ expect(patterns).toEqual(["ls"]) -+ }) -+ -+ it("should extract command with arguments", () => { -+ const patterns = extractCommandPatterns("npm install express") -+ expect(patterns).toEqual(["npm", "npm install", "npm install express"]) -+ }) -+ -+ it("should handle piped commands", () => { -+ const patterns = extractCommandPatterns("ls -la | grep test") -+ expect(patterns).toContain("ls") -+ expect(patterns).toContain("grep") -+ expect(patterns).toContain("grep test") -+ }) -+ -+ it("should handle chained commands with &&", () => { -+ const patterns = extractCommandPatterns("npm install && npm run build") -+ expect(patterns).toContain("npm") -+ expect(patterns).toContain("npm install") -+ expect(patterns).toContain("npm run") -+ expect(patterns).toContain("npm run build") -+ }) -+ -+ it("should handle chained commands with ||", () => { -+ const patterns = extractCommandPatterns("npm test || npm run test:ci") -+ expect(patterns).toContain("npm") -+ expect(patterns).toContain("npm test") -+ expect(patterns).toContain("npm run") -+ expect(patterns).toContain("npm run test:ci") -+ }) -+ -+ it("should handle semicolon separated commands", () => { -+ const patterns = extractCommandPatterns("cd src; npm install") -+ expect(patterns).toContain("cd") -+ expect(patterns).toContain("cd src") -+ expect(patterns).toContain("npm") -+ expect(patterns).toContain("npm install") -+ }) -+ -+ it("should stop at flags", () => { -+ const patterns = extractCommandPatterns('git commit -m "test message"') -+ expect(patterns).toContain("git") -+ expect(patterns).toContain("git commit") -+ expect(patterns).not.toContain("git commit -m") -+ }) -+ -+ it("should stop at paths with slashes", () => { -+ const patterns = extractCommandPatterns("cd /usr/local/bin") -+ expect(patterns).toContain("cd") -+ expect(patterns).not.toContain("cd /usr/local/bin") -+ }) -+ -+ it("should handle empty or null input", () => { -+ expect(extractCommandPatterns("")).toEqual([]) -+ expect(extractCommandPatterns(" ")).toEqual([]) -+ expect(extractCommandPatterns(null as any)).toEqual([]) -+ expect(extractCommandPatterns(undefined as any)).toEqual([]) -+ }) -+ -+ it("should handle complex command with multiple operators", () => { -+ const patterns = extractCommandPatterns('npm install && npm test | grep success || echo "failed"') -+ expect(patterns).toContain("npm") -+ expect(patterns).toContain("npm install") -+ expect(patterns).toContain("npm test") -+ expect(patterns).toContain("grep") -+ expect(patterns).toContain("grep success") -+ expect(patterns).toContain("echo") -+ }) -+ -+ it("should handle malformed commands gracefully", () => { -+ const patterns = extractCommandPatterns("npm install && ") -+ expect(patterns).toContain("npm") -+ expect(patterns).toContain("npm install") -+ }) -+ -+ it("should extract main command even if parsing fails", () => { -+ // Create a command that might cause parsing issues -+ const patterns = extractCommandPatterns('echo "unclosed quote') -+ expect(patterns).toContain("echo") -+ }) -+ -+ it("should handle commands with special characters in arguments", () => { -+ const patterns = extractCommandPatterns("git add .") -+ expect(patterns).toContain("git") -+ expect(patterns).toContain("git add") -+ expect(patterns).not.toContain("git add .") -+ }) -+ -+ it("should return sorted patterns", () => { -+ const patterns = extractCommandPatterns("npm run build && git push") -+ expect(patterns).toEqual([...patterns].sort()) -+ }) -+ -+ it("should handle numeric input like '0 total'", () => { -+ const patterns = extractCommandPatterns("0 total") -+ // Should return empty array since "0" is not a valid command -+ expect(patterns).toEqual([]) -+ }) -+ -+ it("should handle pure numeric commands", () => { -+ const patterns = extractCommandPatterns("0") -+ // Should return empty array since pure numbers are not valid commands -+ expect(patterns).toEqual([]) -+ }) -+}) -+ -+describe("getPatternDescription", () => { -+ it("should return pattern followed by commands", () => { -+ expect(getPatternDescription("cd")).toBe("cd commands") -+ expect(getPatternDescription("npm")).toBe("npm commands") -+ expect(getPatternDescription("npm install")).toBe("npm install commands") -+ expect(getPatternDescription("git")).toBe("git commands") -+ expect(getPatternDescription("git push")).toBe("git push commands") -+ expect(getPatternDescription("python")).toBe("python commands") -+ }) -+ -+ it("should handle any command pattern", () => { -+ expect(getPatternDescription("unknowncommand")).toBe("unknowncommand commands") -+ expect(getPatternDescription("custom-tool")).toBe("custom-tool commands") -+ }) -+ -+ it("should handle package managers", () => { -+ expect(getPatternDescription("yarn")).toBe("yarn commands") -+ expect(getPatternDescription("pnpm")).toBe("pnpm commands") -+ expect(getPatternDescription("bun")).toBe("bun commands") -+ }) -+ -+ it("should handle build tools", () => { -+ expect(getPatternDescription("make")).toBe("make commands") -+ expect(getPatternDescription("cmake")).toBe("cmake commands") -+ expect(getPatternDescription("cargo")).toBe("cargo commands") -+ expect(getPatternDescription("go build")).toBe("go build commands") -+ }) -+}) -+ -+describe("parseCommandAndOutput", () => { -+ it("should handle command with $ prefix without Output: separator", () => { -+ const text = "$ npm install\nInstalling packages..." -+ const result = parseCommandAndOutput(text) -+ // Without Output: separator, the entire text is treated as command -+ expect(result.command).toBe("$ npm install\nInstalling packages...") -+ expect(result.output).toBe("") -+ }) -+ -+ it("should handle command with ❯ prefix without Output: separator", () => { -+ const text = "❯ git status\nOn branch main" -+ const result = parseCommandAndOutput(text) -+ // Without Output: separator, the entire text is treated as command -+ expect(result.command).toBe("❯ git status\nOn branch main") -+ expect(result.output).toBe("") -+ }) -+ -+ it("should handle command with > prefix without Output: separator", () => { -+ const text = "> echo hello\nhello" -+ const result = parseCommandAndOutput(text) -+ // Without Output: separator, the entire text is treated as command -+ expect(result.command).toBe("> echo hello\nhello") -+ expect(result.output).toBe("") -+ }) -+ -+ it("should return original text if no command prefix found", () => { -+ const text = "npm install" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("npm install") -+ expect(result.output).toBe("") -+ }) -+ -+ it("should extract AI suggestions from output with Output: separator", () => { -+ const text = "npm install\nOutput:\nSuggested patterns: npm, npm install, npm run" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("npm install") -+ expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) -+ }) -+ -+ it("should extract suggestions with different formats", () => { -+ const text = "git push\nOutput:\nCommand patterns: git, git push" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("git push") -+ expect(result.suggestions).toEqual(["git", "git push"]) -+ }) -+ -+ it('should extract suggestions from "you can allow" format', () => { -+ const text = "docker run\nOutput:\nYou can allow: docker, docker run" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("docker run") -+ expect(result.suggestions).toEqual(["docker", "docker run"]) -+ }) -+ -+ it("should extract suggestions from bullet points", () => { -+ const text = `npm test -+Output: -+Output here... -+- npm -+- npm test -+- npm run` -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("npm test") -+ expect(result.suggestions).toContain("npm") -+ expect(result.suggestions).toContain("npm test") -+ expect(result.suggestions).toContain("npm run") -+ }) -+ -+ it("should extract suggestions from various bullet formats", () => { -+ const text = `command -+Output: -+• npm -+* git -+- docker -+▪ python` -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("command") -+ expect(result.suggestions).toContain("npm") -+ expect(result.suggestions).toContain("git") -+ expect(result.suggestions).toContain("docker") -+ expect(result.suggestions).toContain("python") -+ }) -+ -+ it("should extract suggestions with backticks", () => { -+ const text = "npm install\nOutput:\n- `npm`\n- `npm install`" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("npm install") -+ expect(result.suggestions).toContain("npm") -+ expect(result.suggestions).toContain("npm install") -+ }) -+ -+ it("should handle empty text", () => { -+ const result = parseCommandAndOutput("") -+ expect(result.command).toBe("") -+ expect(result.output).toBe("") -+ expect(result.suggestions).toEqual([]) -+ }) -+ -+ it("should handle multiline commands without Output: separator", () => { -+ const text = `$ npm install \\ -+ express \\ -+ mongoose -+Installing...` -+ const result = parseCommandAndOutput(text) -+ // Without Output: separator, entire text is treated as command -+ expect(result.command).toBe(text) -+ expect(result.output).toBe("") -+ }) -+ -+ it("should include all suggestions from comma-separated list with Output: separator", () => { -+ const text = "test\nOutput:\nSuggested patterns: npm, npm install, npm run" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("test") -+ expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) -+ }) -+ -+ it("should handle case variations in suggestion patterns", () => { -+ const text = "test\nOutput:\nSuggested Patterns: npm, git\nCommand Patterns: docker" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("test") -+ // Now it should accumulate all suggestions -+ expect(result.suggestions).toContain("npm") -+ expect(result.suggestions).toContain("git") -+ expect(result.suggestions).toContain("docker") -+ }) -+ -+ it("should handle text already split by Output:", () => { -+ const text = "npm install && cd backend\nOutput:\ngithub-pr-contributors-tracker@1.0.0 prepare" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("npm install && cd backend") -+ expect(result.output).toBe("github-pr-contributors-tracker@1.0.0 prepare") -+ }) -+ -+ it("should preserve original command when Output: separator is present", () => { -+ const text = "npm install\nOutput:\n$ npm install\nInstalling packages..." -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("npm install") -+ expect(result.output).toBe("$ npm install\nInstalling packages...") -+ }) -+ -+ it("should handle Output: separator with no output", () => { -+ const text = "ls -la\nOutput:" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("ls -la") -+ expect(result.output).toBe("") -+ }) -+ -+ it("should handle Output: separator with whitespace", () => { -+ const text = "git status\nOutput: \n On branch main " -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("git status") -+ expect(result.output).toBe("On branch main") -+ }) -+ -+ it("should only use first Output: occurrence as separator", () => { -+ const text = 'echo "test"\nOutput:\nFirst output\nOutput: Second output' -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe('echo "test"') -+ expect(result.output).toBe("First output\nOutput: Second output") -+ }) -+ -+ it("should handle output with numbers at the start of lines", () => { -+ const text = `wc -l *.go *.java -+Output: -+25 hello_world.go -+316 HelloWorld.java -+341 total` -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("wc -l *.go *.java") -+ expect(result.output).toBe("25 hello_world.go\n316 HelloWorld.java\n341 total") -+ expect(result.suggestions).toEqual([]) -+ }) -+ -+ it("should handle edge case where text starts with Output:", () => { -+ const text = "Output:\nSome output without a command" -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("") -+ expect(result.output).toBe("Some output without a command") -+ }) -+ -+ it("should not be confused by Output: appearing in the middle of output", () => { -+ const text = `echo "Output: test" -+Output: -+Output: test` -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe('echo "Output: test"') -+ expect(result.output).toBe("Output: test") -+ }) -+ -+ it("should handle commands without shell prompt when Output: separator is present", () => { -+ const text = `npm install -+Output: -+Installing packages...` -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("npm install") -+ expect(result.output).toBe("Installing packages...") -+ }) -+ -+ it("should not parse shell prompts from output when Output: separator exists", () => { -+ const text = `ls -la -+Output: -+$ total 341 -+drwxr-xr-x 10 user staff 320 Jan 22 12:00 . -+drwxr-xr-x 20 user staff 640 Jan 22 11:00 ..` -+ const result = parseCommandAndOutput(text) -+ expect(result.command).toBe("ls -la") -+ expect(result.output).toContain("$ total 341") -+ expect(result.output).toContain("drwxr-xr-x") -+ }) -+}) -+ -+describe("detectSecurityIssues", () => { -+ it("should detect subshell execution with $()", () => { -+ const warnings = detectSecurityIssues("echo $(malicious)") -+ expect(warnings).toHaveLength(1) -+ expect(warnings[0].type).toBe("subshell") -+ expect(warnings[0].message).toContain("subshell execution") -+ }) -+ -+ it("should detect subshell execution with backticks", () => { -+ const warnings = detectSecurityIssues("echo `malicious`") -+ expect(warnings).toHaveLength(1) -+ expect(warnings[0].type).toBe("subshell") -+ expect(warnings[0].message).toContain("subshell execution") -+ }) -+ -+ it("should detect nested subshells", () => { -+ const warnings = detectSecurityIssues("echo $(echo $(date))") -+ expect(warnings).toHaveLength(1) -+ expect(warnings[0].type).toBe("subshell") -+ }) -+ -+ it("should detect subshells in complex commands", () => { -+ const warnings = detectSecurityIssues("npm install && echo $(whoami) || git push") -+ expect(warnings).toHaveLength(1) -+ expect(warnings[0].type).toBe("subshell") -+ }) -+ -+ it("should not detect issues in safe commands", () => { -+ const warnings = detectSecurityIssues("npm install express") -+ expect(warnings).toHaveLength(0) -+ }) -+ -+ it("should handle empty commands", () => { -+ const warnings = detectSecurityIssues("") -+ expect(warnings).toHaveLength(0) -+ }) -+ -+ it("should detect multiple subshell patterns", () => { -+ const warnings = detectSecurityIssues("echo $(date) && echo `whoami`") -+ expect(warnings).toHaveLength(1) // Should still be 1 warning for subshell presence -+ expect(warnings[0].type).toBe("subshell") -+ }) -+ -+ it("should detect subshells in quoted strings", () => { -+ const warnings = detectSecurityIssues('echo "Current user: $(whoami)"') -+ expect(warnings).toHaveLength(1) -+ expect(warnings[0].type).toBe("subshell") -+ }) -+}) -+ -+describe("security integration with extractCommandPatterns", () => { -+ it("should not include subshell content in patterns", () => { -+ const patterns = extractCommandPatterns("echo $(malicious)") -+ expect(patterns).toContain("echo") -+ expect(patterns).not.toContain("$(malicious)") -+ expect(patterns).not.toContain("malicious") -+ }) -+ -+ it("should handle commands with subshells properly", () => { -+ const patterns = extractCommandPatterns("npm install && echo $(whoami)") -+ expect(patterns).toContain("npm") -+ expect(patterns).toContain("npm install") -+ expect(patterns).toContain("echo") -+ expect(patterns).not.toContain("whoami") -+ }) -+ -+ it("should extract patterns from commands with backtick subshells", () => { -+ const patterns = extractCommandPatterns("git commit -m `date`") -+ expect(patterns).toContain("git") -+ expect(patterns).toContain("git commit") -+ expect(patterns).not.toContain("date") -+ }) -+}) -+ -+describe("integration: parseCommandAndOutput with extractCommandPatterns", () => { -+ it("should not extract patterns from output text", () => { -+ const text = `wc -l *.go *.java -+Output: -+wc: *.go: open: No such file or directory -+wc: *.java: open: No such file or directory -+0 total` -+ const { command } = parseCommandAndOutput(text) -+ const patterns = extractCommandPatterns(command) -+ -+ // Should only extract patterns from the command, not the output -+ expect(patterns).toContain("wc") -+ expect(patterns).not.toContain("0") -+ expect(patterns).not.toContain("total") -+ expect(patterns).not.toContain("0 total") -+ }) -+ -+ it("should handle the specific wc command case", () => { -+ const text = `wc -l *.go *.java -+Output: -+25 hello_world.go -+316 HelloWorld.java -+341 total` -+ const { command } = parseCommandAndOutput(text) -+ const patterns = extractCommandPatterns(command) -+ -+ // Should only extract "wc" from the command -+ expect(patterns).toEqual(["wc"]) -+ expect(patterns).not.toContain("341") -+ expect(patterns).not.toContain("total") -+ expect(patterns).not.toContain("341 total") -+ }) -+ -+ it("should handle wc command with error output", () => { -+ const text = `wc -l *.go *.java -+Output: -+wc: *.go: open: No such file or directory -+wc: *.java: open: No such file or directory -+0 total` -+ const { command, output } = parseCommandAndOutput(text) -+ const patterns = extractCommandPatterns(command) -+ -+ // Should only extract "wc" from the command -+ expect(command).toBe("wc -l *.go *.java") -+ expect(output).toContain("0 total") -+ expect(patterns).toEqual(["wc"]) -+ expect(patterns).not.toContain("0") -+ expect(patterns).not.toContain("total") -+ expect(patterns).not.toContain("0 total") -+ }) -+ -+ it("should handle case where only output line is provided", () => { -+ // This simulates if somehow only "0 total" is passed as the text -+ const text = "0 total" -+ const { command } = parseCommandAndOutput(text) -+ const patterns = extractCommandPatterns(command) -+ -+ // In this case, the entire text is treated as command -+ expect(command).toBe("0 total") -+ // But "0 total" is not a valid command pattern (starts with number) -+ expect(patterns).toEqual([]) -+ }) -+ -+ it("should handle commands without output separator", () => { -+ const text = "npm install" -+ const { command } = parseCommandAndOutput(text) -+ const patterns = extractCommandPatterns(command) -+ -+ expect(patterns).toEqual(["npm", "npm install"]) -+ }) -+}) -diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts -new file mode 100644 -index 00000000000..0265ad61bb5 ---- /dev/null -+++ b/webview-ui/src/utils/commandPatterns.ts -@@ -0,0 +1,177 @@ -+import { parse } from "shell-quote" -+ -+export interface CommandPattern { -+ pattern: string -+ description?: string -+} -+ -+export interface SecurityWarning { -+ type: "subshell" | "injection" -+ message: string -+} -+ -+function processCommand(cmd: string[], patterns: Set): void { -+ if (!cmd.length || typeof cmd[0] !== "string") return -+ -+ const mainCmd = cmd[0] -+ -+ // Skip if it's just a number (like "0" from "0 total") -+ if (/^\d+$/.test(mainCmd)) return -+ -+ // Skip common output patterns that aren't commands -+ const skipWords = ["total", "error", "warning", "failed", "success", "done"] -+ if (skipWords.includes(mainCmd.toLowerCase())) return -+ -+ patterns.add(mainCmd) -+ -+ const breakingExps = [/^-/, /[\\/.~]/] -+ -+ for (let i = 1; i < cmd.length; i++) { -+ const arg = cmd[i] -+ -+ if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break -+ -+ const pattern = cmd.slice(0, i + 1).join(" ") -+ patterns.add(pattern) -+ } -+} -+ -+function extractPatterns(cmdStr: string): Set { -+ const patterns = new Set() -+ -+ const parsed = parse(cmdStr) -+ -+ const commandSeparators = new Set(["|", "&&", "||", ";"]) -+ let current: string[] = [] -+ for (const token of parsed) { -+ if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { -+ if (current.length) processCommand(current, patterns) -+ current = [] -+ } else { -+ current.push(String(token)) -+ } -+ } -+ -+ if (current.length) processCommand(current, patterns) -+ -+ return patterns -+} -+ -+export function extractCommandPatterns(command: string): string[] { -+ if (!command?.trim()) return [] -+ -+ // First, check if the command contains subshells and remove them -+ // This is important for security - we don't want to extract patterns from subshell contents -+ const cleanedCommand = command -+ .replace(/\$\([^)]*\)/g, "") // Remove $() subshells -+ .replace(/`[^`]*`/g, "") // Remove backtick subshells -+ -+ const patterns = extractPatterns(cleanedCommand) -+ -+ return Array.from(patterns).sort() -+} -+ -+export function detectSecurityIssues(command: string): SecurityWarning[] { -+ const warnings: SecurityWarning[] = [] -+ -+ // Check for subshell execution attempts -+ if (command.includes("$(") || command.includes("`")) { -+ warnings.push({ -+ type: "subshell", -+ message: "Command contains subshell execution which could bypass restrictions", -+ }) -+ } -+ -+ return warnings -+} -+ -+/** -+ * Get a human-readable description for a command pattern. -+ * Simply returns the pattern followed by "commands". -+ */ -+export function getPatternDescription(pattern: string): string { -+ return `${pattern} commands` -+} -+ -+export function parseCommandAndOutput(text: string): { -+ command: string -+ output: string -+ suggestions: string[] -+} { -+ // Default result -+ const result = { -+ command: text, -+ output: "", -+ suggestions: [] as string[], -+ } -+ -+ // First check if the text already has been split by COMMAND_OUTPUT_STRING -+ // This happens when the command has already been executed and we have the output -+ const outputSeparator = "Output:" -+ const outputIndex = text.indexOf(`\n${outputSeparator}`) -+ -+ if (outputIndex !== -1) { -+ // Text is already split into command and output -+ // The command is everything before the output separator -+ result.command = text.slice(0, outputIndex).trim() -+ // The output is everything after the output separator -+ // We need to skip the newline and "Output:" text -+ const afterNewline = outputIndex + 1 // Skip the newline -+ const afterSeparator = afterNewline + outputSeparator.length // Skip "Output:" -+ // Check if there's a colon and potential space after it -+ let startOfOutput = afterSeparator -+ if (text[afterSeparator] === "\n") { -+ startOfOutput = afterSeparator + 1 // Skip additional newline after "Output:" -+ } -+ result.output = text.slice(startOfOutput).trim() -+ } else if (text.indexOf(outputSeparator) === 0) { -+ // Edge case: text starts with "Output:" (no command) -+ result.command = "" -+ result.output = text.slice(outputSeparator.length).trim() -+ } else { -+ // No output separator found, the entire text is the command -+ result.command = text.trim() -+ result.output = "" -+ } -+ -+ // Look for AI suggestions in the output -+ // These might be in a format like: -+ // "Suggested patterns: npm, npm install, npm run" -+ // or as a list -+ const suggestionPatterns = [ -+ /Suggested patterns?:\s*(.+?)(?:\n|$)/i, -+ /Command patterns?:\s*(.+?)(?:\n|$)/i, -+ /You (?:can|may|might) (?:want to )?(?:allow|add):\s*(.+?)(?:\n|$)/i, -+ ] -+ -+ for (const pattern of suggestionPatterns) { -+ const match = result.output.match(pattern) -+ if (match) { -+ // Split by common delimiters and clean up -+ const suggestions = match[1] -+ .split(/[,;]/) -+ .map((s) => s.trim()) -+ .filter((s) => s) // Allow multi-word patterns like "npm install" -+ -+ if (suggestions.length > 0) { -+ // Add to existing suggestions instead of replacing -+ result.suggestions.push(...suggestions) -+ } -+ } -+ } -+ -+ // Remove duplicates -+ result.suggestions = Array.from(new Set(result.suggestions)) -+ -+ // Also look for bullet points or numbered lists -+ // const listPattern = /^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/gm -+ const lines = result.output.split("\n") -+ for (const line of lines) { -+ const match = line.match(/^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/) -+ if (match && match[1] && !result.suggestions.includes(match[1])) { -+ result.suggestions.push(match[1]) -+ } -+ } -+ -+ return result -+} diff --git a/.roo/temp/pr-5798/review-context.json b/.roo/temp/pr-5798/review-context.json deleted file mode 100644 index e728ab7c1fc..00000000000 --- a/.roo/temp/pr-5798/review-context.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "prNumber": "5798", - "repository": "RooCodeInc/Roo-Code", - "reviewStartTime": "2025-01-23T17:13:13.085Z", - "calledByMode": null, - "prMetadata": { - "title": "feat: Add terminal command permissions UI to chat interface (#5480)", - "author": "hannesrudolph", - "state": "OPEN", - "baseRefName": "main", - "headRefName": "feat/issue-5480-command-permissions-ui", - "additions": 2015, - "deletions": 24, - "changedFiles": 24 - }, - "linkedIssue": { - "number": 5480 - }, - "existingComments": [], - "existingReviews": [], - "filesChanged": [ - "webview-ui/src/components/chat/CommandExecution.tsx", - "webview-ui/src/components/chat/CommandPatternSelector.tsx", - "webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx", - "webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx", - "webview-ui/src/i18n/locales/ca/chat.json", - "webview-ui/src/i18n/locales/de/chat.json", - "webview-ui/src/i18n/locales/en/chat.json", - "webview-ui/src/i18n/locales/es/chat.json", - "webview-ui/src/i18n/locales/fr/chat.json", - "webview-ui/src/i18n/locales/hi/chat.json", - "webview-ui/src/i18n/locales/id/chat.json", - "webview-ui/src/i18n/locales/it/chat.json", - "webview-ui/src/i18n/locales/ja/chat.json", - "webview-ui/src/i18n/locales/ko/chat.json", - "webview-ui/src/i18n/locales/nl/chat.json", - "webview-ui/src/i18n/locales/pl/chat.json", - "webview-ui/src/i18n/locales/pt-BR/chat.json", - "webview-ui/src/i18n/locales/ru/chat.json", - "webview-ui/src/i18n/locales/tr/chat.json", - "webview-ui/src/i18n/locales/vi/chat.json", - "webview-ui/src/i18n/locales/zh-CN/chat.json", - "webview-ui/src/i18n/locales/zh-TW/chat.json", - "webview-ui/src/utils/__tests__/commandPatterns.spec.ts", - "webview-ui/src/utils/commandPatterns.ts" - ], - "delegatedTasks": [], - "findings": { - "critical": [], - "patterns": [], - "redundancy": [], - "architecture": [], - "tests": [] - }, - "reviewStatus": "analyzing" -} diff --git a/.roo/temp/pr-5798/reviews.json b/.roo/temp/pr-5798/reviews.json deleted file mode 100644 index d2ea6b787dd..00000000000 --- a/.roo/temp/pr-5798/reviews.json +++ /dev/null @@ -1,79 +0,0 @@ -[ - { - "author": { "login": "copilot-pull-request-reviewer" }, - "authorAssociation": "NONE", - "body": "## Pull Request Overview\n\nThis PR adds an interactive terminal command permissions UI to the chat interface, allowing users to view, allow, or deny specific command patterns directly from the chat.\n\n- Introduces `commandPatterns.ts` for extracting command patterns, generating descriptions, and parsing command/output text.\n- Adds a `CommandPatternSelector` component and integrates it into `CommandExecution` to toggle allowed/denied patterns with state synchronization.\n- Updates translation JSON files across all locales to include new `commandExecution` keys.\n\n### Reviewed Changes\n\nCopilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.\n\n\u003cdetails\u003e\n\u003csummary\u003eShow a summary per file\u003c/summary\u003e\n\n| File | Description |\r\n| ------------------------------------------------- | -------------------------------------------------------------------------------------------- |\r\n| webview-ui/src/utils/commandPatterns.ts | Adds utilities for command pattern extraction, description lookup, and parsing command/output |\r\n| webview-ui/src/utils/__tests__/commandPatterns.spec.ts | Adds unit tests covering pattern extraction, descriptions, and parsing logic |\r\n| webview-ui/src/components/chat/CommandPatternSelector.tsx | Implements the UI component for toggling command permission patterns |\r\n| webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx | Adds interaction and accessibility tests for `CommandPatternSelector` |\r\n| webview-ui/src/components/chat/CommandExecution.tsx | Integrates the selector into command blocks and syncs state with the extension |\r\n| webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx | Adds tests for command execution rendering and permission changes |\r\n| webview-ui/src/i18n/locales/*/chat.json | Updates all locale files with new translation keys for `commandExecution` UI |\n\u003c/details\u003e\n\n\n\n\u003cdetails\u003e\n\u003csummary\u003eComments suppressed due to low confidence (3)\u003c/summary\u003e\n\n**webview-ui/src/components/chat/CommandExecution.tsx:61**\n* [nitpick] The state variable `showSuggestions` never changes. Consider renaming it to reflect that it’s a constant flag or removing the useState hook entirely if it isn’t meant to update.\n```\n\tconst [showSuggestions] = useState(true)\n```\n**webview-ui/src/components/chat/CommandExecution.tsx:48**\n* The fallback path where `enhanced.command === text` isn’t covered by any tests. Add a unit test to verify the fallback parser branch behaves as expected.\n```\n\t\tif (enhanced.command \u0026\u0026 enhanced.command !== text) {\n```\n**webview-ui/src/components/chat/CommandExecution.tsx:52**\n* parseCommandAndOutput is not imported in this file, so the fallback call will be undefined. Either import it properly or replace this call with parseCommandAndOutputUtil.\n```\n\t\tconst original = parseCommandAndOutput(text)\n```\n\u003c/details\u003e\n\n", - "commit": { "oid": "c4a9670e9733ab32fb5d17c1036a9cd649770233" }, - "id": "PRR_kwDONIq5lM60cWiN", - "includesCreatedEdit": false, - "reactionGroups": [], - "state": "COMMENTED", - "submittedAt": "2025-07-17T00:30:45Z" - }, - { - "author": { "login": "ellipsis-dev" }, - "authorAssociation": "NONE", - "body": "", - "commit": { "oid": "c4a9670e9733ab32fb5d17c1036a9cd649770233" }, - "id": "PRR_kwDONIq5lM60cWrH", - "includesCreatedEdit": false, - "reactionGroups": [], - "state": "COMMENTED", - "submittedAt": "2025-07-17T00:31:06Z" - }, - { - "author": { "login": "copilot-pull-request-reviewer" }, - "authorAssociation": "NONE", - "body": "## Pull Request Overview\n\nThis PR adds a command permissions UI to the chat interface that allows users to manage terminal command permissions directly when viewing command execution results. The implementation includes pattern extraction capabilities for complex shell commands and integrates seamlessly with the existing VSCode extension state.\n\n- Enhanced command execution interface with collapsible permission management section\n- Pattern extraction utility that handles complex shell syntax including pipes, chains, and subshells\n- Comprehensive translation support for 17 languages\n\n### Reviewed Changes\n\nCopilot reviewed 24 out of 24 changed files in this pull request and generated 4 comments.\n\n\u003cdetails\u003e\n\u003csummary\u003eShow a summary per file\u003c/summary\u003e\n\n| File | Description |\r\n| ---- | ----------- |\r\n| `webview-ui/src/utils/commandPatterns.ts` | Core pattern extraction logic with shell parsing and security validation |\r\n| `webview-ui/src/components/chat/CommandPatternSelector.tsx` | UI component for managing command permissions with toggle buttons |\r\n| `webview-ui/src/components/chat/CommandExecution.tsx` | Integration of pattern selector into existing command execution component |\r\n| Translation files | Added command execution UI strings across 17 supported languages |\r\n| Test files | Comprehensive test coverage for pattern extraction and UI components |\n\u003c/details\u003e\n\n\n\n\u003cdetails\u003e\n\u003csummary\u003eComments suppressed due to low confidence (1)\u003c/summary\u003e\n\n**webview-ui/src/utils/commandPatterns.ts:54**\n* [nitpick] The variable name 'breakingExps' is ambiguous. Consider renaming to 'stopPatterns' or 'terminatingPatterns' to better describe its purpose.\n```\n\t// Patterns that indicate we should stop looking for subcommands\n```\n\u003c/details\u003e\n\n", - "commit": { "oid": "6463e8f7c70df56cff3f25e836b084c895b6312f" }, - "id": "PRR_kwDONIq5lM60o8Sj", - "includesCreatedEdit": false, - "reactionGroups": [], - "state": "COMMENTED", - "submittedAt": "2025-07-17T18:47:03Z" - }, - { - "author": { "login": "daniel-lxs" }, - "authorAssociation": "COLLABORATOR", - "body": "@roomote-agent Use the switch_mode tool to switch to pr-fixer and address the reviews on this PR, commit the changes to this PR, do not create a new one", - "commit": { "oid": "94efb1a123b6f89e1ff9c6be25a3dc547b6c4ce9" }, - "id": "PRR_kwDONIq5lM61IWGu", - "includesCreatedEdit": false, - "reactionGroups": [], - "state": "COMMENTED", - "submittedAt": "2025-07-21T16:37:50Z" - }, - { - "author": { "login": "ellipsis-dev" }, - "authorAssociation": "NONE", - "body": "", - "commit": { "oid": "586cc157daa49059e6880384343cd01167f8855d" }, - "id": "PRR_kwDONIq5lM61ge8E", - "includesCreatedEdit": false, - "reactionGroups": [], - "state": "COMMENTED", - "submittedAt": "2025-07-23T00:07:42Z" - }, - { - "author": { "login": "daniel-lxs" }, - "authorAssociation": "COLLABORATOR", - "body": "I am seeing this on certain commands \n\n\u003cimg width=\"425\" height=\"408\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/47c39e19-f410-46e7-b889-e7dcafdac3ce\" /\u003e\n\nNot sure if this is intended but I imagine for longer commands this list might become too large", - "commit": { "oid": "586cc157daa49059e6880384343cd01167f8855d" }, - "id": "PRR_kwDONIq5lM61p9QB", - "includesCreatedEdit": false, - "reactionGroups": [], - "state": "COMMENTED", - "submittedAt": "2025-07-23T14:28:42Z" - }, - { - "author": { "login": "ellipsis-dev" }, - "authorAssociation": "NONE", - "body": "", - "commit": { "oid": "b358c958bff8d817e4f848bc1683a2adff45c283" }, - "id": "PRR_kwDONIq5lM61qdnL", - "includesCreatedEdit": false, - "reactionGroups": [], - "state": "COMMENTED", - "submittedAt": "2025-07-23T14:49:38Z" - } -] diff --git a/.roo/temp/pr-5798/test-analysis.md b/.roo/temp/pr-5798/test-analysis.md deleted file mode 100644 index 83899593d58..00000000000 --- a/.roo/temp/pr-5798/test-analysis.md +++ /dev/null @@ -1,166 +0,0 @@ -## Test Analysis for PR #5798 - -### Test Organization - -#### File Location and Structure - -The test files are properly organized following the project's conventions: - -- **Component tests**: Located in `webview-ui/src/components/chat/__tests__/` alongside the components they test -- **Utility tests**: Located in `webview-ui/src/utils/__tests__/` alongside the utility modules -- **Naming convention**: All test files use the `.spec.ts` or `.spec.tsx` extension, consistent with the project standard - -#### Test File Sizes - -- `CommandExecution.spec.tsx`: 591 lines - This is quite large and could benefit from splitting into smaller, more focused test files -- `CommandPatternSelector.spec.tsx`: 252 lines - Reasonable size for a component test -- `commandPatterns.spec.ts`: 501 lines - Large but acceptable given the complexity of the utility being tested - -### Coverage Assessment - -#### CommandExecution.spec.tsx - -**Strengths:** - -- Comprehensive coverage of command parsing scenarios -- Tests for edge cases like empty commands, malformed input, and special characters -- Good coverage of pattern extraction and security features -- Tests integration with CommandPatternSelector component -- Covers state management and event handling - -**Areas for Improvement:** - -- Missing tests for error boundaries and error states -- Could add more tests for accessibility features -- No performance-related tests (e.g., handling very long commands) - -#### CommandPatternSelector.spec.tsx - -**Strengths:** - -- Tests all major UI interactions (expand/collapse, button clicks) -- Covers tooltip and internationalization features -- Tests state management for allowed/denied commands -- Good coverage of edge cases (empty patterns, duplicate prevention) - -**Areas for Improvement:** - -- Missing tests for keyboard navigation -- No tests for focus management -- Could add tests for screen reader announcements - -#### commandPatterns.spec.ts - -**Strengths:** - -- Excellent coverage of command parsing logic -- Comprehensive tests for pattern extraction -- Good coverage of security features (subshell detection) -- Tests for various command formats and edge cases -- Integration tests between different utility functions - -**Gaps:** - -- No tests for performance with extremely long or complex commands -- Missing tests for Unicode and special character handling in commands - -### Pattern Consistency - -#### Testing Framework Usage - -All test files consistently use: - -- Vitest as the testing framework (`describe`, `it`, `expect`, `vi`) -- React Testing Library for component tests (`render`, `screen`, `fireEvent`) -- Proper setup and teardown with `beforeEach` and `vi.clearAllMocks()` - -#### Mock Patterns - -The tests follow consistent mocking patterns: - -```typescript -// Component mocks -vi.mock("../../../utils/vscode", () => ({ - vscode: { - postMessage: vi.fn(), - }, -})) - -// Module mocks with actual implementation -vi.mock("../../../utils/commandPatterns", async () => { - const actual = await vi.importActual( - "../../../utils/commandPatterns", - ) - return { - ...actual, - // specific overrides - } -}) -``` - -#### Test Structure - -Tests follow a consistent structure: - -1. Arrange - Set up test data and mocks -2. Act - Perform the action being tested -3. Assert - Verify the expected outcome - -### Comparison with Existing Tests - -#### Alignment with Project Standards - -Comparing with existing tests like `HistoryView.spec.tsx` and `SettingsView.spec.tsx`: - -**Consistent Patterns:** - -- Use of `data-testid` for element selection -- Mock setup at the top of test files -- Context provider wrappers for components that need them -- Clear test descriptions using BDD-style language - -**Deviations:** - -- The new tests use more inline mock components, while existing tests tend to use more complete mock implementations -- Some existing tests use `@/utils/test-utils` for rendering, while the new tests import directly from `@testing-library/react` - -### Recommendations - -#### 1. Test File Organization - -- Consider splitting `CommandExecution.spec.tsx` into smaller files: - - `CommandExecution.rendering.spec.tsx` - UI rendering tests - - `CommandExecution.patterns.spec.tsx` - Pattern extraction tests - - `CommandExecution.integration.spec.tsx` - Integration with other components - -#### 2. Test Naming Conventions - -- Standardize test descriptions to follow the pattern: "should [expected behavior] when [condition]" -- Group related tests using nested `describe` blocks more consistently - -#### 3. Mock Improvements - -- Create shared mock utilities for commonly mocked modules (vscode, i18n) -- Use mock factories to reduce duplication across test files - -#### 4. Coverage Enhancements - -- Add tests for error states and error boundaries -- Include accessibility tests using `@testing-library/jest-dom` matchers -- Add performance tests for handling large inputs -- Test keyboard navigation and focus management - -#### 5. Test Data Management - -- Extract test data into separate fixtures or factories -- Create builders for complex test objects to improve maintainability - -#### 6. Integration with CI/CD - -- Ensure these tests are included in the test coverage reports -- Add performance benchmarks for critical paths -- Consider adding visual regression tests for UI components - -### Conclusion - -The test files in PR #5798 demonstrate good testing practices with comprehensive coverage of the new command pattern functionality. While there are areas for improvement, particularly around test organization and accessibility testing, the tests provide solid coverage of the core functionality and edge cases. The patterns used are largely consistent with the existing codebase, making the tests maintainable and easy to understand. From 17dbda3d4da505df93380d7fbd42cf0b5835e10b Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 23 Jul 2025 12:21:02 -0600 Subject: [PATCH 12/31] fix: update command parser to handle all edge cases from original implementation - Add support for all line ending types (\r\n, \r, \n) - Handle simple variable references (, ) to prevent shell-quote from splitting them - Handle special bash variables (0, , etc.) - Maintain compatibility with all existing tests - Fix linting warnings --- webview-ui/src/utils/command-parser.ts | 244 ++++++++++++++++--------- 1 file changed, 159 insertions(+), 85 deletions(-) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index 1384fff3d3c..a3fb6d92e24 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -27,97 +27,171 @@ export function parseCommandString(command: string): { } } - // Storage for replaced content - const redirections: string[] = [] - const subshells: string[] = [] - const quotes: string[] = [] - const arrayIndexing: string[] = [] - - // First handle PowerShell redirections by temporarily replacing them - let processedCommand = command.replace(/\d*>&\d*/g, (match) => { - redirections.push(match) - return `__REDIR_${redirections.length - 1}__` - }) - - // Handle array indexing expressions: ${array[...]} pattern and partial expressions - processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => { - arrayIndexing.push(match) - return `__ARRAY_${arrayIndexing.length - 1}__` - }) - - // Then handle subshell commands - store them for security analysis - const hasSubshells = command.includes("$(") || command.includes("`") - - processedCommand = processedCommand - .replace(/\$\((.*?)\)/g, (_, inner) => { - const trimmedInner = inner.trim() - subshells.push(trimmedInner) - return `__SUBSH_${subshells.length - 1}__` - }) - .replace(/`(.*?)`/g, (_, inner) => { - const trimmedInner = inner.trim() - subshells.push(trimmedInner) - return `__SUBSH_${subshells.length - 1}__` - }) - - // Then handle quoted strings - processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { - quotes.push(match) - return `__QUOTE_${quotes.length - 1}__` - }) - - const tokens = parse(processedCommand) as ShellToken[] - const commands: string[] = [] - let currentCommand: string[] = [] - - for (const token of tokens) { - if (typeof token === "object" && "op" in token) { - // Chain operator - split command - if (["&&", "||", ";", "|"].includes(token.op)) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] + try { + // First split by newlines (including all types: \n, \r\n, \r) to handle multi-line commands + const lines = command.split(/\r\n|\r|\n/) + const allCommands: string[] = [] + + for (const line of lines) { + const trimmedLine = line.trim() + if (!trimmedLine) continue // Skip empty lines + + // Storage for replaced content + const redirections: string[] = [] + const subshells: string[] = [] + const quotes: string[] = [] + const arrayIndexing: string[] = [] + const arithmeticExpressions: string[] = [] + const variables: string[] = [] + + // First handle PowerShell redirections by temporarily replacing them + let processedCommand = trimmedLine.replace(/\d*>&\d*/g, (match) => { + redirections.push(match) + return `__REDIR_${redirections.length - 1}__` + }) + + // Handle arithmetic expressions: $((...)) pattern + // Match the entire arithmetic expression including nested parentheses + processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => { + arithmeticExpressions.push(match) + return `__ARITH_${arithmeticExpressions.length - 1}__` + }) + + // Handle array indexing expressions: ${array[...]} pattern and partial expressions + processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => { + arrayIndexing.push(match) + return `__ARRAY_${arrayIndexing.length - 1}__` + }) + + // Handle simple variable references: $varname pattern + // This prevents shell-quote from splitting $count into separate tokens + processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => { + variables.push(match) + return `__VAR_${variables.length - 1}__` + }) + + // Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9 + processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => { + variables.push(match) + return `__VAR_${variables.length - 1}__` + }) + + // Then handle subshell commands - store them for security analysis + const _hasSubshells = trimmedLine.includes("$(") || trimmedLine.includes("`") + + processedCommand = processedCommand + .replace(/\$\(((?!\().*?)\)/g, (_, inner) => { + const trimmedInner = inner.trim() + subshells.push(trimmedInner) + return `__SUBSH_${subshells.length - 1}__` + }) + .replace(/`(.*?)`/g, (_, inner) => { + const trimmedInner = inner.trim() + subshells.push(trimmedInner) + return `__SUBSH_${subshells.length - 1}__` + }) + + // Then handle quoted strings + processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { + quotes.push(match) + return `__QUOTE_${quotes.length - 1}__` + }) + + const tokens = parse(processedCommand) as ShellToken[] + const commands: string[] = [] + let currentCommand: string[] = [] + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + + if (typeof token === "object" && "op" in token) { + // Chain operator - split command + if (["&&", "||", ";", "|"].includes(token.op)) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + } else { + // Other operators (>, &) are part of the command + currentCommand.push(token.op) + } + } else if (typeof token === "string") { + // Check if it's a subshell placeholder + const subshellMatch = token.match(/__SUBSH_(\d+)__/) + if (subshellMatch) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + commands.push(subshells[parseInt(subshellMatch[1])]) + } else { + currentCommand.push(token) + } } - } else { - // Other operators (>, &) are part of the command - currentCommand.push(token.op) } - } else if (typeof token === "string") { - // Check if it's a subshell placeholder - const subshellMatch = token.match(/__SUBSH_(\d+)__/) - if (subshellMatch) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } - commands.push(subshells[parseInt(subshellMatch[1])]) - } else { - currentCommand.push(token) + + // Add any remaining command + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) } + + // Restore quotes, redirections, arithmetic expressions, variables, and array indexing + const restoredCommands = commands.map((cmd) => { + let result = cmd + // Restore quotes + result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) + // Restore redirections + result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) + // Restore arithmetic expressions + result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) + // Restore variables + result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) + // Restore array indexing expressions + result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) + return result + }) + + allCommands.push(...restoredCommands) } - } - // Add any remaining command - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - } + // Check if any line has subshells + const hasSubshells = command.includes("$(") || command.includes("`") + const subshellCommands: string[] = [] + + // Extract subshell commands for security analysis + let match: RegExpExecArray | null + const subshellRegex1 = /\$\(((?!\().*?)\)/g + const subshellRegex2 = /`(.*?)`/g - // Restore quotes, redirections, and array indexing - const restoredCommands = commands.map((cmd) => { - let result = cmd - // Restore quotes - result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) - // Restore redirections - result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) - // Restore array indexing expressions - result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) - return result - }) - - return { - subCommands: restoredCommands, - hasSubshells, - subshellCommands: subshells, + while ((match = subshellRegex1.exec(command)) !== null) { + if (match[1]) { + subshellCommands.push(match[1].trim()) + } + } + + while ((match = subshellRegex2.exec(command)) !== null) { + if (match[1]) { + subshellCommands.push(match[1].trim()) + } + } + + return { + subCommands: allCommands, + hasSubshells, + subshellCommands, + } + } catch (_error) { + // If shell-quote fails, fall back to simple splitting + const fallbackCommands = command + .split(/\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + return { + subCommands: fallbackCommands.length > 0 ? fallbackCommands : [command], + hasSubshells: command.includes("$(") || command.includes("`"), + subshellCommands: [], + } } } From 0a5cf463a17056e56e3bb4fce747d4aebceed6b1 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 23 Jul 2025 13:26:06 -0600 Subject: [PATCH 13/31] fix: revert conditional display of command pattern selector Per user feedback, the command pattern selector should always be displayed when patterns are available, regardless of whether command restrictions are configured. --- .../src/components/chat/CommandExecution.tsx | 5 +-- .../chat/__tests__/CommandExecution.spec.tsx | 33 ++----------------- 2 files changed, 3 insertions(+), 35 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index a0efabeb4d4..8bbb7d9041f 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -47,9 +47,6 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) const [streamingOutput, setStreamingOutput] = useState("") const [status, setStatus] = useState(null) - // Show suggestions when user has command restrictions enabled (has denied commands) - // This provides a better UX by only showing the pattern selector when it's relevant - const showCommandSuggestions = deniedCommands.length > 0 || allowedCommands.length > 0 // The command's output can either come from the text associated with the // task message (this is the case for completed commands) or from the @@ -206,7 +203,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec )}
- {showCommandSuggestions && commandPatterns.length > 0 && ( + {commandPatterns.length > 0 && ( { - const noRestrictionsState = { - ...mockExtensionState, - allowedCommands: [], - deniedCommands: [], - } - - render( - - - , - ) - - // Should not show pattern selector when no restrictions are configured - expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() - }) - - it("should show pattern selector when command restrictions are configured", () => { - // Default mockExtensionState has allowedCommands: ["npm"] and deniedCommands: ["rm"] - render( - - - , - ) - - // Should show pattern selector when restrictions are configured - expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - }) - it("should expand output when terminal shell integration is disabled", () => { const disabledState = { ...mockExtensionState, @@ -317,8 +288,8 @@ Output here` , ) - // When both are undefined (which defaults to empty arrays), pattern selector should not show - expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() + // Should show pattern selector when patterns are available + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() }) it("should handle pattern change when moving from denied to allowed", () => { From 8b4150e0178ee3b95af60d19d727648ee7e494bb Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 24 Jul 2025 10:40:08 -0500 Subject: [PATCH 14/31] Refactor command execution and security handling - Removed security warning detection from CommandExecution component and related tests. - Simplified command parsing logic by consolidating command extraction and validation. - Updated command pattern extraction to handle duplicate patterns gracefully. - Enhanced command parsing to limit extracted patterns to a maximum of three levels. - Removed unused security issue detection functions and related tests. - Improved test coverage for command pattern extraction and validation. --- .../src/components/chat/CommandExecution.tsx | 23 +- .../chat/__tests__/CommandExecution.spec.tsx | 12 - .../__tests__/CommandPatternSelector.spec.tsx | 240 +++----------- .../utils/__tests__/command-parser.spec.ts | 190 +++++------ .../utils/__tests__/commandPatterns.spec.ts | 87 +---- webview-ui/src/utils/command-parser.ts | 306 +++--------------- webview-ui/src/utils/command-validation.ts | 38 ++- webview-ui/src/utils/commandPatterns.ts | 9 +- 8 files changed, 206 insertions(+), 699 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 8bbb7d9041f..673bcca00b8 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -1,6 +1,6 @@ import { useCallback, useState, memo, useMemo } from "react" import { useEvent } from "react-use" -import { ChevronDown, Skull, AlertTriangle } from "lucide-react" +import { ChevronDown, Skull } from "lucide-react" import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types" @@ -18,7 +18,6 @@ import { getPatternDescription, parseCommandAndOutput, CommandPattern, - detectSecurityIssues, } from "../../utils/commandPatterns" interface CommandExecutionProps { @@ -71,11 +70,6 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec return patterns }, [command]) - // Detect security issues in the command - const securityWarnings = useMemo(() => { - return detectSecurityIssues(command) - }, [command]) - // Handle pattern changes const handleAllowPatternChange = (pattern: string) => { const isAllowed = allowedCommands.includes(pattern) @@ -186,21 +180,6 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
- {securityWarnings.length > 0 && ( -
-
- -
-
Security Warning
- {securityWarnings.map((warning, index) => ( -
- {warning.message} -
- ))} -
-
-
- )}
{commandPatterns.length > 0 && ( diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index 7b60a87b4dd..7b584d75e79 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -365,18 +365,6 @@ Other output here` expect(screen.queryByText("whoami")).not.toBeInTheDocument() }) - it("should display security warning for commands with subshells", () => { - render( - - - , - ) - - // Should show security warning - expect(screen.getByText("Security Warning")).toBeInTheDocument() - expect(screen.getByText(/subshell execution/)).toBeInTheDocument() - }) - it("should handle commands with backtick subshells", () => { render( diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx index 4dd69e3969a..d48d16c2d2b 100644 --- a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -1,50 +1,29 @@ import React from "react" import { render, screen, fireEvent } from "@testing-library/react" -import { describe, it, expect, vi, beforeEach } from "vitest" +import { describe, it, expect, vi } from "vitest" import { CommandPatternSelector } from "../CommandPatternSelector" import { CommandPattern } from "../../../utils/commandPatterns" +import { TooltipProvider } from "../../../components/ui/tooltip" // Mock react-i18next vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, }), - Trans: ({ i18nKey, components }: any) => { - if (i18nKey === "chat:commandExecution.commandManagementDescription") { - return ( - - Manage command permissions: Click ✓ to allow auto-execution, ✗ to deny execution. Patterns can be - toggled on/off or removed from lists. {components.settingsLink} - - ) - } - return {i18nKey} - }, + Trans: ({ i18nKey, children }: any) => {i18nKey || children}, })) // Mock VSCodeLink vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeLink: ({ children, onClick }: any) => ( - {children || "View all settings"} - - ), -})) - -// Mock StandardTooltip -vi.mock("../../ui/standard-tooltip", () => ({ - StandardTooltip: ({ children, content }: any) => ( -
{children} - {/* Render the content to make it testable */} -
{content}
-
+ ), })) -// Mock window.postMessage -const mockPostMessage = vi.fn() -window.postMessage = mockPostMessage +// Wrapper component with TooltipProvider +const TestWrapper = ({ children }: { children: React.ReactNode }) => {children} describe("CommandPatternSelector", () => { const mockPatterns: CommandPattern[] = [ @@ -61,192 +40,53 @@ describe("CommandPatternSelector", () => { onDenyPatternChange: vi.fn(), } - beforeEach(() => { - vi.clearAllMocks() - }) - - it("should render collapsed by default", () => { - render() - - expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument() - expect(screen.queryByText("npm commands")).not.toBeInTheDocument() - }) + it("should render with unique pattern keys", () => { + const { container } = render( + + + , + ) - it("should expand when clicked", () => { - render() + // The component should render without errors + expect(container).toBeTruthy() - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + // Click to expand the component + const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) fireEvent.click(expandButton) - // Check for the patterns themselves + // Check that patterns are rendered expect(screen.getByText("npm")).toBeInTheDocument() expect(screen.getByText("npm install")).toBeInTheDocument() expect(screen.getByText("git")).toBeInTheDocument() - - // Check for the descriptions - expect(screen.getByText("- npm commands")).toBeInTheDocument() - expect(screen.getByText("- npm install commands")).toBeInTheDocument() - expect(screen.getByText("- git commands")).toBeInTheDocument() }) - it("should collapse when clicked again", () => { - render() - - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) - fireEvent.click(expandButton) - - const collapseButton = screen.getByRole("button", { name: "chat:commandExecution.collapseManagement" }) - fireEvent.click(collapseButton) - - expect(screen.queryByText("npm commands")).not.toBeInTheDocument() - }) - - it("should show correct status for patterns", () => { - render() - - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) - fireEvent.click(expandButton) - - // Check that npm has allowed styling (green) - const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0] - expect(npmAllowButton).toHaveClass("bg-green-500/20") - - // Check that git has denied styling (red) - const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0] - expect(gitDenyButton).toHaveClass("bg-red-500/20") - }) - - it("should call onAllowPatternChange when allow button is clicked", () => { - render() - - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) - fireEvent.click(expandButton) - - // Find all allow buttons with the "add to allowed" label - const allowButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToAllowed" }) - - // The second one should be for npm install (first is npm which is already allowed) - fireEvent.click(allowButtons[0]) - - expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm install") - }) - - it("should call onDenyPatternChange when deny button is clicked", () => { - render() - - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) - fireEvent.click(expandButton) - - // Find all deny buttons with the "add to denied" label - const denyButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToDenied" }) - - // The second one should be for npm install (first is npm, third is git which is already denied) - fireEvent.click(denyButtons[1]) - - expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("npm install") - }) - - it("should toggle allowed pattern when clicked", () => { - render() - - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) - fireEvent.click(expandButton) - - // Find the allow button for npm (which is already allowed) - const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0] - fireEvent.click(npmAllowButton) - - expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm") - }) - - it("should toggle denied pattern when clicked", () => { - render() - - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) - fireEvent.click(expandButton) - - // Find the deny button for git (which is already denied) - const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0] - fireEvent.click(gitDenyButton) - - expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("git") - }) - - it("should have tooltip with settings link", () => { - const { container } = render() - - // The info icon should have a tooltip - const tooltipWrapper = container.querySelector('[title="tooltip"]') - expect(tooltipWrapper).toBeTruthy() - - // The tooltip content includes a settings link (mocked as VSCodeLink) - // It's rendered in a hidden div for testing purposes - const settingsLink = container.querySelector('a[href="#"]') - expect(settingsLink).toBeTruthy() - expect(settingsLink?.textContent).toBe("View all settings") - - // Test that clicking the link posts the correct message - if (settingsLink) { - fireEvent.click(settingsLink) - - expect(mockPostMessage).toHaveBeenCalledWith( - { - type: "action", - action: "settingsButtonClicked", - values: { section: "autoApprove" }, - }, - "*", - ) + it("should handle duplicate patterns gracefully", () => { + // Test with duplicate patterns to ensure keys are still unique + const duplicatePatterns: CommandPattern[] = [ + { pattern: "npm", description: "npm commands" }, + { pattern: "npm", description: "duplicate npm commands" }, // Duplicate pattern + { pattern: "git", description: "git commands" }, + ] + + const props = { + ...defaultProps, + patterns: duplicatePatterns, } - }) - - it("should render with empty patterns", () => { - render() - - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) - fireEvent.click(expandButton) - - // The expanded view should exist but be empty since there are no patterns - const expandedContent = screen - .getByRole("button", { name: "chat:commandExecution.collapseManagement" }) - .parentElement?.querySelector(".px-3.pb-3") - expect(expandedContent).toBeInTheDocument() - expect(expandedContent?.children.length).toBe(0) - }) - - it("should render patterns without descriptions", () => { - const patternsWithoutDesc: CommandPattern[] = [{ pattern: "custom-command" }] - - render() - - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) - fireEvent.click(expandButton) - - expect(screen.getByText("custom-command")).toBeInTheDocument() - }) - - it("should always show info icon with tooltip", () => { - const { container } = render() - - // Info icon should always be visible (not just when expanded) - // Look for the Info icon which is wrapped in StandardTooltip - const infoIcon = container.querySelector(".ml-1") - expect(infoIcon).toBeTruthy() - }) - - it("should apply correct classes for chevron rotation", () => { - const { container } = render() - // Initially collapsed - chevron should be rotated - let chevron = container.querySelector(".size-3.transition-transform") - expect(chevron).toHaveClass("-rotate-90") + // This should not throw an error even with duplicate patterns + const { container } = render( + + + , + ) + expect(container).toBeTruthy() - // Click to expand - const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" }) + // Click to expand the component + const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) fireEvent.click(expandButton) - // When expanded - chevron should not be rotated - chevron = container.querySelector(".size-3.transition-transform") - expect(chevron).toHaveClass("rotate-0") + // Both instances of "npm" should be rendered + const npmElements = screen.getAllByText("npm") + expect(npmElements).toHaveLength(2) }) }) diff --git a/webview-ui/src/utils/__tests__/command-parser.spec.ts b/webview-ui/src/utils/__tests__/command-parser.spec.ts index 4063e7a1ac9..05303f87fc4 100644 --- a/webview-ui/src/utils/__tests__/command-parser.spec.ts +++ b/webview-ui/src/utils/__tests__/command-parser.spec.ts @@ -1,161 +1,137 @@ import { describe, it, expect } from "vitest" -import { parseCommandString, extractPatternsFromCommand, detectCommandSecurityIssues } from "../command-parser" +import { extractPatternsFromCommand } from "../command-parser" -describe("parseCommandString", () => { - it("should parse simple command", () => { - const result = parseCommandString("ls -la") - expect(result.subCommands).toEqual(["ls -la"]) - expect(result.hasSubshells).toBe(false) - expect(result.subshellCommands).toEqual([]) +describe("extractPatternsFromCommand", () => { + it("should extract simple command pattern", () => { + const patterns = extractPatternsFromCommand("ls") + expect(patterns).toEqual(["ls"]) }) - it("should parse command with && operator", () => { - const result = parseCommandString("npm install && npm test") - expect(result.subCommands).toEqual(["npm install", "npm test"]) - expect(result.hasSubshells).toBe(false) + it("should extract command with subcommand", () => { + const patterns = extractPatternsFromCommand("git push origin main") + expect(patterns).toEqual(["git", "git push", "git push origin"]) }) - it("should parse command with || operator", () => { - const result = parseCommandString("npm test || npm run test:ci") - expect(result.subCommands).toEqual(["npm test", "npm run test:ci"]) - expect(result.hasSubshells).toBe(false) + it("should stop at flags", () => { + const patterns = extractPatternsFromCommand("git commit -m 'test'") + expect(patterns).toEqual(["git", "git commit"]) }) - it("should parse command with pipe", () => { - const result = parseCommandString("ls -la | grep test") - expect(result.subCommands).toEqual(["ls -la", "grep test"]) - expect(result.hasSubshells).toBe(false) + it("should stop at paths", () => { + const patterns = extractPatternsFromCommand("cd /usr/local/bin") + expect(patterns).toEqual(["cd"]) }) - it("should detect and extract subshells with $()", () => { - const result = parseCommandString("echo $(date)") - expect(result.subCommands).toEqual(["echo", "date"]) - expect(result.hasSubshells).toBe(true) - expect(result.subshellCommands).toEqual(["date"]) + it("should handle pipes", () => { + const patterns = extractPatternsFromCommand("ls -la | grep test") + expect(patterns).toEqual(["grep", "grep test", "ls"]) }) - it("should detect and extract subshells with backticks", () => { - const result = parseCommandString("echo `whoami`") - expect(result.subCommands).toEqual(["echo", "whoami"]) - expect(result.hasSubshells).toBe(true) - expect(result.subshellCommands).toEqual(["whoami"]) + it("should handle && operator", () => { + const patterns = extractPatternsFromCommand("npm install && git push origin main") + expect(patterns).toEqual(["git", "git push", "git push origin", "npm", "npm install"]) }) - it("should handle PowerShell redirections", () => { - const result = parseCommandString("command 2>&1") - expect(result.subCommands).toEqual(["command 2>&1"]) - expect(result.hasSubshells).toBe(false) + it("should handle || operator", () => { + const patterns = extractPatternsFromCommand("npm test || npm run test:ci") + expect(patterns).toEqual(["npm", "npm run", "npm test"]) }) - it("should handle quoted strings", () => { - const result = parseCommandString('echo "hello world"') - expect(result.subCommands).toEqual(['echo "hello world"']) - expect(result.hasSubshells).toBe(false) + it("should handle semicolon separator", () => { + const patterns = extractPatternsFromCommand("cd src; npm install") + expect(patterns).toEqual(["cd", "cd src", "npm", "npm install"]) }) - it("should handle array indexing expressions", () => { - const result = parseCommandString("echo ${array[0]}") - expect(result.subCommands).toEqual(["echo ${array[0]}"]) - expect(result.hasSubshells).toBe(false) + it("should skip numeric commands", () => { + const patterns = extractPatternsFromCommand("0 total") + expect(patterns).toEqual([]) }) it("should handle empty command", () => { - const result = parseCommandString("") - expect(result.subCommands).toEqual([]) - expect(result.hasSubshells).toBe(false) - expect(result.subshellCommands).toEqual([]) + const patterns = extractPatternsFromCommand("") + expect(patterns).toEqual([]) }) - it("should handle complex command with multiple operators", () => { - const result = parseCommandString("npm install && npm test | grep success || echo 'failed'") - expect(result.subCommands).toEqual(["npm install", "npm test", "grep success", "echo failed"]) - expect(result.hasSubshells).toBe(false) + it("should handle null/undefined", () => { + expect(extractPatternsFromCommand(null as any)).toEqual([]) + expect(extractPatternsFromCommand(undefined as any)).toEqual([]) }) -}) -describe("extractPatternsFromCommand", () => { - it("should extract simple command pattern", () => { - const patterns = extractPatternsFromCommand("ls") - expect(patterns).toEqual(["ls"]) + it("should handle scripts", () => { + const patterns = extractPatternsFromCommand("./script.sh --verbose") + expect(patterns).toEqual(["./script.sh"]) }) - it("should extract command with arguments", () => { - const patterns = extractPatternsFromCommand("npm install express") - expect(patterns).toEqual(["npm", "npm install", "npm install express"]) + it("should handle paths with dots", () => { + const patterns = extractPatternsFromCommand("git add .") + expect(patterns).toEqual(["git", "git add"]) }) - it("should stop at flags", () => { - const patterns = extractPatternsFromCommand("git commit -m 'test'") - expect(patterns).toEqual(["git", "git commit"]) - }) - - it("should stop at paths", () => { - const patterns = extractPatternsFromCommand("cd /usr/local/bin") + it("should handle paths with tilde", () => { + const patterns = extractPatternsFromCommand("cd ~/projects") expect(patterns).toEqual(["cd"]) }) - it("should handle piped commands", () => { - const patterns = extractPatternsFromCommand("ls -la | grep test") - expect(patterns).toContain("ls") - expect(patterns).toContain("grep") - expect(patterns).toContain("grep test") - }) - - it("should remove subshells before extracting patterns", () => { - const patterns = extractPatternsFromCommand("echo $(malicious)") - expect(patterns).toEqual(["echo"]) - expect(patterns).not.toContain("malicious") + it("should handle colons in arguments", () => { + const patterns = extractPatternsFromCommand("docker run image:tag") + expect(patterns).toEqual(["docker", "docker run"]) }) - it("should skip numeric commands", () => { - const patterns = extractPatternsFromCommand("0 total") - expect(patterns).toEqual([]) + it("should return sorted patterns", () => { + const patterns = extractPatternsFromCommand("npm run build && git push") + expect(patterns).toEqual(["git", "git push", "npm", "npm run", "npm run build"]) }) - it("should skip common output words", () => { - const patterns = extractPatternsFromCommand("error") - expect(patterns).toEqual([]) + it("should handle complex command with multiple operators", () => { + const patterns = extractPatternsFromCommand("npm install && npm test | grep success || echo 'failed'") + expect(patterns).toContain("npm") + expect(patterns).toContain("npm install") + expect(patterns).toContain("npm test") + expect(patterns).toContain("grep") + expect(patterns).toContain("echo") }) - it("should handle empty command", () => { - const patterns = extractPatternsFromCommand("") - expect(patterns).toEqual([]) + it("should handle malformed commands gracefully", () => { + const patterns = extractPatternsFromCommand("echo 'unclosed quote") + expect(patterns).toContain("echo") }) - it("should return sorted patterns", () => { + it("should not treat package managers specially", () => { const patterns = extractPatternsFromCommand("npm run build") expect(patterns).toEqual(["npm", "npm run", "npm run build"]) + // Now includes "npm run build" with 3-level extraction + }) + + it("should extract at most 3 levels", () => { + const patterns = extractPatternsFromCommand("git push origin main --force") + expect(patterns).toEqual(["git", "git push", "git push origin"]) + // Should NOT include deeper levels beyond 3 }) -}) -describe("detectCommandSecurityIssues", () => { - it("should detect subshell with $()", () => { - const warnings = detectCommandSecurityIssues("echo $(malicious)") - expect(warnings).toHaveLength(1) - expect(warnings[0].type).toBe("subshell") - expect(warnings[0].message).toContain("subshell execution") + it("should handle multi-level commands like gh pr", () => { + const patterns = extractPatternsFromCommand("gh pr checkout 123") + expect(patterns).toEqual(["gh", "gh pr", "gh pr checkout"]) }) - it("should detect subshell with backticks", () => { - const warnings = detectCommandSecurityIssues("echo `malicious`") - expect(warnings).toHaveLength(1) - expect(warnings[0].type).toBe("subshell") - expect(warnings[0].message).toContain("subshell execution") + it("should extract 3 levels for git remote add", () => { + const patterns = extractPatternsFromCommand("git remote add origin https://github.com/user/repo.git") + expect(patterns).toEqual(["git", "git remote", "git remote add"]) }) - it("should detect multiple subshell patterns", () => { - const warnings = detectCommandSecurityIssues("echo $(date) && echo `whoami`") - expect(warnings).toHaveLength(1) // Still one warning for subshell presence - expect(warnings[0].type).toBe("subshell") + it("should extract 3 levels for npm run build", () => { + const patterns = extractPatternsFromCommand("npm run build --production") + expect(patterns).toEqual(["npm", "npm run", "npm run build"]) }) - it("should not detect issues in safe commands", () => { - const warnings = detectCommandSecurityIssues("npm install express") - expect(warnings).toHaveLength(0) + it("should stop at file extensions even at third level", () => { + const patterns = extractPatternsFromCommand("node scripts test.js") + expect(patterns).toEqual(["node", "node scripts"]) + // Should NOT include "node scripts test.js" because of .js }) - it("should handle empty command", () => { - const warnings = detectCommandSecurityIssues("") - expect(warnings).toHaveLength(0) + it("should stop at flags at any level", () => { + const patterns = extractPatternsFromCommand("docker run -it ubuntu") + expect(patterns).toEqual(["docker", "docker run"]) + // Stops at -it flag }) }) diff --git a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts index 92feda0412f..ff563be1ac1 100644 --- a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts +++ b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts @@ -1,10 +1,5 @@ import { describe, it, expect } from "vitest" -import { - extractCommandPatterns, - getPatternDescription, - parseCommandAndOutput, - detectSecurityIssues, -} from "../commandPatterns" +import { extractCommandPatterns, getPatternDescription, parseCommandAndOutput } from "../commandPatterns" describe("extractCommandPatterns", () => { it("should extract simple command", () => { @@ -37,7 +32,7 @@ describe("extractCommandPatterns", () => { expect(patterns).toContain("npm") expect(patterns).toContain("npm test") expect(patterns).toContain("npm run") - expect(patterns).toContain("npm run test:ci") + expect(patterns).not.toContain("npm run test:ci") }) it("should handle semicolon separated commands", () => { @@ -94,12 +89,12 @@ describe("extractCommandPatterns", () => { const patterns = extractCommandPatterns("git add .") expect(patterns).toContain("git") expect(patterns).toContain("git add") - expect(patterns).not.toContain("git add .") + expect(patterns).not.toContain("git add .") // dot is excluded }) it("should return sorted patterns", () => { const patterns = extractCommandPatterns("npm run build && git push") - expect(patterns).toEqual([...patterns].sort()) + expect(patterns).toEqual(["git", "git push", "npm", "npm run", "npm run build"]) }) it("should handle numeric input like '0 total'", () => { @@ -354,80 +349,6 @@ drwxr-xr-x 20 user staff 640 Jan 22 11:00 ..` }) }) -describe("detectSecurityIssues", () => { - it("should detect subshell execution with $()", () => { - const warnings = detectSecurityIssues("echo $(malicious)") - expect(warnings).toHaveLength(1) - expect(warnings[0].type).toBe("subshell") - expect(warnings[0].message).toContain("subshell execution") - }) - - it("should detect subshell execution with backticks", () => { - const warnings = detectSecurityIssues("echo `malicious`") - expect(warnings).toHaveLength(1) - expect(warnings[0].type).toBe("subshell") - expect(warnings[0].message).toContain("subshell execution") - }) - - it("should detect nested subshells", () => { - const warnings = detectSecurityIssues("echo $(echo $(date))") - expect(warnings).toHaveLength(1) - expect(warnings[0].type).toBe("subshell") - }) - - it("should detect subshells in complex commands", () => { - const warnings = detectSecurityIssues("npm install && echo $(whoami) || git push") - expect(warnings).toHaveLength(1) - expect(warnings[0].type).toBe("subshell") - }) - - it("should not detect issues in safe commands", () => { - const warnings = detectSecurityIssues("npm install express") - expect(warnings).toHaveLength(0) - }) - - it("should handle empty commands", () => { - const warnings = detectSecurityIssues("") - expect(warnings).toHaveLength(0) - }) - - it("should detect multiple subshell patterns", () => { - const warnings = detectSecurityIssues("echo $(date) && echo `whoami`") - expect(warnings).toHaveLength(1) // Should still be 1 warning for subshell presence - expect(warnings[0].type).toBe("subshell") - }) - - it("should detect subshells in quoted strings", () => { - const warnings = detectSecurityIssues('echo "Current user: $(whoami)"') - expect(warnings).toHaveLength(1) - expect(warnings[0].type).toBe("subshell") - }) -}) - -describe("security integration with extractCommandPatterns", () => { - it("should not include subshell content in patterns", () => { - const patterns = extractCommandPatterns("echo $(malicious)") - expect(patterns).toContain("echo") - expect(patterns).not.toContain("$(malicious)") - expect(patterns).not.toContain("malicious") - }) - - it("should handle commands with subshells properly", () => { - const patterns = extractCommandPatterns("npm install && echo $(whoami)") - expect(patterns).toContain("npm") - expect(patterns).toContain("npm install") - expect(patterns).toContain("echo") - expect(patterns).not.toContain("whoami") - }) - - it("should extract patterns from commands with backtick subshells", () => { - const patterns = extractCommandPatterns("git commit -m `date`") - expect(patterns).toContain("git") - expect(patterns).toContain("git commit") - expect(patterns).not.toContain("date") - }) -}) - describe("integration: parseCommandAndOutput with extractCommandPatterns", () => { it("should not extract patterns from output text", () => { const text = `wc -l *.go *.java diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index a3fb6d92e24..d8ad9eebbc0 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -1,289 +1,67 @@ import { parse } from "shell-quote" -type ShellToken = string | { op: string } | { command: string } - /** - * Shared command parsing utility that consolidates parsing logic - * from both command-validation.ts and commandPatterns.ts + * Extract command patterns from a command string. + * Returns at most 3 levels: base command, command + first argument, and command + first two arguments. + * Stops at flags (-), paths (/\~), file extensions (.ext), or special characters (:). */ +export function extractPatternsFromCommand(command: string): string[] { + if (!command?.trim()) return [] -/** - * Parse a command string and handle special cases like subshells, - * redirections, and quoted strings. - * - * @param command - The command string to parse - * @returns Object containing parsed information - */ -export function parseCommandString(command: string): { - subCommands: string[] - hasSubshells: boolean - subshellCommands: string[] -} { - if (!command?.trim()) { - return { - subCommands: [], - hasSubshells: false, - subshellCommands: [], - } - } + const patterns = new Set() try { - // First split by newlines (including all types: \n, \r\n, \r) to handle multi-line commands - const lines = command.split(/\r\n|\r|\n/) - const allCommands: string[] = [] - - for (const line of lines) { - const trimmedLine = line.trim() - if (!trimmedLine) continue // Skip empty lines - - // Storage for replaced content - const redirections: string[] = [] - const subshells: string[] = [] - const quotes: string[] = [] - const arrayIndexing: string[] = [] - const arithmeticExpressions: string[] = [] - const variables: string[] = [] - - // First handle PowerShell redirections by temporarily replacing them - let processedCommand = trimmedLine.replace(/\d*>&\d*/g, (match) => { - redirections.push(match) - return `__REDIR_${redirections.length - 1}__` - }) - - // Handle arithmetic expressions: $((...)) pattern - // Match the entire arithmetic expression including nested parentheses - processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => { - arithmeticExpressions.push(match) - return `__ARITH_${arithmeticExpressions.length - 1}__` - }) - - // Handle array indexing expressions: ${array[...]} pattern and partial expressions - processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => { - arrayIndexing.push(match) - return `__ARRAY_${arrayIndexing.length - 1}__` - }) - - // Handle simple variable references: $varname pattern - // This prevents shell-quote from splitting $count into separate tokens - processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => { - variables.push(match) - return `__VAR_${variables.length - 1}__` - }) - - // Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9 - processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => { - variables.push(match) - return `__VAR_${variables.length - 1}__` - }) - - // Then handle subshell commands - store them for security analysis - const _hasSubshells = trimmedLine.includes("$(") || trimmedLine.includes("`") - - processedCommand = processedCommand - .replace(/\$\(((?!\().*?)\)/g, (_, inner) => { - const trimmedInner = inner.trim() - subshells.push(trimmedInner) - return `__SUBSH_${subshells.length - 1}__` - }) - .replace(/`(.*?)`/g, (_, inner) => { - const trimmedInner = inner.trim() - subshells.push(trimmedInner) - return `__SUBSH_${subshells.length - 1}__` - }) - - // Then handle quoted strings - processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { - quotes.push(match) - return `__QUOTE_${quotes.length - 1}__` - }) - - const tokens = parse(processedCommand) as ShellToken[] - const commands: string[] = [] - let currentCommand: string[] = [] - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i] - - if (typeof token === "object" && "op" in token) { - // Chain operator - split command - if (["&&", "||", ";", "|"].includes(token.op)) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } - } else { - // Other operators (>, &) are part of the command - currentCommand.push(token.op) - } - } else if (typeof token === "string") { - // Check if it's a subshell placeholder - const subshellMatch = token.match(/__SUBSH_(\d+)__/) - if (subshellMatch) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } - commands.push(subshells[parseInt(subshellMatch[1])]) - } else { - currentCommand.push(token) - } + const parsed = parse(command) + const commandSeparators = new Set(["|", "&&", "||", ";"]) + let currentTokens: string[] = [] + + for (const token of parsed) { + if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { + // Process accumulated tokens as a command + if (currentTokens.length > 0) { + extractFromTokens(currentTokens, patterns) + currentTokens = [] } - } - - // Add any remaining command - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - } - - // Restore quotes, redirections, arithmetic expressions, variables, and array indexing - const restoredCommands = commands.map((cmd) => { - let result = cmd - // Restore quotes - result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) - // Restore redirections - result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) - // Restore arithmetic expressions - result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) - // Restore variables - result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) - // Restore array indexing expressions - result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) - return result - }) - - allCommands.push(...restoredCommands) - } - - // Check if any line has subshells - const hasSubshells = command.includes("$(") || command.includes("`") - const subshellCommands: string[] = [] - - // Extract subshell commands for security analysis - let match: RegExpExecArray | null - const subshellRegex1 = /\$\(((?!\().*?)\)/g - const subshellRegex2 = /`(.*?)`/g - - while ((match = subshellRegex1.exec(command)) !== null) { - if (match[1]) { - subshellCommands.push(match[1].trim()) + } else if (typeof token === "string") { + currentTokens.push(token) } } - while ((match = subshellRegex2.exec(command)) !== null) { - if (match[1]) { - subshellCommands.push(match[1].trim()) - } - } - - return { - subCommands: allCommands, - hasSubshells, - subshellCommands, + // Process any remaining tokens + if (currentTokens.length > 0) { + extractFromTokens(currentTokens, patterns) } } catch (_error) { - // If shell-quote fails, fall back to simple splitting - const fallbackCommands = command - .split(/\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0) - - return { - subCommands: fallbackCommands.length > 0 ? fallbackCommands : [command], - hasSubshells: command.includes("$(") || command.includes("`"), - subshellCommands: [], - } + // Fallback: just extract the first word + const firstWord = command.trim().split(/\s+/)[0] + if (firstWord) patterns.add(firstWord) } -} - -/** - * Extract command patterns for permission management. - * This is a simplified version that focuses on extracting - * the main command and its subcommands for pattern matching. - * - * @param command - The command string to extract patterns from - * @returns Array of command patterns - */ -export function extractPatternsFromCommand(command: string): string[] { - if (!command?.trim()) return [] - - // First, remove subshells for security - we don't want to extract patterns from subshell contents - const cleanedCommand = command - .replace(/\$\([^)]*\)/g, "") // Remove $() subshells - .replace(/`[^`]*`/g, "") // Remove backtick subshells - - const patterns = new Set() - const parsed = parse(cleanedCommand) as ShellToken[] - - const commandSeparators = new Set(["|", "&&", "||", ";"]) - let current: string[] = [] - - for (const token of parsed) { - if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { - if (current.length) processCommandForPatterns(current, patterns) - current = [] - } else { - current.push(String(token)) - } - } - - if (current.length) processCommandForPatterns(current, patterns) return Array.from(patterns).sort() } -/** - * Process a single command to extract patterns - */ -function processCommandForPatterns(cmd: string[], patterns: Set): void { - if (!cmd.length || typeof cmd[0] !== "string") return - - const mainCmd = cmd[0] - - // Skip if it's just a number (like "0" from "0 total") - if (/^\d+$/.test(mainCmd)) return - - // Skip common output patterns that aren't commands - const skipWords = ["total", "error", "warning", "failed", "success", "done"] - if (skipWords.includes(mainCmd.toLowerCase())) return - - patterns.add(mainCmd) - - const breakingExps = [/^-/, /[\\/.~]/] - - for (let i = 1; i < cmd.length; i++) { - const arg = cmd[i] +function isValidToken(token: string): boolean { + return !!token && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/) +} - if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break +function extractFromTokens(tokens: string[], patterns: Set): void { + if (tokens.length === 0) return - const pattern = cmd.slice(0, i + 1).join(" ") - patterns.add(pattern) - } -} + const mainCmd = tokens[0] -/** - * Security analysis for commands - */ -export interface SecurityWarning { - type: "subshell" | "injection" - message: string -} + // Skip numeric commands like "0" from "0 total" + if (/^\d+$/.test(mainCmd)) return -/** - * Detect security issues in a command - * - * @param command - The command to analyze - * @returns Array of security warnings - */ -export function detectCommandSecurityIssues(command: string): SecurityWarning[] { - const warnings: SecurityWarning[] = [] + // Build patterns progressively up to 3 levels + let pattern = mainCmd + patterns.add(pattern) - // Check for subshell execution attempts - if (command.includes("$(") || command.includes("`")) { - warnings.push({ - type: "subshell", - message: "Command contains subshell execution which could bypass restrictions", - }) + for (let i = 1; i < Math.min(tokens.length, 3); i++) { + if (isValidToken(tokens[i])) { + pattern += ` ${tokens[i]}` + patterns.add(pattern) + } else { + break // Stop at first invalid token + } } - - return warnings } diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts index 2e69fe8b740..6396eef1a4d 100644 --- a/webview-ui/src/utils/command-validation.ts +++ b/webview-ui/src/utils/command-validation.ts @@ -1,4 +1,4 @@ -import { parseCommandString } from "./command-parser" +import { parse } from "shell-quote" /** * # Command Denylist Feature - Longest Prefix Match Strategy @@ -68,8 +68,40 @@ import { parseCommandString } from "./command-parser" * - Newlines as command separators */ export function parseCommand(command: string): string[] { - const { subCommands } = parseCommandString(command) - return subCommands + if (!command?.trim()) return [] + + try { + const parsed = parse(command) + const commands: string[] = [] + let currentCommand: string[] = [] + + for (const token of parsed) { + if (typeof token === "object" && "op" in token) { + // Chain operator - split command + if (["&&", "||", ";", "|"].includes(token.op)) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + } else { + // Other operators are part of the command + currentCommand.push(token.op) + } + } else if (typeof token === "string") { + currentCommand.push(token) + } + } + + // Add any remaining command + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + } + + return commands + } catch (_error) { + // If shell-quote fails, fall back to simple splitting + return [command] + } } /** diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts index 4ae05b17c88..e68a468b224 100644 --- a/webview-ui/src/utils/commandPatterns.ts +++ b/webview-ui/src/utils/commandPatterns.ts @@ -1,21 +1,14 @@ -import { extractPatternsFromCommand, detectCommandSecurityIssues, SecurityWarning } from "./command-parser" +import { extractPatternsFromCommand } from "./command-parser" export interface CommandPattern { pattern: string description?: string } -// Re-export SecurityWarning type from command-parser -export type { SecurityWarning } - export function extractCommandPatterns(command: string): string[] { return extractPatternsFromCommand(command) } -export function detectSecurityIssues(command: string): SecurityWarning[] { - return detectCommandSecurityIssues(command) -} - /** * Get a human-readable description for a command pattern. * Simply returns the pattern followed by "commands". From 627ea8462d890d33fb45adf1eef802c07a146528 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 24 Jul 2025 11:56:55 -0500 Subject: [PATCH 15/31] fix: stop extracting patterns at command flags and revert command-validation changes --- webview-ui/src/utils/command-parser.ts | 2 +- webview-ui/src/utils/command-validation.ts | 197 ++++++++++++++++++--- 2 files changed, 173 insertions(+), 26 deletions(-) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index d8ad9eebbc0..14a83313ba3 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -41,7 +41,7 @@ export function extractPatternsFromCommand(command: string): string[] { } function isValidToken(token: string): boolean { - return !!token && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/) + return !!token && !token.startsWith("-") && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/) } function extractFromTokens(tokens: string[], patterns: Set): void { diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts index 6396eef1a4d..b403d41d8c2 100644 --- a/webview-ui/src/utils/command-validation.ts +++ b/webview-ui/src/utils/command-validation.ts @@ -1,5 +1,7 @@ import { parse } from "shell-quote" +type ShellToken = string | { op: string } | { command: string } + /** * # Command Denylist Feature - Longest Prefix Match Strategy * @@ -70,38 +72,183 @@ import { parse } from "shell-quote" export function parseCommand(command: string): string[] { if (!command?.trim()) return [] + // Split by newlines first (handle different line ending formats) + // This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac) + const lines = command.split(/\r\n|\r|\n/) + const allCommands: string[] = [] + + for (const line of lines) { + // Skip empty lines + if (!line.trim()) continue + + // Process each line through the existing parsing logic + const lineCommands = parseCommandLine(line) + allCommands.push(...lineCommands) + } + + return allCommands +} + +/** + * Parse a single line of commands (internal helper function) + */ +function parseCommandLine(command: string): string[] { + if (!command?.trim()) return [] + + // Storage for replaced content + const redirections: string[] = [] + const subshells: string[] = [] + const quotes: string[] = [] + const arrayIndexing: string[] = [] + const arithmeticExpressions: string[] = [] + const variables: string[] = [] + const parameterExpansions: string[] = [] + const processSubstitutions: string[] = [] + + // First handle PowerShell redirections by temporarily replacing them + let processedCommand = command.replace(/\d*>&\d*/g, (match) => { + redirections.push(match) + return `__REDIR_${redirections.length - 1}__` + }) + + // Handle arithmetic expressions: $((...)) pattern + // Match the entire arithmetic expression including nested parentheses + processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => { + arithmeticExpressions.push(match) + return `__ARITH_${arithmeticExpressions.length - 1}__` + }) + + // Handle parameter expansions: ${...} patterns (including array indexing) + // This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc. + processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => { + parameterExpansions.push(match) + return `__PARAM_${parameterExpansions.length - 1}__` + }) + + // Handle process substitutions: <(...) and >(...) + processedCommand = processedCommand.replace(/[<>]\([^)]+\)/g, (match) => { + processSubstitutions.push(match) + return `__PROCSUB_${processSubstitutions.length - 1}__` + }) + + // Handle simple variable references: $varname pattern + // This prevents shell-quote from splitting $count into separate tokens + processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => { + variables.push(match) + return `__VAR_${variables.length - 1}__` + }) + + // Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9 + processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => { + variables.push(match) + return `__VAR_${variables.length - 1}__` + }) + + // Then handle subshell commands + processedCommand = processedCommand + .replace(/\$\((.*?)\)/g, (_, inner) => { + subshells.push(inner.trim()) + return `__SUBSH_${subshells.length - 1}__` + }) + .replace(/`(.*?)`/g, (_, inner) => { + subshells.push(inner.trim()) + return `__SUBSH_${subshells.length - 1}__` + }) + + // Then handle quoted strings + processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { + quotes.push(match) + return `__QUOTE_${quotes.length - 1}__` + }) + + let tokens: ShellToken[] try { - const parsed = parse(command) - const commands: string[] = [] - let currentCommand: string[] = [] - - for (const token of parsed) { - if (typeof token === "object" && "op" in token) { - // Chain operator - split command - if (["&&", "||", ";", "|"].includes(token.op)) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } - } else { - // Other operators are part of the command - currentCommand.push(token.op) + tokens = parse(processedCommand) as ShellToken[] + } catch (error: any) { + // If shell-quote fails to parse, fall back to simple splitting + console.warn("shell-quote parse error:", error.message, "for command:", processedCommand) + + // Simple fallback: split by common operators + const fallbackCommands = processedCommand + .split(/(?:&&|\|\||;|\|)/) + .map((cmd) => cmd.trim()) + .filter((cmd) => cmd.length > 0) + + // Restore all placeholders for each command + return fallbackCommands.map((cmd) => { + let result = cmd + // Restore quotes + result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) + // Restore redirections + result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) + // Restore array indexing expressions + result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) + // Restore arithmetic expressions + result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) + // Restore parameter expansions + result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) + // Restore process substitutions + result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)]) + // Restore variable references + result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) + return result + }) + } + + const commands: string[] = [] + let currentCommand: string[] = [] + + for (const token of tokens) { + if (typeof token === "object" && "op" in token) { + // Chain operator - split command + if (["&&", "||", ";", "|"].includes(token.op)) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] } - } else if (typeof token === "string") { + } else { + // Other operators (>, &) are part of the command + currentCommand.push(token.op) + } + } else if (typeof token === "string") { + // Check if it's a subshell placeholder + const subshellMatch = token.match(/__SUBSH_(\d+)__/) + if (subshellMatch) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + commands.push(subshells[parseInt(subshellMatch[1])]) + } else { currentCommand.push(token) } } + } - // Add any remaining command - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - } - - return commands - } catch (_error) { - // If shell-quote fails, fall back to simple splitting - return [command] + // Add any remaining command + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) } + + // Restore quotes and redirections + return commands.map((cmd) => { + let result = cmd + // Restore quotes + result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) + // Restore redirections + result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) + // Restore array indexing expressions + result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) + // Restore arithmetic expressions + result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) + // Restore parameter expansions + result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) + // Restore process substitutions + result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)]) + // Restore variable references + result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) + return result + }) } /** From cbc756d8b518eba4becc97690d0e66e9bd4c64d7 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 24 Jul 2025 12:16:10 -0500 Subject: [PATCH 16/31] refactor: remove commandPatterns.ts and simplify command parsing - Remove unnecessary commandPatterns.ts wrapper module - Use extractPatternsFromCommand directly from command-parser.ts - Simplify command/output parsing logic in CommandExecution.tsx - Move CommandPattern interface to components that use it - All tests passing --- .../src/components/chat/CommandExecution.tsx | 56 ++- .../chat/CommandPatternSelector.tsx | 6 +- .../chat/__tests__/CommandExecution.spec.tsx | 13 - .../__tests__/CommandPatternSelector.spec.tsx | 6 +- .../utils/__tests__/commandPatterns.spec.ts | 422 ------------------ webview-ui/src/utils/commandPatterns.ts | 101 ----- 6 files changed, 44 insertions(+), 560 deletions(-) delete mode 100644 webview-ui/src/utils/__tests__/commandPatterns.spec.ts delete mode 100644 webview-ui/src/utils/commandPatterns.ts diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 673bcca00b8..cf0702e3022 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -13,12 +13,12 @@ import { cn } from "@src/lib/utils" import { Button } from "@src/components/ui" import CodeBlock from "../common/CodeBlock" import { CommandPatternSelector } from "./CommandPatternSelector" -import { - extractCommandPatterns, - getPatternDescription, - parseCommandAndOutput, - CommandPattern, -} from "../../utils/commandPatterns" +import { extractPatternsFromCommand } from "../../utils/command-parser" + +interface CommandPattern { + pattern: string + description?: string +} interface CommandExecutionProps { executionId: string @@ -37,8 +37,28 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec } = useExtensionState() const { command, output: parsedOutput } = useMemo(() => { - // Use the enhanced parser from commandPatterns - return parseCommandAndOutput(text || "") + // Parse command and output using the "Output:" separator + const outputSeparator = "Output:" + const outputIndex = text?.indexOf(`\n${outputSeparator}`) ?? -1 + + if (outputIndex !== -1) { + // Text is split into command and output + const cmd = text!.slice(0, outputIndex).trim() + // Skip the newline and "Output:" text + const afterSeparator = outputIndex + 1 + outputSeparator.length + let startOfOutput = afterSeparator + if (text![afterSeparator] === "\n") { + startOfOutput = afterSeparator + 1 + } + const out = text!.slice(startOfOutput).trim() + return { command: cmd, output: out } + } else if (text?.indexOf(outputSeparator) === 0) { + // Edge case: text starts with "Output:" (no command) + return { command: "", output: text.slice(outputSeparator.length).trim() } + } else { + // No output separator found, the entire text is the command + return { command: text?.trim() || "", output: "" } + } }, [text]) // If we aren't opening the VSCode terminal for this command then we default @@ -54,20 +74,12 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec // Extract command patterns from the actual command that was executed const commandPatterns = useMemo(() => { - const patterns: CommandPattern[] = [] - - // Always extract patterns from the actual command that was executed - // We don't use AI suggestions because the patterns should reflect - // what was actually executed, not what the AI thinks might be useful - const extractedPatterns = extractCommandPatterns(command) - extractedPatterns.forEach((pattern) => { - patterns.push({ - pattern, - description: getPatternDescription(pattern), - }) - }) - - return patterns + // Extract patterns from the actual command that was executed + const extractedPatterns = extractPatternsFromCommand(command) + return extractedPatterns.map((pattern) => ({ + pattern, + description: `${pattern} commands`, + })) }, [command]) // Handle pattern changes diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index 17799a8aecc..ac87b90cbcc 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -3,9 +3,13 @@ import { Check, ChevronDown, Info, X } from "lucide-react" import { cn } from "../../lib/utils" import { useTranslation, Trans } from "react-i18next" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import { CommandPattern } from "../../utils/commandPatterns" import { StandardTooltip } from "../ui/standard-tooltip" +interface CommandPattern { + pattern: string + description?: string +} + interface CommandPatternSelectorProps { patterns: CommandPattern[] allowedCommands: string[] diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index 7b584d75e79..ed9e0034ea9 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -21,19 +21,6 @@ vi.mock("../../common/CodeBlock", () => ({ default: ({ source }: { source: string }) =>
{source}
, })) -// Mock the commandPatterns module but use the actual implementation -vi.mock("../../../utils/commandPatterns", async () => { - const actual = await vi.importActual( - "../../../utils/commandPatterns", - ) - return { - ...actual, - parseCommandAndOutput: actual.parseCommandAndOutput, - extractCommandPatterns: actual.extractCommandPatterns, - getPatternDescription: actual.getPatternDescription, - } -}) - vi.mock("../CommandPatternSelector", () => ({ CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => (
diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx index d48d16c2d2b..815d4dfbfbd 100644 --- a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -2,9 +2,13 @@ import React from "react" import { render, screen, fireEvent } from "@testing-library/react" import { describe, it, expect, vi } from "vitest" import { CommandPatternSelector } from "../CommandPatternSelector" -import { CommandPattern } from "../../../utils/commandPatterns" import { TooltipProvider } from "../../../components/ui/tooltip" +interface CommandPattern { + pattern: string + description?: string +} + // Mock react-i18next vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts b/webview-ui/src/utils/__tests__/commandPatterns.spec.ts deleted file mode 100644 index ff563be1ac1..00000000000 --- a/webview-ui/src/utils/__tests__/commandPatterns.spec.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { describe, it, expect } from "vitest" -import { extractCommandPatterns, getPatternDescription, parseCommandAndOutput } from "../commandPatterns" - -describe("extractCommandPatterns", () => { - it("should extract simple command", () => { - const patterns = extractCommandPatterns("ls") - expect(patterns).toEqual(["ls"]) - }) - - it("should extract command with arguments", () => { - const patterns = extractCommandPatterns("npm install express") - expect(patterns).toEqual(["npm", "npm install", "npm install express"]) - }) - - it("should handle piped commands", () => { - const patterns = extractCommandPatterns("ls -la | grep test") - expect(patterns).toContain("ls") - expect(patterns).toContain("grep") - expect(patterns).toContain("grep test") - }) - - it("should handle chained commands with &&", () => { - const patterns = extractCommandPatterns("npm install && npm run build") - expect(patterns).toContain("npm") - expect(patterns).toContain("npm install") - expect(patterns).toContain("npm run") - expect(patterns).toContain("npm run build") - }) - - it("should handle chained commands with ||", () => { - const patterns = extractCommandPatterns("npm test || npm run test:ci") - expect(patterns).toContain("npm") - expect(patterns).toContain("npm test") - expect(patterns).toContain("npm run") - expect(patterns).not.toContain("npm run test:ci") - }) - - it("should handle semicolon separated commands", () => { - const patterns = extractCommandPatterns("cd src; npm install") - expect(patterns).toContain("cd") - expect(patterns).toContain("cd src") - expect(patterns).toContain("npm") - expect(patterns).toContain("npm install") - }) - - it("should stop at flags", () => { - const patterns = extractCommandPatterns('git commit -m "test message"') - expect(patterns).toContain("git") - expect(patterns).toContain("git commit") - expect(patterns).not.toContain("git commit -m") - }) - - it("should stop at paths with slashes", () => { - const patterns = extractCommandPatterns("cd /usr/local/bin") - expect(patterns).toContain("cd") - expect(patterns).not.toContain("cd /usr/local/bin") - }) - - it("should handle empty or null input", () => { - expect(extractCommandPatterns("")).toEqual([]) - expect(extractCommandPatterns(" ")).toEqual([]) - expect(extractCommandPatterns(null as any)).toEqual([]) - expect(extractCommandPatterns(undefined as any)).toEqual([]) - }) - - it("should handle complex command with multiple operators", () => { - const patterns = extractCommandPatterns('npm install && npm test | grep success || echo "failed"') - expect(patterns).toContain("npm") - expect(patterns).toContain("npm install") - expect(patterns).toContain("npm test") - expect(patterns).toContain("grep") - expect(patterns).toContain("grep success") - expect(patterns).toContain("echo") - }) - - it("should handle malformed commands gracefully", () => { - const patterns = extractCommandPatterns("npm install && ") - expect(patterns).toContain("npm") - expect(patterns).toContain("npm install") - }) - - it("should extract main command even if parsing fails", () => { - // Create a command that might cause parsing issues - const patterns = extractCommandPatterns('echo "unclosed quote') - expect(patterns).toContain("echo") - }) - - it("should handle commands with special characters in arguments", () => { - const patterns = extractCommandPatterns("git add .") - expect(patterns).toContain("git") - expect(patterns).toContain("git add") - expect(patterns).not.toContain("git add .") // dot is excluded - }) - - it("should return sorted patterns", () => { - const patterns = extractCommandPatterns("npm run build && git push") - expect(patterns).toEqual(["git", "git push", "npm", "npm run", "npm run build"]) - }) - - it("should handle numeric input like '0 total'", () => { - const patterns = extractCommandPatterns("0 total") - // Should return empty array since "0" is not a valid command - expect(patterns).toEqual([]) - }) - - it("should handle pure numeric commands", () => { - const patterns = extractCommandPatterns("0") - // Should return empty array since pure numbers are not valid commands - expect(patterns).toEqual([]) - }) -}) - -describe("getPatternDescription", () => { - it("should return pattern followed by commands", () => { - expect(getPatternDescription("cd")).toBe("cd commands") - expect(getPatternDescription("npm")).toBe("npm commands") - expect(getPatternDescription("npm install")).toBe("npm install commands") - expect(getPatternDescription("git")).toBe("git commands") - expect(getPatternDescription("git push")).toBe("git push commands") - expect(getPatternDescription("python")).toBe("python commands") - }) - - it("should handle any command pattern", () => { - expect(getPatternDescription("unknowncommand")).toBe("unknowncommand commands") - expect(getPatternDescription("custom-tool")).toBe("custom-tool commands") - }) - - it("should handle package managers", () => { - expect(getPatternDescription("yarn")).toBe("yarn commands") - expect(getPatternDescription("pnpm")).toBe("pnpm commands") - expect(getPatternDescription("bun")).toBe("bun commands") - }) - - it("should handle build tools", () => { - expect(getPatternDescription("make")).toBe("make commands") - expect(getPatternDescription("cmake")).toBe("cmake commands") - expect(getPatternDescription("cargo")).toBe("cargo commands") - expect(getPatternDescription("go build")).toBe("go build commands") - }) -}) - -describe("parseCommandAndOutput", () => { - it("should handle command with $ prefix without Output: separator", () => { - const text = "$ npm install\nInstalling packages..." - const result = parseCommandAndOutput(text) - // Without Output: separator, the entire text is treated as command - expect(result.command).toBe("$ npm install\nInstalling packages...") - expect(result.output).toBe("") - }) - - it("should handle command with ❯ prefix without Output: separator", () => { - const text = "❯ git status\nOn branch main" - const result = parseCommandAndOutput(text) - // Without Output: separator, the entire text is treated as command - expect(result.command).toBe("❯ git status\nOn branch main") - expect(result.output).toBe("") - }) - - it("should handle command with > prefix without Output: separator", () => { - const text = "> echo hello\nhello" - const result = parseCommandAndOutput(text) - // Without Output: separator, the entire text is treated as command - expect(result.command).toBe("> echo hello\nhello") - expect(result.output).toBe("") - }) - - it("should return original text if no command prefix found", () => { - const text = "npm install" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm install") - expect(result.output).toBe("") - }) - - it("should extract AI suggestions from output with Output: separator", () => { - const text = "npm install\nOutput:\nSuggested patterns: npm, npm install, npm run" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm install") - expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) - }) - - it("should extract suggestions with different formats", () => { - const text = "git push\nOutput:\nCommand patterns: git, git push" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("git push") - expect(result.suggestions).toEqual(["git", "git push"]) - }) - - it('should extract suggestions from "you can allow" format', () => { - const text = "docker run\nOutput:\nYou can allow: docker, docker run" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("docker run") - expect(result.suggestions).toEqual(["docker", "docker run"]) - }) - - it("should extract suggestions from bullet points", () => { - const text = `npm test -Output: -Output here... -- npm -- npm test -- npm run` - const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm test") - expect(result.suggestions).toContain("npm") - expect(result.suggestions).toContain("npm test") - expect(result.suggestions).toContain("npm run") - }) - - it("should extract suggestions from various bullet formats", () => { - const text = `command -Output: -• npm -* git -- docker -▪ python` - const result = parseCommandAndOutput(text) - expect(result.command).toBe("command") - expect(result.suggestions).toContain("npm") - expect(result.suggestions).toContain("git") - expect(result.suggestions).toContain("docker") - expect(result.suggestions).toContain("python") - }) - - it("should extract suggestions with backticks", () => { - const text = "npm install\nOutput:\n- `npm`\n- `npm install`" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm install") - expect(result.suggestions).toContain("npm") - expect(result.suggestions).toContain("npm install") - }) - - it("should handle empty text", () => { - const result = parseCommandAndOutput("") - expect(result.command).toBe("") - expect(result.output).toBe("") - expect(result.suggestions).toEqual([]) - }) - - it("should handle multiline commands without Output: separator", () => { - const text = `$ npm install \\ - express \\ - mongoose -Installing...` - const result = parseCommandAndOutput(text) - // Without Output: separator, entire text is treated as command - expect(result.command).toBe(text) - expect(result.output).toBe("") - }) - - it("should include all suggestions from comma-separated list with Output: separator", () => { - const text = "test\nOutput:\nSuggested patterns: npm, npm install, npm run" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("test") - expect(result.suggestions).toEqual(["npm", "npm install", "npm run"]) - }) - - it("should handle case variations in suggestion patterns", () => { - const text = "test\nOutput:\nSuggested Patterns: npm, git\nCommand Patterns: docker" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("test") - // Now it should accumulate all suggestions - expect(result.suggestions).toContain("npm") - expect(result.suggestions).toContain("git") - expect(result.suggestions).toContain("docker") - }) - - it("should handle text already split by Output:", () => { - const text = "npm install && cd backend\nOutput:\ngithub-pr-contributors-tracker@1.0.0 prepare" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm install && cd backend") - expect(result.output).toBe("github-pr-contributors-tracker@1.0.0 prepare") - }) - - it("should preserve original command when Output: separator is present", () => { - const text = "npm install\nOutput:\n$ npm install\nInstalling packages..." - const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm install") - expect(result.output).toBe("$ npm install\nInstalling packages...") - }) - - it("should handle Output: separator with no output", () => { - const text = "ls -la\nOutput:" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("ls -la") - expect(result.output).toBe("") - }) - - it("should handle Output: separator with whitespace", () => { - const text = "git status\nOutput: \n On branch main " - const result = parseCommandAndOutput(text) - expect(result.command).toBe("git status") - expect(result.output).toBe("On branch main") - }) - - it("should only use first Output: occurrence as separator", () => { - const text = 'echo "test"\nOutput:\nFirst output\nOutput: Second output' - const result = parseCommandAndOutput(text) - expect(result.command).toBe('echo "test"') - expect(result.output).toBe("First output\nOutput: Second output") - }) - - it("should handle output with numbers at the start of lines", () => { - const text = `wc -l *.go *.java -Output: -25 hello_world.go -316 HelloWorld.java -341 total` - const result = parseCommandAndOutput(text) - expect(result.command).toBe("wc -l *.go *.java") - expect(result.output).toBe("25 hello_world.go\n316 HelloWorld.java\n341 total") - expect(result.suggestions).toEqual([]) - }) - - it("should handle edge case where text starts with Output:", () => { - const text = "Output:\nSome output without a command" - const result = parseCommandAndOutput(text) - expect(result.command).toBe("") - expect(result.output).toBe("Some output without a command") - }) - - it("should not be confused by Output: appearing in the middle of output", () => { - const text = `echo "Output: test" -Output: -Output: test` - const result = parseCommandAndOutput(text) - expect(result.command).toBe('echo "Output: test"') - expect(result.output).toBe("Output: test") - }) - - it("should handle commands without shell prompt when Output: separator is present", () => { - const text = `npm install -Output: -Installing packages...` - const result = parseCommandAndOutput(text) - expect(result.command).toBe("npm install") - expect(result.output).toBe("Installing packages...") - }) - - it("should not parse shell prompts from output when Output: separator exists", () => { - const text = `ls -la -Output: -$ total 341 -drwxr-xr-x 10 user staff 320 Jan 22 12:00 . -drwxr-xr-x 20 user staff 640 Jan 22 11:00 ..` - const result = parseCommandAndOutput(text) - expect(result.command).toBe("ls -la") - expect(result.output).toContain("$ total 341") - expect(result.output).toContain("drwxr-xr-x") - }) -}) - -describe("integration: parseCommandAndOutput with extractCommandPatterns", () => { - it("should not extract patterns from output text", () => { - const text = `wc -l *.go *.java -Output: -wc: *.go: open: No such file or directory -wc: *.java: open: No such file or directory -0 total` - const { command } = parseCommandAndOutput(text) - const patterns = extractCommandPatterns(command) - - // Should only extract patterns from the command, not the output - expect(patterns).toContain("wc") - expect(patterns).not.toContain("0") - expect(patterns).not.toContain("total") - expect(patterns).not.toContain("0 total") - }) - - it("should handle the specific wc command case", () => { - const text = `wc -l *.go *.java -Output: -25 hello_world.go -316 HelloWorld.java -341 total` - const { command } = parseCommandAndOutput(text) - const patterns = extractCommandPatterns(command) - - // Should only extract "wc" from the command - expect(patterns).toEqual(["wc"]) - expect(patterns).not.toContain("341") - expect(patterns).not.toContain("total") - expect(patterns).not.toContain("341 total") - }) - - it("should handle wc command with error output", () => { - const text = `wc -l *.go *.java -Output: -wc: *.go: open: No such file or directory -wc: *.java: open: No such file or directory -0 total` - const { command, output } = parseCommandAndOutput(text) - const patterns = extractCommandPatterns(command) - - // Should only extract "wc" from the command - expect(command).toBe("wc -l *.go *.java") - expect(output).toContain("0 total") - expect(patterns).toEqual(["wc"]) - expect(patterns).not.toContain("0") - expect(patterns).not.toContain("total") - expect(patterns).not.toContain("0 total") - }) - - it("should handle case where only output line is provided", () => { - // This simulates if somehow only "0 total" is passed as the text - const text = "0 total" - const { command } = parseCommandAndOutput(text) - const patterns = extractCommandPatterns(command) - - // In this case, the entire text is treated as command - expect(command).toBe("0 total") - // But "0 total" is not a valid command pattern (starts with number) - expect(patterns).toEqual([]) - }) - - it("should handle commands without output separator", () => { - const text = "npm install" - const { command } = parseCommandAndOutput(text) - const patterns = extractCommandPatterns(command) - - expect(patterns).toEqual(["npm", "npm install"]) - }) -}) diff --git a/webview-ui/src/utils/commandPatterns.ts b/webview-ui/src/utils/commandPatterns.ts deleted file mode 100644 index e68a468b224..00000000000 --- a/webview-ui/src/utils/commandPatterns.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { extractPatternsFromCommand } from "./command-parser" - -export interface CommandPattern { - pattern: string - description?: string -} - -export function extractCommandPatterns(command: string): string[] { - return extractPatternsFromCommand(command) -} - -/** - * Get a human-readable description for a command pattern. - * Simply returns the pattern followed by "commands". - */ -export function getPatternDescription(pattern: string): string { - return `${pattern} commands` -} - -export function parseCommandAndOutput(text: string): { - command: string - output: string - suggestions: string[] -} { - // Default result - const result = { - command: text, - output: "", - suggestions: [] as string[], - } - - // First check if the text already has been split by COMMAND_OUTPUT_STRING - // This happens when the command has already been executed and we have the output - const outputSeparator = "Output:" - const outputIndex = text.indexOf(`\n${outputSeparator}`) - - if (outputIndex !== -1) { - // Text is already split into command and output - // The command is everything before the output separator - result.command = text.slice(0, outputIndex).trim() - // The output is everything after the output separator - // We need to skip the newline and "Output:" text - const afterNewline = outputIndex + 1 // Skip the newline - const afterSeparator = afterNewline + outputSeparator.length // Skip "Output:" - // Check if there's a colon and potential space after it - let startOfOutput = afterSeparator - if (text[afterSeparator] === "\n") { - startOfOutput = afterSeparator + 1 // Skip additional newline after "Output:" - } - result.output = text.slice(startOfOutput).trim() - } else if (text.indexOf(outputSeparator) === 0) { - // Edge case: text starts with "Output:" (no command) - result.command = "" - result.output = text.slice(outputSeparator.length).trim() - } else { - // No output separator found, the entire text is the command - result.command = text.trim() - result.output = "" - } - - // Look for AI suggestions in the output - // These might be in a format like: - // "Suggested patterns: npm, npm install, npm run" - // or as a list - const suggestionPatterns = [ - /Suggested patterns?:\s*(.+?)(?:\n|$)/i, - /Command patterns?:\s*(.+?)(?:\n|$)/i, - /You (?:can|may|might) (?:want to )?(?:allow|add):\s*(.+?)(?:\n|$)/i, - ] - - for (const pattern of suggestionPatterns) { - const match = result.output.match(pattern) - if (match) { - // Split by common delimiters and clean up - const suggestions = match[1] - .split(/[,;]/) - .map((s) => s.trim()) - .filter((s) => s) // Allow multi-word patterns like "npm install" - - if (suggestions.length > 0) { - // Add to existing suggestions instead of replacing - result.suggestions.push(...suggestions) - } - } - } - - // Remove duplicates - result.suggestions = Array.from(new Set(result.suggestions)) - - // Also look for bullet points or numbered lists - // const listPattern = /^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/gm - const lines = result.output.split("\n") - for (const line of lines) { - const match = line.match(/^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/) - if (match && match[1] && !result.suggestions.includes(match[1])) { - result.suggestions.push(match[1]) - } - } - - return result -} From 7799f0cabfe0ab99f21d84bf3368274d0420a2d5 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 24 Jul 2025 12:26:42 -0500 Subject: [PATCH 17/31] refactor: remove automatic 'commands' description suffix from patterns --- webview-ui/src/components/chat/CommandExecution.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index cf0702e3022..e485ed5bc07 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -78,7 +78,6 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec const extractedPatterns = extractPatternsFromCommand(command) return extractedPatterns.map((pattern) => ({ pattern, - description: `${pattern} commands`, })) }, [command]) From 9481c95bbe95fa0d4a7adb6bf08ef2a83419c978 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 24 Jul 2025 12:37:44 -0500 Subject: [PATCH 18/31] Update webview-ui/src/components/chat/CommandExecution.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- webview-ui/src/components/chat/CommandExecution.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index e485ed5bc07..79d2b54edb2 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -43,7 +43,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec if (outputIndex !== -1) { // Text is split into command and output - const cmd = text!.slice(0, outputIndex).trim() + const cmd = (text ?? '').slice(0, outputIndex).trim() // Skip the newline and "Output:" text const afterSeparator = outputIndex + 1 + outputSeparator.length let startOfOutput = afterSeparator From 63ce9e7a9bcfc7977d790ae8f232b5732cd99594 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 24 Jul 2025 12:38:25 -0500 Subject: [PATCH 19/31] Update webview-ui/src/components/chat/CommandPatternSelector.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- webview-ui/src/components/chat/CommandPatternSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index ac87b90cbcc..c830ae5be86 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -84,7 +84,7 @@ export const CommandPatternSelector: React.FC = ({ {patterns.map((item, index) => { const status = getPatternStatus(item.pattern) return ( -
+
{item.pattern} {item.description && ( From d819ae57b7d1c2eb333a2d5bc871959a78ca0fb0 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 24 Jul 2025 12:47:49 -0500 Subject: [PATCH 20/31] fix: remove unused index parameter in CommandPatternSelector --- webview-ui/src/components/chat/CommandPatternSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index c830ae5be86..8d3acf71c4d 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -81,7 +81,7 @@ export const CommandPatternSelector: React.FC = ({ {isExpanded && (
- {patterns.map((item, index) => { + {patterns.map((item) => { const status = getPatternStatus(item.pattern) return (
From ea360590af2e8e33393a4f7da883e8152de28d99 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 24 Jul 2025 12:57:51 -0500 Subject: [PATCH 21/31] fix: improve error handling in extractPatternsFromCommand function --- webview-ui/src/utils/command-parser.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index 14a83313ba3..14a4480ae79 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -31,7 +31,8 @@ export function extractPatternsFromCommand(command: string): string[] { if (currentTokens.length > 0) { extractFromTokens(currentTokens, patterns) } - } catch (_error) { + } catch (error) { + console.warn("Failed to parse command:", error) // Fallback: just extract the first word const firstWord = command.trim().split(/\s+/)[0] if (firstWord) patterns.add(firstWord) From 82350297a4012759b0ffffc93425e17b4081777d Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 08:23:43 -0500 Subject: [PATCH 22/31] feat: simplify command permissions UI to use full commands instead of patterns - Replace pattern extraction with single text input for full command - Allow users to edit command before approving/denying - Show active state (check/x) based on current allowed/denied lists - Remove command-parser.ts and related pattern extraction logic - Update tests to match new simplified behavior This change addresses user feedback requesting a simpler interface where users can edit the full command before setting permissions. --- .../src/components/chat/CommandExecution.tsx | 43 ++-- .../chat/CommandPatternSelector.tsx | 115 ++++----- .../chat/__tests__/CommandExecution.spec.tsx | 232 ++++++++---------- .../__tests__/CommandPatternSelector.spec.tsx | 168 ++++++++++--- .../utils/__tests__/command-parser.spec.ts | 137 ----------- webview-ui/src/utils/command-parser.ts | 68 ----- 6 files changed, 304 insertions(+), 459 deletions(-) delete mode 100644 webview-ui/src/utils/__tests__/command-parser.spec.ts delete mode 100644 webview-ui/src/utils/command-parser.ts diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 79d2b54edb2..50c52abd278 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -13,12 +13,6 @@ import { cn } from "@src/lib/utils" import { Button } from "@src/components/ui" import CodeBlock from "../common/CodeBlock" import { CommandPatternSelector } from "./CommandPatternSelector" -import { extractPatternsFromCommand } from "../../utils/command-parser" - -interface CommandPattern { - pattern: string - description?: string -} interface CommandExecutionProps { executionId: string @@ -43,7 +37,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec if (outputIndex !== -1) { // Text is split into command and output - const cmd = (text ?? '').slice(0, outputIndex).trim() + const cmd = (text ?? "").slice(0, outputIndex).trim() // Skip the newline and "Output:" text const afterSeparator = outputIndex + 1 + outputSeparator.length let startOfOutput = afterSeparator @@ -72,20 +66,11 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec // streaming output (this is the case for running commands). const output = streamingOutput || parsedOutput - // Extract command patterns from the actual command that was executed - const commandPatterns = useMemo(() => { - // Extract patterns from the actual command that was executed - const extractedPatterns = extractPatternsFromCommand(command) - return extractedPatterns.map((pattern) => ({ - pattern, - })) - }, [command]) - - // Handle pattern changes - const handleAllowPatternChange = (pattern: string) => { - const isAllowed = allowedCommands.includes(pattern) - const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern] - const newDenied = deniedCommands.filter((p) => p !== pattern) + // Handle command changes + const handleAllowCommandChange = (cmd: string) => { + const isAllowed = allowedCommands.includes(cmd) + const newAllowed = isAllowed ? allowedCommands.filter((c) => c !== cmd) : [...allowedCommands, cmd] + const newDenied = deniedCommands.filter((c) => c !== cmd) setAllowedCommands(newAllowed) setDeniedCommands(newDenied) @@ -93,10 +78,10 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec vscode.postMessage({ type: "deniedCommands", commands: newDenied }) } - const handleDenyPatternChange = (pattern: string) => { - const isDenied = deniedCommands.includes(pattern) - const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern] - const newAllowed = allowedCommands.filter((p) => p !== pattern) + const handleDenyCommandChange = (cmd: string) => { + const isDenied = deniedCommands.includes(cmd) + const newDenied = isDenied ? deniedCommands.filter((c) => c !== cmd) : [...deniedCommands, cmd] + const newAllowed = allowedCommands.filter((c) => c !== cmd) setAllowedCommands(newAllowed) setDeniedCommands(newDenied) @@ -193,13 +178,13 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
- {commandPatterns.length > 0 && ( + {command && ( )}
diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index 8d3acf71c4d..cadc90c4433 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -5,35 +5,33 @@ import { useTranslation, Trans } from "react-i18next" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { StandardTooltip } from "../ui/standard-tooltip" -interface CommandPattern { - pattern: string - description?: string -} - interface CommandPatternSelectorProps { - patterns: CommandPattern[] + command: string allowedCommands: string[] deniedCommands: string[] - onAllowPatternChange: (pattern: string) => void - onDenyPatternChange: (pattern: string) => void + onAllowCommandChange: (command: string) => void + onDenyCommandChange: (command: string) => void } export const CommandPatternSelector: React.FC = ({ - patterns, + command, allowedCommands, deniedCommands, - onAllowPatternChange, - onDenyPatternChange, + onAllowCommandChange, + onDenyCommandChange, }) => { const { t } = useTranslation() const [isExpanded, setIsExpanded] = useState(false) + const [editedCommand, setEditedCommand] = useState(command) - const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => { - if (allowedCommands.includes(pattern)) return "allowed" - if (deniedCommands.includes(pattern)) return "denied" + const getCommandStatus = (cmd: string): "allowed" | "denied" | "none" => { + if (allowedCommands.includes(cmd)) return "allowed" + if (deniedCommands.includes(cmd)) return "denied" return "none" } + const currentStatus = getCommandStatus(editedCommand) + return (
{isExpanded && ( -
- {patterns.map((item) => { - const status = getPatternStatus(item.pattern) - return ( -
-
- {item.pattern} - {item.description && ( - - - {item.description} - - )} -
-
- - -
-
- ) - })} +
+
+
+ setEditedCommand(e.target.value)} + className="font-mono text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1 w-full focus:outline-none focus:border-vscode-focusBorder" + placeholder={command} + /> +
+
+ + +
+
)}
diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index ed9e0034ea9..23f53ccd0df 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -22,15 +22,11 @@ vi.mock("../../common/CodeBlock", () => ({ })) vi.mock("../CommandPatternSelector", () => ({ - CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => ( + CommandPatternSelector: ({ command, onAllowCommandChange, onDenyCommandChange }: any) => (
- {patterns.map((p: any, i: number) => ( -
- {p.pattern} - - -
- ))} + {command} + +
), })) @@ -88,7 +84,7 @@ describe("CommandExecution", () => { expect(screen.getByTestId("custom-title")).toBeInTheDocument() }) - it("should show command pattern selector for simple commands", () => { + it("should show command pattern selector for commands", () => { render( @@ -96,72 +92,85 @@ describe("CommandExecution", () => { ) expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("npm")).toBeInTheDocument() - expect(screen.getByText("npm install")).toBeInTheDocument() + expect(screen.getByText("npm install express")).toBeInTheDocument() }) - it("should handle allow pattern change", () => { + it("should handle allow command change", () => { render( , ) - const allowButton = screen.getByText("Allow git") + const allowButton = screen.getByText("Allow git push") fireEvent.click(allowButton) - expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git"]) + expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git push"]) expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) - expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git"] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git push"] }) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) }) - it("should handle deny pattern change", () => { + it("should handle deny command change", () => { render( , ) - const denyButton = screen.getByText("Deny docker") + const denyButton = screen.getByText("Deny docker run") fireEvent.click(denyButton) expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) - expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker"]) + expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker run"]) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) - expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker"] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker run"] }) }) - it("should toggle allowed pattern", () => { + it("should toggle allowed command", () => { + // Update the mock state to have "npm test" in allowedCommands + const stateWithNpmTest = { + ...mockExtensionState, + allowedCommands: ["npm test"], + deniedCommands: ["rm"], + } + render( - + - , + , ) - const allowButton = screen.getByText("Allow npm") + const allowButton = screen.getByText("Allow npm test") fireEvent.click(allowButton) - // npm is already in allowedCommands, so it should be removed - expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith([]) - expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) + // "npm test" is already in allowedCommands, so it should be removed + expect(stateWithNpmTest.setAllowedCommands).toHaveBeenCalledWith([]) + expect(stateWithNpmTest.setDeniedCommands).toHaveBeenCalledWith(["rm"]) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: [] }) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) }) - it("should toggle denied pattern", () => { + it("should toggle denied command", () => { + // Update the mock state to have "rm -rf" in deniedCommands + const stateWithRmRf = { + ...mockExtensionState, + allowedCommands: ["npm"], + deniedCommands: ["rm -rf"], + } + render( - + - , + , ) - const denyButton = screen.getByText("Deny rm") + const denyButton = screen.getByText("Deny rm -rf") fireEvent.click(denyButton) - // rm is already in deniedCommands, so it should be removed - expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) - expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([]) + // "rm -rf" is already in deniedCommands, so it should be removed + expect(stateWithRmRf.setAllowedCommands).toHaveBeenCalledWith(["npm"]) + expect(stateWithRmRf.setDeniedCommands).toHaveBeenCalledWith([]) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) }) @@ -181,7 +190,7 @@ Installing...` expect(codeBlocks[0]).toHaveTextContent("npm install") }) - it("should parse command with AI suggestions", () => { + it("should parse command with output", () => { const commandText = `npm install Output: Suggested patterns: npm, npm install, npm run` @@ -198,11 +207,8 @@ Suggested patterns: npm, npm install, npm run` expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run") expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - // Check that only patterns from the actual command are extracted, not from AI suggestions - expect(screen.getByText("npm")).toBeInTheDocument() - expect(screen.getAllByText("npm install").length).toBeGreaterThan(0) - // "npm run" should NOT be in the patterns since it's only in the AI suggestions, not the actual command - expect(screen.queryByText("npm run")).not.toBeInTheDocument() + // Should show the full command + expect(screen.getByText("npm install")).toBeInTheDocument() }) it("should handle commands with pipes", () => { @@ -213,8 +219,7 @@ Suggested patterns: npm, npm install, npm run` ) expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("ls")).toBeInTheDocument() - expect(screen.getByText("grep")).toBeInTheDocument() + expect(screen.getByText("ls -la | grep test")).toBeInTheDocument() }) it("should handle commands with && operator", () => { @@ -225,9 +230,7 @@ Suggested patterns: npm, npm install, npm run` ) expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("npm")).toBeInTheDocument() - expect(screen.getByText("npm install")).toBeInTheDocument() - expect(screen.getByText("npm test")).toBeInTheDocument() + expect(screen.getByText("npm install && npm test")).toBeInTheDocument() }) it("should not show pattern selector for empty commands", () => { @@ -279,25 +282,32 @@ Output here` expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() }) - it("should handle pattern change when moving from denied to allowed", () => { + it("should handle command change when moving from denied to allowed", () => { + // Update the mock state to have "rm file.txt" in deniedCommands + const stateWithRmInDenied = { + ...mockExtensionState, + allowedCommands: ["npm"], + deniedCommands: ["rm file.txt"], + } + render( - + - , + , ) - const allowButton = screen.getByText("Allow rm") + const allowButton = screen.getByText("Allow rm file.txt") fireEvent.click(allowButton) - // rm should be removed from denied and added to allowed - expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm"]) - expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([]) - expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm"] }) + // "rm file.txt" should be removed from denied and added to allowed + expect(stateWithRmInDenied.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm file.txt"]) + expect(stateWithRmInDenied.setDeniedCommands).toHaveBeenCalledWith([]) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm file.txt"] }) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] }) }) describe("integration with CommandPatternSelector", () => { - it("should extract patterns from complex commands with multiple operators", () => { + it("should show complex commands with multiple operators", () => { render( @@ -306,23 +316,20 @@ Output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - expect(screen.getByText("npm")).toBeInTheDocument() - expect(screen.getByText("npm install")).toBeInTheDocument() - expect(screen.getByText("npm test")).toBeInTheDocument() - expect(screen.getByText("echo")).toBeInTheDocument() + expect(screen.getByText("npm install && npm test || echo 'failed'")).toBeInTheDocument() }) - it("should handle commands with malformed suggestions gracefully", () => { - const commandWithMalformedSuggestions = `npm install + it("should handle commands with output", () => { + const commandWithOutput = `npm install Output: -Suggested patterns: npm, , npm install, +Installing packages... Other output here` render( icon} title={Run Command} /> @@ -331,12 +338,11 @@ Other output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should still show valid patterns - expect(screen.getAllByText("npm")[0]).toBeInTheDocument() - expect(screen.getAllByText("npm install")[0]).toBeInTheDocument() + // Should show the command + expect(screen.getByText("npm install")).toBeInTheDocument() }) - it("should handle commands with subshells by not including them in patterns", () => { + it("should handle commands with subshells", () => { render( @@ -345,11 +351,7 @@ Other output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - expect(screen.getByText("echo")).toBeInTheDocument() - expect(screen.getByText("git")).toBeInTheDocument() - expect(screen.getByText("git status")).toBeInTheDocument() - // Should not include subshell content - expect(screen.queryByText("whoami")).not.toBeInTheDocument() + expect(screen.getByText("echo $(whoami) && git status")).toBeInTheDocument() }) it("should handle commands with backtick subshells", () => { @@ -361,13 +363,10 @@ Other output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - expect(screen.getByText("git")).toBeInTheDocument() - expect(screen.getByText("git commit")).toBeInTheDocument() - // Should not include subshell content - expect(screen.queryByText("date")).not.toBeInTheDocument() + expect(screen.getByText("git commit -m `date`")).toBeInTheDocument() }) - it("should handle pattern changes for commands with special characters", () => { + it("should handle commands with special characters", () => { render( @@ -376,22 +375,15 @@ Other output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - expect(screen.getByText("cd")).toBeInTheDocument() - expect(screen.getByText("npm")).toBeInTheDocument() - expect(screen.getByText("npm start")).toBeInTheDocument() + expect(screen.getByText("cd ~/projects && npm start")).toBeInTheDocument() }) - it("should handle commands with mixed content including output and suggestions", () => { + it("should handle commands with mixed content including output", () => { const commandWithMixedContent = `npm test Output: Running tests... ✓ Test 1 passed -✓ Test 2 passed - -Suggested patterns: npm, npm test, npm run -- npm -- npm test -- npm run test` +✓ Test 2 passed` render( @@ -406,18 +398,15 @@ Suggested patterns: npm, npm test, npm run const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should show patterns only from the actual command, not from AI suggestions - expect(screen.getAllByText("npm")[0]).toBeInTheDocument() - expect(screen.getAllByText("npm test")[0]).toBeInTheDocument() - // "npm run" should NOT be in the patterns since it's only in the AI suggestions - expect(screen.queryByText("npm run")).not.toBeInTheDocument() + // Should show the command + expect(screen.getByText("npm test")).toBeInTheDocument() }) - it("should update both allowed and denied lists when patterns conflict", () => { + it("should update both allowed and denied lists when commands conflict", () => { const conflictState = { ...mockExtensionState, allowedCommands: ["git"], - deniedCommands: ["git push"], + deniedCommands: ["git push origin main"], } render( @@ -426,31 +415,31 @@ Suggested patterns: npm, npm test, npm run , ) - // Click to allow "git push" - const allowButton = screen.getByText("Allow git push") + // Click to allow "git push origin main" + const allowButton = screen.getByText("Allow git push origin main") fireEvent.click(allowButton) // Should add to allowed and remove from denied - expect(conflictState.setAllowedCommands).toHaveBeenCalledWith(["git", "git push"]) + expect(conflictState.setAllowedCommands).toHaveBeenCalledWith(["git", "git push origin main"]) expect(conflictState.setDeniedCommands).toHaveBeenCalledWith([]) }) - it("should handle commands that cannot be parsed and fallback gracefully", () => { - // Test with a command that might cause parsing issues - const unparsableCommand = "echo 'test with unclosed quote" + it("should handle commands with special quotes", () => { + // Test with a command that has quotes + const commandWithQuotes = "echo 'test with unclosed quote" render( - + , ) // Should still render the command expect(screen.getByTestId("code-block")).toHaveTextContent("echo 'test with unclosed quote") - // Should show pattern selector with at least the main command + // Should show pattern selector with the full command expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("echo")).toBeInTheDocument() + expect(screen.getByText("echo 'test with unclosed quote")).toBeInTheDocument() }) it("should handle empty or whitespace-only commands", () => { @@ -486,9 +475,7 @@ Without any command prefix` expect(codeBlock.textContent).toContain("Without any command prefix") }) - it("should handle fallback case where parsed command equals original text", () => { - // This tests the case where parseCommandAndOutput returns command === text - // which happens when there's no output separator or command prefix + it("should handle simple commands", () => { const plainCommand = "docker build ." render( @@ -500,18 +487,16 @@ Without any command prefix` // Should render the command expect(screen.getByTestId("code-block")).toHaveTextContent("docker build .") - // Should show pattern selector with extracted patterns + // Should show pattern selector with the full command expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("docker")).toBeInTheDocument() - expect(screen.getByText("docker build")).toBeInTheDocument() + expect(screen.getByText("docker build .")).toBeInTheDocument() - // Verify no output is shown (since command === text means no output) + // Verify no output is shown (since there's no Output: separator) const codeBlocks = screen.getAllByTestId("code-block") expect(codeBlocks).toHaveLength(1) // Only the command block, no output block }) - it("should not extract patterns from command output numbers", () => { - // This tests the specific bug where "0 total" from wc output was being extracted as a command + it("should handle commands with numeric output", () => { const commandWithNumericOutput = `wc -l *.go *.java Output: 10 file1.go @@ -533,17 +518,15 @@ Output: const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should only extract "wc" from the actual command - expect(screen.getByText("wc")).toBeInTheDocument() + // Should show the full command + expect(screen.getByText("wc -l *.go *.java")).toBeInTheDocument() - // Should NOT extract numeric patterns from output like "45 total" - expect(screen.queryByText("45")).not.toBeInTheDocument() - expect(screen.queryByText("total")).not.toBeInTheDocument() - expect(screen.queryByText("45 total")).not.toBeInTheDocument() + // The output should still be displayed in the code block + expect(codeBlocks.length).toBeGreaterThan(1) + expect(codeBlocks[1].textContent).toContain("45 total") }) - it("should handle the edge case of 0 total in output", () => { - // This is the exact case from the bug report + it("should handle commands with zero output", () => { const commandWithZeroTotal = `wc -l *.go *.java Output: 0 total` @@ -558,17 +541,8 @@ Output: const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should only extract "wc" from the actual command - // Check within the pattern selector specifically - const patternTexts = Array.from(selector.querySelectorAll("span")).map((el) => el.textContent) - - // Should have "wc" as a pattern - expect(patternTexts).toContain("wc") - - // Should NOT have "0", "total", or "0 total" as patterns - expect(patternTexts).not.toContain("0") - expect(patternTexts).not.toContain("total") - expect(patternTexts).not.toContain("0 total") + // Should show the full command + expect(screen.getByText("wc -l *.go *.java")).toBeInTheDocument() // The output should still be displayed in the code block const codeBlocks = screen.getAllByTestId("code-block") diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx index 815d4dfbfbd..449fd504c69 100644 --- a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -4,11 +4,6 @@ import { describe, it, expect, vi } from "vitest" import { CommandPatternSelector } from "../CommandPatternSelector" import { TooltipProvider } from "../../../components/ui/tooltip" -interface CommandPattern { - pattern: string - description?: string -} - // Mock react-i18next vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -30,21 +25,15 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ const TestWrapper = ({ children }: { children: React.ReactNode }) => {children} describe("CommandPatternSelector", () => { - const mockPatterns: CommandPattern[] = [ - { pattern: "npm", description: "npm commands" }, - { pattern: "npm install", description: "npm install commands" }, - { pattern: "git", description: "git commands" }, - ] - const defaultProps = { - patterns: mockPatterns, - allowedCommands: ["npm"], - deniedCommands: ["git"], - onAllowPatternChange: vi.fn(), - onDenyPatternChange: vi.fn(), + command: "npm install express", + allowedCommands: ["npm install"], + deniedCommands: ["git push"], + onAllowCommandChange: vi.fn(), + onDenyCommandChange: vi.fn(), } - it("should render with unique pattern keys", () => { + it("should render with command input", () => { const { container } = render( @@ -58,39 +47,148 @@ describe("CommandPatternSelector", () => { const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) fireEvent.click(expandButton) - // Check that patterns are rendered - expect(screen.getByText("npm")).toBeInTheDocument() - expect(screen.getByText("npm install")).toBeInTheDocument() - expect(screen.getByText("git")).toBeInTheDocument() + // Check that the input is rendered with the command + const input = screen.getByDisplayValue("npm install express") + expect(input).toBeInTheDocument() }) - it("should handle duplicate patterns gracefully", () => { - // Test with duplicate patterns to ensure keys are still unique - const duplicatePatterns: CommandPattern[] = [ - { pattern: "npm", description: "npm commands" }, - { pattern: "npm", description: "duplicate npm commands" }, // Duplicate pattern - { pattern: "git", description: "git commands" }, - ] + it("should allow editing the command", () => { + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + fireEvent.click(expandButton) + + // Get the input and change its value + const input = screen.getByDisplayValue("npm install express") as HTMLInputElement + fireEvent.change(input, { target: { value: "npm install react" } }) + + // Check that the input value has changed + expect(input.value).toBe("npm install react") + }) + it("should show allowed status for commands in allowed list", () => { const props = { ...defaultProps, - patterns: duplicatePatterns, + command: "npm install", } - // This should not throw an error even with duplicate patterns - const { container } = render( + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + fireEvent.click(expandButton) + + // The allow button should have the active styling (we can check by aria-label) + const allowButton = screen.getByRole("button", { name: /chat:commandExecution.removeFromAllowed/i }) + expect(allowButton).toBeInTheDocument() + }) + + it("should show denied status for commands in denied list", () => { + const props = { + ...defaultProps, + command: "git push", + } + + render( , ) - expect(container).toBeTruthy() // Click to expand the component const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) fireEvent.click(expandButton) - // Both instances of "npm" should be rendered - const npmElements = screen.getAllByText("npm") - expect(npmElements).toHaveLength(2) + // The deny button should have the active styling (we can check by aria-label) + const denyButton = screen.getByRole("button", { name: /chat:commandExecution.removeFromDenied/i }) + expect(denyButton).toBeInTheDocument() + }) + + it("should call onAllowCommandChange when allow button is clicked", () => { + const mockOnAllowCommandChange = vi.fn() + const props = { + ...defaultProps, + onAllowCommandChange: mockOnAllowCommandChange, + } + + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + fireEvent.click(expandButton) + + // Click the allow button + const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i }) + fireEvent.click(allowButton) + + // Check that the callback was called with the command + expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install express") + }) + + it("should call onDenyCommandChange when deny button is clicked", () => { + const mockOnDenyCommandChange = vi.fn() + const props = { + ...defaultProps, + onDenyCommandChange: mockOnDenyCommandChange, + } + + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + fireEvent.click(expandButton) + + // Click the deny button + const denyButton = screen.getByRole("button", { name: /chat:commandExecution.addToDenied/i }) + fireEvent.click(denyButton) + + // Check that the callback was called with the command + expect(mockOnDenyCommandChange).toHaveBeenCalledWith("npm install express") + }) + + it("should use edited command value when buttons are clicked", () => { + const mockOnAllowCommandChange = vi.fn() + const props = { + ...defaultProps, + onAllowCommandChange: mockOnAllowCommandChange, + } + + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + fireEvent.click(expandButton) + + // Edit the command + const input = screen.getByDisplayValue("npm install express") as HTMLInputElement + fireEvent.change(input, { target: { value: "npm install react" } }) + + // Click the allow button + const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i }) + fireEvent.click(allowButton) + + // Check that the callback was called with the edited command + expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install react") }) }) diff --git a/webview-ui/src/utils/__tests__/command-parser.spec.ts b/webview-ui/src/utils/__tests__/command-parser.spec.ts deleted file mode 100644 index 05303f87fc4..00000000000 --- a/webview-ui/src/utils/__tests__/command-parser.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect } from "vitest" -import { extractPatternsFromCommand } from "../command-parser" - -describe("extractPatternsFromCommand", () => { - it("should extract simple command pattern", () => { - const patterns = extractPatternsFromCommand("ls") - expect(patterns).toEqual(["ls"]) - }) - - it("should extract command with subcommand", () => { - const patterns = extractPatternsFromCommand("git push origin main") - expect(patterns).toEqual(["git", "git push", "git push origin"]) - }) - - it("should stop at flags", () => { - const patterns = extractPatternsFromCommand("git commit -m 'test'") - expect(patterns).toEqual(["git", "git commit"]) - }) - - it("should stop at paths", () => { - const patterns = extractPatternsFromCommand("cd /usr/local/bin") - expect(patterns).toEqual(["cd"]) - }) - - it("should handle pipes", () => { - const patterns = extractPatternsFromCommand("ls -la | grep test") - expect(patterns).toEqual(["grep", "grep test", "ls"]) - }) - - it("should handle && operator", () => { - const patterns = extractPatternsFromCommand("npm install && git push origin main") - expect(patterns).toEqual(["git", "git push", "git push origin", "npm", "npm install"]) - }) - - it("should handle || operator", () => { - const patterns = extractPatternsFromCommand("npm test || npm run test:ci") - expect(patterns).toEqual(["npm", "npm run", "npm test"]) - }) - - it("should handle semicolon separator", () => { - const patterns = extractPatternsFromCommand("cd src; npm install") - expect(patterns).toEqual(["cd", "cd src", "npm", "npm install"]) - }) - - it("should skip numeric commands", () => { - const patterns = extractPatternsFromCommand("0 total") - expect(patterns).toEqual([]) - }) - - it("should handle empty command", () => { - const patterns = extractPatternsFromCommand("") - expect(patterns).toEqual([]) - }) - - it("should handle null/undefined", () => { - expect(extractPatternsFromCommand(null as any)).toEqual([]) - expect(extractPatternsFromCommand(undefined as any)).toEqual([]) - }) - - it("should handle scripts", () => { - const patterns = extractPatternsFromCommand("./script.sh --verbose") - expect(patterns).toEqual(["./script.sh"]) - }) - - it("should handle paths with dots", () => { - const patterns = extractPatternsFromCommand("git add .") - expect(patterns).toEqual(["git", "git add"]) - }) - - it("should handle paths with tilde", () => { - const patterns = extractPatternsFromCommand("cd ~/projects") - expect(patterns).toEqual(["cd"]) - }) - - it("should handle colons in arguments", () => { - const patterns = extractPatternsFromCommand("docker run image:tag") - expect(patterns).toEqual(["docker", "docker run"]) - }) - - it("should return sorted patterns", () => { - const patterns = extractPatternsFromCommand("npm run build && git push") - expect(patterns).toEqual(["git", "git push", "npm", "npm run", "npm run build"]) - }) - - it("should handle complex command with multiple operators", () => { - const patterns = extractPatternsFromCommand("npm install && npm test | grep success || echo 'failed'") - expect(patterns).toContain("npm") - expect(patterns).toContain("npm install") - expect(patterns).toContain("npm test") - expect(patterns).toContain("grep") - expect(patterns).toContain("echo") - }) - - it("should handle malformed commands gracefully", () => { - const patterns = extractPatternsFromCommand("echo 'unclosed quote") - expect(patterns).toContain("echo") - }) - - it("should not treat package managers specially", () => { - const patterns = extractPatternsFromCommand("npm run build") - expect(patterns).toEqual(["npm", "npm run", "npm run build"]) - // Now includes "npm run build" with 3-level extraction - }) - - it("should extract at most 3 levels", () => { - const patterns = extractPatternsFromCommand("git push origin main --force") - expect(patterns).toEqual(["git", "git push", "git push origin"]) - // Should NOT include deeper levels beyond 3 - }) - - it("should handle multi-level commands like gh pr", () => { - const patterns = extractPatternsFromCommand("gh pr checkout 123") - expect(patterns).toEqual(["gh", "gh pr", "gh pr checkout"]) - }) - - it("should extract 3 levels for git remote add", () => { - const patterns = extractPatternsFromCommand("git remote add origin https://github.com/user/repo.git") - expect(patterns).toEqual(["git", "git remote", "git remote add"]) - }) - - it("should extract 3 levels for npm run build", () => { - const patterns = extractPatternsFromCommand("npm run build --production") - expect(patterns).toEqual(["npm", "npm run", "npm run build"]) - }) - - it("should stop at file extensions even at third level", () => { - const patterns = extractPatternsFromCommand("node scripts test.js") - expect(patterns).toEqual(["node", "node scripts"]) - // Should NOT include "node scripts test.js" because of .js - }) - - it("should stop at flags at any level", () => { - const patterns = extractPatternsFromCommand("docker run -it ubuntu") - expect(patterns).toEqual(["docker", "docker run"]) - // Stops at -it flag - }) -}) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts deleted file mode 100644 index 14a4480ae79..00000000000 --- a/webview-ui/src/utils/command-parser.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { parse } from "shell-quote" - -/** - * Extract command patterns from a command string. - * Returns at most 3 levels: base command, command + first argument, and command + first two arguments. - * Stops at flags (-), paths (/\~), file extensions (.ext), or special characters (:). - */ -export function extractPatternsFromCommand(command: string): string[] { - if (!command?.trim()) return [] - - const patterns = new Set() - - try { - const parsed = parse(command) - const commandSeparators = new Set(["|", "&&", "||", ";"]) - let currentTokens: string[] = [] - - for (const token of parsed) { - if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { - // Process accumulated tokens as a command - if (currentTokens.length > 0) { - extractFromTokens(currentTokens, patterns) - currentTokens = [] - } - } else if (typeof token === "string") { - currentTokens.push(token) - } - } - - // Process any remaining tokens - if (currentTokens.length > 0) { - extractFromTokens(currentTokens, patterns) - } - } catch (error) { - console.warn("Failed to parse command:", error) - // Fallback: just extract the first word - const firstWord = command.trim().split(/\s+/)[0] - if (firstWord) patterns.add(firstWord) - } - - return Array.from(patterns).sort() -} - -function isValidToken(token: string): boolean { - return !!token && !token.startsWith("-") && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/) -} - -function extractFromTokens(tokens: string[], patterns: Set): void { - if (tokens.length === 0) return - - const mainCmd = tokens[0] - - // Skip numeric commands like "0" from "0 total" - if (/^\d+$/.test(mainCmd)) return - - // Build patterns progressively up to 3 levels - let pattern = mainCmd - patterns.add(pattern) - - for (let i = 1; i < Math.min(tokens.length, 3); i++) { - if (isValidToken(tokens[i])) { - pattern += ` ${tokens[i]}` - patterns.add(pattern) - } else { - break // Stop at first invalid token - } - } -} From 20f99a252358fd0d98c73428f644f714038b83f9 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 09:03:45 -0500 Subject: [PATCH 23/31] feat: improve command permissions UI with click-to-edit functionality - Add click-to-edit behavior: command displays as text and becomes input on click - Add proper padding between collapsible trigger and input field - Fix layout shift between text and input states with transparent border - Update focus styling to match standard input components (focus:outline-0) - Add hover effect with background highlight for better UX - Support Enter to confirm and Escape to cancel edits --- .../chat/CommandPatternSelector.tsx | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index cadc90c4433..340e819d3eb 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -23,6 +23,7 @@ export const CommandPatternSelector: React.FC = ({ const { t } = useTranslation() const [isExpanded, setIsExpanded] = useState(false) const [editedCommand, setEditedCommand] = useState(command) + const [isEditing, setIsEditing] = useState(false) const getCommandStatus = (cmd: string): "allowed" | "denied" | "none" => { if (allowedCommands.includes(cmd)) return "allowed" @@ -78,16 +79,36 @@ export const CommandPatternSelector: React.FC = ({ {isExpanded && ( -
+
- setEditedCommand(e.target.value)} - className="font-mono text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1 w-full focus:outline-none focus:border-vscode-focusBorder" - placeholder={command} - /> + {isEditing ? ( + setEditedCommand(e.target.value)} + onBlur={() => setIsEditing(false)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setIsEditing(false) + } + if (e.key === "Escape") { + setEditedCommand(command) + setIsEditing(false) + } + }} + className="font-mono text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1.5 w-full focus:outline-0" + placeholder={command} + autoFocus + /> + ) : ( +
setIsEditing(true)} + className="font-mono text-xs text-vscode-foreground cursor-pointer hover:bg-vscode-list-hoverBackground px-2 py-1.5 rounded transition-colors border border-transparent" + title="Click to edit command"> + {editedCommand} +
+ )}
diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index 340e819d3eb..81ce1123765 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -1,146 +1,179 @@ -import React, { useState } from "react" +import React, { useState, useMemo } from "react" import { Check, ChevronDown, Info, X } from "lucide-react" import { cn } from "../../lib/utils" import { useTranslation, Trans } from "react-i18next" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { StandardTooltip } from "../ui/standard-tooltip" +interface CommandPattern { + pattern: string + description?: string +} + interface CommandPatternSelectorProps { command: string + patterns: CommandPattern[] allowedCommands: string[] deniedCommands: string[] - onAllowCommandChange: (command: string) => void - onDenyCommandChange: (command: string) => void + onAllowPatternChange: (pattern: string) => void + onDenyPatternChange: (pattern: string) => void } export const CommandPatternSelector: React.FC = ({ command, + patterns, allowedCommands, deniedCommands, - onAllowCommandChange, - onDenyCommandChange, + onAllowPatternChange, + onDenyPatternChange, }) => { const { t } = useTranslation() const [isExpanded, setIsExpanded] = useState(false) - const [editedCommand, setEditedCommand] = useState(command) - const [isEditing, setIsEditing] = useState(false) + const [editingStates, setEditingStates] = useState>({}) + + // Create a combined list with full command first, then patterns + const allPatterns = useMemo(() => { + const fullCommandPattern = { pattern: command, description: "Full command" } + return [fullCommandPattern, ...patterns] + }, [command, patterns]) - const getCommandStatus = (cmd: string): "allowed" | "denied" | "none" => { - if (allowedCommands.includes(cmd)) return "allowed" - if (deniedCommands.includes(cmd)) return "denied" + const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => { + if (allowedCommands.includes(pattern)) return "allowed" + if (deniedCommands.includes(pattern)) return "denied" return "none" } - const currentStatus = getCommandStatus(editedCommand) + const getEditState = (pattern: string) => { + return editingStates[pattern] || { isEditing: false, value: pattern } + } + + const setEditState = (pattern: string, isEditing: boolean, value?: string) => { + setEditingStates((prev) => ({ + ...prev, + [pattern]: { isEditing, value: value ?? pattern }, + })) + } return (
+
+ + {allowedCommands.length} {t("chat:commandExecution.allowed")} + + + + {deniedCommands.length} {t("chat:commandExecution.denied")} + +
{isExpanded && ( -
-
-
- {isEditing ? ( - setEditedCommand(e.target.value)} - onBlur={() => setIsEditing(false)} - onKeyDown={(e) => { - if (e.key === "Enter") { - setIsEditing(false) - } - if (e.key === "Escape") { - setEditedCommand(command) - setIsEditing(false) - } - }} - className="font-mono text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1.5 w-full focus:outline-0" - placeholder={command} - autoFocus - /> - ) : ( -
setIsEditing(true)} - className="font-mono text-xs text-vscode-foreground cursor-pointer hover:bg-vscode-list-hoverBackground px-2 py-1.5 rounded transition-colors border border-transparent" - title="Click to edit command"> - {editedCommand} +
+ {allPatterns.map((item) => { + const editState = getEditState(item.pattern) + const status = getPatternStatus(editState.value) + + return ( +
+
+ {editState.isEditing ? ( + setEditState(item.pattern, true, e.target.value)} + onBlur={() => setEditState(item.pattern, false)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setEditState(item.pattern, false) + } + if (e.key === "Escape") { + setEditState(item.pattern, false, item.pattern) + } + }} + className="font-mono text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1.5 w-full focus:outline-0 focus:ring-1 focus:ring-vscode-focusBorder" + placeholder={item.pattern} + autoFocus + /> + ) : ( +
setEditState(item.pattern, true)} + className="font-mono text-xs text-vscode-foreground cursor-pointer hover:bg-vscode-list-hoverBackground px-2 py-1.5 rounded transition-colors border border-transparent" + title="Click to edit pattern"> + {editState.value} + {item.description && ( + + - {item.description} + + )} +
+ )} +
+
+ +
- )} -
-
- - -
-
+
+ ) + })}
)}
diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx index 449fd504c69..76b052192b1 100644 --- a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -27,13 +27,17 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => { const defaultProps = { command: "npm install express", + patterns: [ + { pattern: "npm install", description: "Install npm packages" }, + { pattern: "npm *", description: "Any npm command" }, + ], allowedCommands: ["npm install"], deniedCommands: ["git push"], - onAllowCommandChange: vi.fn(), - onDenyCommandChange: vi.fn(), + onAllowPatternChange: vi.fn(), + onDenyPatternChange: vi.fn(), } - it("should render with command input", () => { + it("should render with command permissions header", () => { const { container } = render( @@ -43,16 +47,45 @@ describe("CommandPatternSelector", () => { // The component should render without errors expect(container).toBeTruthy() + // Check for the command permissions text + expect(screen.getByText("chat:commandExecution.commandPermissions")).toBeInTheDocument() + }) + + it("should show full command as first pattern when expanded", () => { + render( + + + , + ) + // Click to expand the component - const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + const expandButton = screen.getByRole("button") fireEvent.click(expandButton) - // Check that the input is rendered with the command - const input = screen.getByDisplayValue("npm install express") - expect(input).toBeInTheDocument() + // Check that the full command is shown with description + expect(screen.getByText("npm install express")).toBeInTheDocument() + expect(screen.getByText("- Full command")).toBeInTheDocument() + }) + + it("should show extracted patterns when expanded", () => { + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button") + fireEvent.click(expandButton) + + // Check that patterns are shown + expect(screen.getByText("npm install")).toBeInTheDocument() + expect(screen.getByText("- Install npm packages")).toBeInTheDocument() + expect(screen.getByText("npm *")).toBeInTheDocument() + expect(screen.getByText("- Any npm command")).toBeInTheDocument() }) - it("should allow editing the command", () => { + it("should allow editing patterns when clicked", () => { render( @@ -60,42 +93,45 @@ describe("CommandPatternSelector", () => { ) // Click to expand the component - const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + const expandButton = screen.getByRole("button") fireEvent.click(expandButton) - // Get the input and change its value + // Click on the full command pattern + const fullCommandDiv = screen.getByText("npm install express").closest("div") + fireEvent.click(fullCommandDiv!) + + // An input should appear const input = screen.getByDisplayValue("npm install express") as HTMLInputElement - fireEvent.change(input, { target: { value: "npm install react" } }) + expect(input).toBeInTheDocument() - // Check that the input value has changed + // Change the value + fireEvent.change(input, { target: { value: "npm install react" } }) expect(input.value).toBe("npm install react") }) - it("should show allowed status for commands in allowed list", () => { - const props = { - ...defaultProps, - command: "npm install", - } - + it("should show allowed status for patterns in allowed list", () => { render( - + , ) // Click to expand the component - const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + const expandButton = screen.getByRole("button") fireEvent.click(expandButton) + // Find the npm install pattern row + const npmInstallPattern = screen.getByText("npm install").closest(".ml-5") + // The allow button should have the active styling (we can check by aria-label) - const allowButton = screen.getByRole("button", { name: /chat:commandExecution.removeFromAllowed/i }) + const allowButton = npmInstallPattern?.querySelector('button[aria-label*="removeFromAllowed"]') expect(allowButton).toBeInTheDocument() }) - it("should show denied status for commands in denied list", () => { + it("should show denied status for patterns in denied list", () => { const props = { ...defaultProps, - command: "git push", + patterns: [{ pattern: "git push", description: "Push to git" }], } render( @@ -105,19 +141,22 @@ describe("CommandPatternSelector", () => { ) // Click to expand the component - const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + const expandButton = screen.getByRole("button") fireEvent.click(expandButton) + // Find the git push pattern row + const gitPushPattern = screen.getByText("git push").closest(".ml-5") + // The deny button should have the active styling (we can check by aria-label) - const denyButton = screen.getByRole("button", { name: /chat:commandExecution.removeFromDenied/i }) + const denyButton = gitPushPattern?.querySelector('button[aria-label*="removeFromDenied"]') expect(denyButton).toBeInTheDocument() }) - it("should call onAllowCommandChange when allow button is clicked", () => { - const mockOnAllowCommandChange = vi.fn() + it("should call onAllowPatternChange when allow button is clicked", () => { + const mockOnAllowPatternChange = vi.fn() const props = { ...defaultProps, - onAllowCommandChange: mockOnAllowCommandChange, + onAllowPatternChange: mockOnAllowPatternChange, } render( @@ -127,22 +166,23 @@ describe("CommandPatternSelector", () => { ) // Click to expand the component - const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + const expandButton = screen.getByRole("button") fireEvent.click(expandButton) - // Click the allow button - const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i }) - fireEvent.click(allowButton) + // Find the full command pattern row and click allow + const fullCommandPattern = screen.getByText("npm install express").closest(".ml-5") + const allowButton = fullCommandPattern?.querySelector('button[aria-label*="addToAllowed"]') + fireEvent.click(allowButton!) - // Check that the callback was called with the command - expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install express") + // Check that the callback was called with the pattern + expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install express") }) - it("should call onDenyCommandChange when deny button is clicked", () => { - const mockOnDenyCommandChange = vi.fn() + it("should call onDenyPatternChange when deny button is clicked", () => { + const mockOnDenyPatternChange = vi.fn() const props = { ...defaultProps, - onDenyCommandChange: mockOnDenyCommandChange, + onDenyPatternChange: mockOnDenyPatternChange, } render( @@ -152,22 +192,23 @@ describe("CommandPatternSelector", () => { ) // Click to expand the component - const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + const expandButton = screen.getByRole("button") fireEvent.click(expandButton) - // Click the deny button - const denyButton = screen.getByRole("button", { name: /chat:commandExecution.addToDenied/i }) - fireEvent.click(denyButton) + // Find the full command pattern row and click deny + const fullCommandPattern = screen.getByText("npm install express").closest(".ml-5") + const denyButton = fullCommandPattern?.querySelector('button[aria-label*="addToDenied"]') + fireEvent.click(denyButton!) - // Check that the callback was called with the command - expect(mockOnDenyCommandChange).toHaveBeenCalledWith("npm install express") + // Check that the callback was called with the pattern + expect(mockOnDenyPatternChange).toHaveBeenCalledWith("npm install express") }) - it("should use edited command value when buttons are clicked", () => { - const mockOnAllowCommandChange = vi.fn() + it("should use edited pattern value when buttons are clicked", () => { + const mockOnAllowPatternChange = vi.fn() const props = { ...defaultProps, - onAllowCommandChange: mockOnAllowCommandChange, + onAllowPatternChange: mockOnAllowPatternChange, } render( @@ -177,18 +218,53 @@ describe("CommandPatternSelector", () => { ) // Click to expand the component - const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) + const expandButton = screen.getByRole("button") fireEvent.click(expandButton) + // Click on the full command pattern to edit + const fullCommandDiv = screen.getByText("npm install express").closest("div") + fireEvent.click(fullCommandDiv!) + // Edit the command const input = screen.getByDisplayValue("npm install express") as HTMLInputElement fireEvent.change(input, { target: { value: "npm install react" } }) + // Press Enter to confirm edit + fireEvent.keyDown(input, { key: "Enter" }) + // Click the allow button - const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i }) - fireEvent.click(allowButton) + const fullCommandPattern = screen.getByText("npm install react").closest(".ml-5") + const allowButton = fullCommandPattern?.querySelector('button[aria-label*="addToAllowed"]') + fireEvent.click(allowButton!) + + // Check that the callback was called with the edited pattern + expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install react") + }) + + it("should cancel edit on Escape key", () => { + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button") + fireEvent.click(expandButton) + + // Click on the full command pattern to edit + const fullCommandDiv = screen.getByText("npm install express").closest("div") + fireEvent.click(fullCommandDiv!) + + // Edit the command + const input = screen.getByDisplayValue("npm install express") as HTMLInputElement + fireEvent.change(input, { target: { value: "npm install react" } }) + + // Press Escape to cancel + fireEvent.keyDown(input, { key: "Escape" }) - // Check that the callback was called with the edited command - expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install react") + // The original value should be restored + expect(screen.getByText("npm install express")).toBeInTheDocument() + expect(screen.queryByDisplayValue("npm install react")).not.toBeInTheDocument() }) }) diff --git a/webview-ui/src/utils/__tests__/command-parser.spec.ts b/webview-ui/src/utils/__tests__/command-parser.spec.ts new file mode 100644 index 00000000000..05303f87fc4 --- /dev/null +++ b/webview-ui/src/utils/__tests__/command-parser.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest" +import { extractPatternsFromCommand } from "../command-parser" + +describe("extractPatternsFromCommand", () => { + it("should extract simple command pattern", () => { + const patterns = extractPatternsFromCommand("ls") + expect(patterns).toEqual(["ls"]) + }) + + it("should extract command with subcommand", () => { + const patterns = extractPatternsFromCommand("git push origin main") + expect(patterns).toEqual(["git", "git push", "git push origin"]) + }) + + it("should stop at flags", () => { + const patterns = extractPatternsFromCommand("git commit -m 'test'") + expect(patterns).toEqual(["git", "git commit"]) + }) + + it("should stop at paths", () => { + const patterns = extractPatternsFromCommand("cd /usr/local/bin") + expect(patterns).toEqual(["cd"]) + }) + + it("should handle pipes", () => { + const patterns = extractPatternsFromCommand("ls -la | grep test") + expect(patterns).toEqual(["grep", "grep test", "ls"]) + }) + + it("should handle && operator", () => { + const patterns = extractPatternsFromCommand("npm install && git push origin main") + expect(patterns).toEqual(["git", "git push", "git push origin", "npm", "npm install"]) + }) + + it("should handle || operator", () => { + const patterns = extractPatternsFromCommand("npm test || npm run test:ci") + expect(patterns).toEqual(["npm", "npm run", "npm test"]) + }) + + it("should handle semicolon separator", () => { + const patterns = extractPatternsFromCommand("cd src; npm install") + expect(patterns).toEqual(["cd", "cd src", "npm", "npm install"]) + }) + + it("should skip numeric commands", () => { + const patterns = extractPatternsFromCommand("0 total") + expect(patterns).toEqual([]) + }) + + it("should handle empty command", () => { + const patterns = extractPatternsFromCommand("") + expect(patterns).toEqual([]) + }) + + it("should handle null/undefined", () => { + expect(extractPatternsFromCommand(null as any)).toEqual([]) + expect(extractPatternsFromCommand(undefined as any)).toEqual([]) + }) + + it("should handle scripts", () => { + const patterns = extractPatternsFromCommand("./script.sh --verbose") + expect(patterns).toEqual(["./script.sh"]) + }) + + it("should handle paths with dots", () => { + const patterns = extractPatternsFromCommand("git add .") + expect(patterns).toEqual(["git", "git add"]) + }) + + it("should handle paths with tilde", () => { + const patterns = extractPatternsFromCommand("cd ~/projects") + expect(patterns).toEqual(["cd"]) + }) + + it("should handle colons in arguments", () => { + const patterns = extractPatternsFromCommand("docker run image:tag") + expect(patterns).toEqual(["docker", "docker run"]) + }) + + it("should return sorted patterns", () => { + const patterns = extractPatternsFromCommand("npm run build && git push") + expect(patterns).toEqual(["git", "git push", "npm", "npm run", "npm run build"]) + }) + + it("should handle complex command with multiple operators", () => { + const patterns = extractPatternsFromCommand("npm install && npm test | grep success || echo 'failed'") + expect(patterns).toContain("npm") + expect(patterns).toContain("npm install") + expect(patterns).toContain("npm test") + expect(patterns).toContain("grep") + expect(patterns).toContain("echo") + }) + + it("should handle malformed commands gracefully", () => { + const patterns = extractPatternsFromCommand("echo 'unclosed quote") + expect(patterns).toContain("echo") + }) + + it("should not treat package managers specially", () => { + const patterns = extractPatternsFromCommand("npm run build") + expect(patterns).toEqual(["npm", "npm run", "npm run build"]) + // Now includes "npm run build" with 3-level extraction + }) + + it("should extract at most 3 levels", () => { + const patterns = extractPatternsFromCommand("git push origin main --force") + expect(patterns).toEqual(["git", "git push", "git push origin"]) + // Should NOT include deeper levels beyond 3 + }) + + it("should handle multi-level commands like gh pr", () => { + const patterns = extractPatternsFromCommand("gh pr checkout 123") + expect(patterns).toEqual(["gh", "gh pr", "gh pr checkout"]) + }) + + it("should extract 3 levels for git remote add", () => { + const patterns = extractPatternsFromCommand("git remote add origin https://github.com/user/repo.git") + expect(patterns).toEqual(["git", "git remote", "git remote add"]) + }) + + it("should extract 3 levels for npm run build", () => { + const patterns = extractPatternsFromCommand("npm run build --production") + expect(patterns).toEqual(["npm", "npm run", "npm run build"]) + }) + + it("should stop at file extensions even at third level", () => { + const patterns = extractPatternsFromCommand("node scripts test.js") + expect(patterns).toEqual(["node", "node scripts"]) + // Should NOT include "node scripts test.js" because of .js + }) + + it("should stop at flags at any level", () => { + const patterns = extractPatternsFromCommand("docker run -it ubuntu") + expect(patterns).toEqual(["docker", "docker run"]) + // Stops at -it flag + }) +}) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts new file mode 100644 index 00000000000..14a4480ae79 --- /dev/null +++ b/webview-ui/src/utils/command-parser.ts @@ -0,0 +1,68 @@ +import { parse } from "shell-quote" + +/** + * Extract command patterns from a command string. + * Returns at most 3 levels: base command, command + first argument, and command + first two arguments. + * Stops at flags (-), paths (/\~), file extensions (.ext), or special characters (:). + */ +export function extractPatternsFromCommand(command: string): string[] { + if (!command?.trim()) return [] + + const patterns = new Set() + + try { + const parsed = parse(command) + const commandSeparators = new Set(["|", "&&", "||", ";"]) + let currentTokens: string[] = [] + + for (const token of parsed) { + if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) { + // Process accumulated tokens as a command + if (currentTokens.length > 0) { + extractFromTokens(currentTokens, patterns) + currentTokens = [] + } + } else if (typeof token === "string") { + currentTokens.push(token) + } + } + + // Process any remaining tokens + if (currentTokens.length > 0) { + extractFromTokens(currentTokens, patterns) + } + } catch (error) { + console.warn("Failed to parse command:", error) + // Fallback: just extract the first word + const firstWord = command.trim().split(/\s+/)[0] + if (firstWord) patterns.add(firstWord) + } + + return Array.from(patterns).sort() +} + +function isValidToken(token: string): boolean { + return !!token && !token.startsWith("-") && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/) +} + +function extractFromTokens(tokens: string[], patterns: Set): void { + if (tokens.length === 0) return + + const mainCmd = tokens[0] + + // Skip numeric commands like "0" from "0 total" + if (/^\d+$/.test(mainCmd)) return + + // Build patterns progressively up to 3 levels + let pattern = mainCmd + patterns.add(pattern) + + for (let i = 1; i < Math.min(tokens.length, 3); i++) { + if (isValidToken(tokens[i])) { + pattern += ` ${tokens[i]}` + patterns.add(pattern) + } else { + break // Stop at first invalid token + } + } +} From 77bc9e6f92d11034739dd5a8ba4bf1cd01776bb3 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 10:41:40 -0500 Subject: [PATCH 25/31] fix: remove 'Full command' description and use correct translation keys - Removed the 'Full command' description from the first pattern - Updated translation keys to use existing ones from chat.json - Fixed tooltip to use Trans component with settings link - Updated tests to match the changes --- .../chat/CommandPatternSelector.tsx | 39 ++++++++----------- .../__tests__/CommandPatternSelector.spec.tsx | 5 +-- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index 81ce1123765..798d6779ece 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -33,7 +33,7 @@ export const CommandPatternSelector: React.FC = ({ // Create a combined list with full command first, then patterns const allPatterns = useMemo(() => { - const fullCommandPattern = { pattern: command, description: "Full command" } + const fullCommandPattern: CommandPattern = { pattern: command } return [fullCommandPattern, ...patterns] }, [command, patterns]) @@ -65,37 +65,30 @@ export const CommandPatternSelector: React.FC = ({ "-rotate-90": !isExpanded, })} /> - {t("chat:commandExecution.commandPermissions")} + {t("chat:commandExecution.manageCommands")} -

{t("chat:commandExecution.permissionsTooltip")}

-

- - ), - }} - /> -

+
+ + ), + }} + />
}>
- - {allowedCommands.length} {t("chat:commandExecution.allowed")} - + {allowedCommands.length} allowed - - {deniedCommands.length} {t("chat:commandExecution.denied")} - + {deniedCommands.length} denied
diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx index 76b052192b1..0b8ff18b002 100644 --- a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -48,7 +48,7 @@ describe("CommandPatternSelector", () => { expect(container).toBeTruthy() // Check for the command permissions text - expect(screen.getByText("chat:commandExecution.commandPermissions")).toBeInTheDocument() + expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument() }) it("should show full command as first pattern when expanded", () => { @@ -62,9 +62,8 @@ describe("CommandPatternSelector", () => { const expandButton = screen.getByRole("button") fireEvent.click(expandButton) - // Check that the full command is shown with description + // Check that the full command is shown expect(screen.getByText("npm install express")).toBeInTheDocument() - expect(screen.getByText("- Full command")).toBeInTheDocument() }) it("should show extracted patterns when expanded", () => { From 19c2c492815a090da7baa21127ad27b74093200b Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 10:54:05 -0500 Subject: [PATCH 26/31] refactor: remove unused token validation function and simplify token processing logic --- .../src/components/chat/CommandPatternSelector.tsx | 5 ----- webview-ui/src/utils/command-parser.ts | 12 ++---------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index 798d6779ece..858bf4d7e06 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -85,11 +85,6 @@ export const CommandPatternSelector: React.FC = ({
-
- {allowedCommands.length} allowed - - {deniedCommands.length} denied -
{isExpanded && ( diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index 14a4480ae79..1c955f82a9d 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -41,10 +41,6 @@ export function extractPatternsFromCommand(command: string): string[] { return Array.from(patterns).sort() } -function isValidToken(token: string): boolean { - return !!token && !token.startsWith("-") && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/) -} - function extractFromTokens(tokens: string[], patterns: Set): void { if (tokens.length === 0) return @@ -58,11 +54,7 @@ function extractFromTokens(tokens: string[], patterns: Set): void { patterns.add(pattern) for (let i = 1; i < Math.min(tokens.length, 3); i++) { - if (isValidToken(tokens[i])) { - pattern += ` ${tokens[i]}` - patterns.add(pattern) - } else { - break // Stop at first invalid token - } + pattern += ` ${tokens[i]}` + patterns.add(pattern) } } From a18be7d860ca120a3d87bcd8eab7ec5c386c360f Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 11:14:50 -0500 Subject: [PATCH 27/31] fix: update tests to match new CommandPatternSelector interface - Fixed CommandExecution tests to use pattern selector correctly - Fixed CommandPatternSelector test for edited pattern values - Updated command-parser to properly stop at flags, paths, and file extensions - Fixed linting error by removing unused patterns parameter --- .../chat/__tests__/CommandExecution.spec.tsx | 61 +++++++++++-------- .../__tests__/CommandPatternSelector.spec.tsx | 13 ++-- webview-ui/src/utils/command-parser.ts | 19 +++++- 3 files changed, 60 insertions(+), 33 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index 23f53ccd0df..f59cb9a2eab 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -22,11 +22,11 @@ vi.mock("../../common/CodeBlock", () => ({ })) vi.mock("../CommandPatternSelector", () => ({ - CommandPatternSelector: ({ command, onAllowCommandChange, onDenyCommandChange }: any) => ( + CommandPatternSelector: ({ command, onAllowPatternChange, onDenyPatternChange }: any) => (
{command} - - + +
), })) @@ -92,7 +92,9 @@ describe("CommandExecution", () => { ) expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("npm install express")).toBeInTheDocument() + // Check that the command is shown in the pattern selector + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toHaveTextContent("npm install express") }) it("should handle allow command change", () => { @@ -206,9 +208,10 @@ Suggested patterns: npm, npm install, npm run` expect(codeBlocks[0]).toHaveTextContent("npm install") expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run") - expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - // Should show the full command - expect(screen.getByText("npm install")).toBeInTheDocument() + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + // Should show the full command in the selector + expect(selector).toHaveTextContent("npm install") }) it("should handle commands with pipes", () => { @@ -218,8 +221,9 @@ Suggested patterns: npm, npm install, npm run` , ) - expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("ls -la | grep test")).toBeInTheDocument() + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("ls -la | grep test") }) it("should handle commands with && operator", () => { @@ -229,8 +233,9 @@ Suggested patterns: npm, npm install, npm run` , ) - expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("npm install && npm test")).toBeInTheDocument() + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("npm install && npm test") }) it("should not show pattern selector for empty commands", () => { @@ -316,7 +321,7 @@ Output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - expect(screen.getByText("npm install && npm test || echo 'failed'")).toBeInTheDocument() + expect(selector).toHaveTextContent("npm install && npm test || echo 'failed'") }) it("should handle commands with output", () => { @@ -338,8 +343,8 @@ Other output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should show the command - expect(screen.getByText("npm install")).toBeInTheDocument() + // Should show the command in the selector + expect(selector).toHaveTextContent("npm install") }) it("should handle commands with subshells", () => { @@ -351,7 +356,7 @@ Other output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - expect(screen.getByText("echo $(whoami) && git status")).toBeInTheDocument() + expect(selector).toHaveTextContent("echo $(whoami) && git status") }) it("should handle commands with backtick subshells", () => { @@ -363,7 +368,7 @@ Other output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - expect(screen.getByText("git commit -m `date`")).toBeInTheDocument() + expect(selector).toHaveTextContent("git commit -m `date`") }) it("should handle commands with special characters", () => { @@ -375,7 +380,7 @@ Other output here` const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - expect(screen.getByText("cd ~/projects && npm start")).toBeInTheDocument() + expect(selector).toHaveTextContent("cd ~/projects && npm start") }) it("should handle commands with mixed content including output", () => { @@ -398,8 +403,8 @@ Running tests... const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should show the command - expect(screen.getByText("npm test")).toBeInTheDocument() + // Should show the command in the selector + expect(selector).toHaveTextContent("npm test") }) it("should update both allowed and denied lists when commands conflict", () => { @@ -438,8 +443,9 @@ Running tests... expect(screen.getByTestId("code-block")).toHaveTextContent("echo 'test with unclosed quote") // Should show pattern selector with the full command - expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("echo 'test with unclosed quote")).toBeInTheDocument() + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("echo 'test with unclosed quote") }) it("should handle empty or whitespace-only commands", () => { @@ -488,8 +494,9 @@ Without any command prefix` expect(screen.getByTestId("code-block")).toHaveTextContent("docker build .") // Should show pattern selector with the full command - expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() - expect(screen.getByText("docker build .")).toBeInTheDocument() + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("docker build .") // Verify no output is shown (since there's no Output: separator) const codeBlocks = screen.getAllByTestId("code-block") @@ -518,8 +525,8 @@ Output: const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should show the full command - expect(screen.getByText("wc -l *.go *.java")).toBeInTheDocument() + // Should show the full command in the selector + expect(selector).toHaveTextContent("wc -l *.go *.java") // The output should still be displayed in the code block expect(codeBlocks.length).toBeGreaterThan(1) @@ -541,8 +548,8 @@ Output: const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() - // Should show the full command - expect(screen.getByText("wc -l *.go *.java")).toBeInTheDocument() + // Should show the full command in the selector + expect(selector).toHaveTextContent("wc -l *.go *.java") // The output should still be displayed in the code block const codeBlocks = screen.getAllByTestId("code-block") diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx index 0b8ff18b002..18c5ddd5aae 100644 --- a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -228,12 +228,15 @@ describe("CommandPatternSelector", () => { const input = screen.getByDisplayValue("npm install express") as HTMLInputElement fireEvent.change(input, { target: { value: "npm install react" } }) - // Press Enter to confirm edit - fireEvent.keyDown(input, { key: "Enter" }) + // Don't press Enter or blur - just click the button while still editing + // This simulates the user clicking the button while the input is still focused - // Click the allow button - const fullCommandPattern = screen.getByText("npm install react").closest(".ml-5") - const allowButton = fullCommandPattern?.querySelector('button[aria-label*="addToAllowed"]') + // Find the allow button in the same row as the input + const patternRow = input.closest(".ml-5") + const allowButton = patternRow?.querySelector('button[aria-label*="addToAllowed"]') + expect(allowButton).toBeInTheDocument() + + // Click the allow button - this should use the current edited value fireEvent.click(allowButton!) // Check that the callback was called with the edited pattern diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index 1c955f82a9d..c7d954665ed 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -54,7 +54,24 @@ function extractFromTokens(tokens: string[], patterns: Set): void { patterns.add(pattern) for (let i = 1; i < Math.min(tokens.length, 3); i++) { - pattern += ` ${tokens[i]}` + const token = tokens[i] + + // Stop at flags (starting with -) + if (token.startsWith("-")) break + + // Stop at paths (starting with / or ~) + if (token.startsWith("/") || token.startsWith("~")) break + + // Stop at file extensions + if (token.includes(".") && /\.\w+$/.test(token)) break + + // Stop at colons (like image:tag) + if (token.includes(":")) break + + // Stop at dots (like . for current directory) + if (token === ".") break + + pattern += ` ${token}` patterns.add(pattern) } } From c4f5c5f97fe7848452c805637abdc28ff08f9e96 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 11:35:19 -0500 Subject: [PATCH 28/31] refactor: restore parseCommandAndOutput function from main - Extracted inline parsing logic into a separate function like in main branch - Added check to not show CommandPatternSelector for empty/whitespace commands - This maintains the same functionality while keeping the code cleaner --- .../src/components/chat/CommandExecution.tsx | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 37a3faeb312..d027e5e604d 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -7,6 +7,8 @@ import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/ import { ExtensionMessage } from "@roo/ExtensionMessage" import { safeJsonParse } from "@roo/safeJsonParse" +import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences" + import { vscode } from "@src/utils/vscode" import { useExtensionState } from "@src/context/ExtensionStateContext" import { cn } from "@src/lib/utils" @@ -36,30 +38,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec setDeniedCommands, } = useExtensionState() - const { command, output: parsedOutput } = useMemo(() => { - // Parse command and output using the "Output:" separator - const outputSeparator = "Output:" - const outputIndex = text?.indexOf(`\n${outputSeparator}`) ?? -1 - - if (outputIndex !== -1) { - // Text is split into command and output - const cmd = (text ?? "").slice(0, outputIndex).trim() - // Skip the newline and "Output:" text - const afterSeparator = outputIndex + 1 + outputSeparator.length - let startOfOutput = afterSeparator - if (text![afterSeparator] === "\n") { - startOfOutput = afterSeparator + 1 - } - const out = text!.slice(startOfOutput).trim() - return { command: cmd, output: out } - } else if (text?.indexOf(outputSeparator) === 0) { - // Edge case: text starts with "Output:" (no command) - return { command: "", output: text.slice(outputSeparator.length).trim() } - } else { - // No output separator found, the entire text is the command - return { command: text?.trim() || "", output: "" } - } - }, [text]) + const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text]) // If we aren't opening the VSCode terminal for this command then we default // to expanding the command execution output. @@ -192,7 +171,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
- {command && ( + {command && command.trim() && ( { + if (!text) { + return { command: "", output: "" } + } + + const index = text.indexOf(COMMAND_OUTPUT_STRING) + + if (index === -1) { + return { command: text, output: "" } + } + + return { + command: text.slice(0, index), + output: text.slice(index + COMMAND_OUTPUT_STRING.length), + } +} From 83627716b4d3a1afbf4f63109bb0424d7d9c83e9 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 11:51:54 -0500 Subject: [PATCH 29/31] fix: ensure unique command patterns and trim whitespace in command extraction --- .../chat/CommandPatternSelector.tsx | 16 ++++++++++++++- webview-ui/src/utils/command-parser.ts | 20 ++----------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index 858bf4d7e06..431141fa326 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -34,7 +34,21 @@ export const CommandPatternSelector: React.FC = ({ // Create a combined list with full command first, then patterns const allPatterns = useMemo(() => { const fullCommandPattern: CommandPattern = { pattern: command } - return [fullCommandPattern, ...patterns] + + // Create a set to track unique patterns we've already seen + const seenPatterns = new Set() + seenPatterns.add(command) // Add the full command first + + // Filter out any patterns that are duplicates or are the same as the full command + const uniquePatterns = patterns.filter((p) => { + if (seenPatterns.has(p.pattern)) { + return false + } + seenPatterns.add(p.pattern) + return true + }) + + return [fullCommandPattern, ...uniquePatterns] }, [command, patterns]) const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => { diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index c7d954665ed..c56b0eafd9a 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -51,27 +51,11 @@ function extractFromTokens(tokens: string[], patterns: Set): void { // Build patterns progressively up to 3 levels let pattern = mainCmd - patterns.add(pattern) + patterns.add(pattern.trim()) for (let i = 1; i < Math.min(tokens.length, 3); i++) { const token = tokens[i] - - // Stop at flags (starting with -) - if (token.startsWith("-")) break - - // Stop at paths (starting with / or ~) - if (token.startsWith("/") || token.startsWith("~")) break - - // Stop at file extensions - if (token.includes(".") && /\.\w+$/.test(token)) break - - // Stop at colons (like image:tag) - if (token.includes(":")) break - - // Stop at dots (like . for current directory) - if (token === ".") break - pattern += ` ${token}` - patterns.add(pattern) + patterns.add(pattern.trim()) // Ensure no trailing whitespace } } From b3068fb4d94c02c8d6feee7ffb5dac4e0cc761e3 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 12:15:52 -0500 Subject: [PATCH 30/31] fix: implement breaking patterns in command extraction - Stop pattern extraction at flags (starting with -) - Stop at paths containing /, ~, ., or : - Limit extraction to maximum 3 levels - Fixes all failing unit tests in command-parser.spec.ts --- webview-ui/src/utils/command-parser.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index c56b0eafd9a..562500295e2 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -42,20 +42,27 @@ export function extractPatternsFromCommand(command: string): string[] { } function extractFromTokens(tokens: string[], patterns: Set): void { - if (tokens.length === 0) return + if (tokens.length === 0 || typeof tokens[0] !== "string") return const mainCmd = tokens[0] // Skip numeric commands like "0" from "0 total" if (/^\d+$/.test(mainCmd)) return - // Build patterns progressively up to 3 levels - let pattern = mainCmd - patterns.add(pattern.trim()) + patterns.add(mainCmd) - for (let i = 1; i < Math.min(tokens.length, 3); i++) { - const token = tokens[i] - pattern += ` ${token}` - patterns.add(pattern.trim()) // Ensure no trailing whitespace + // Breaking expressions that indicate we should stop looking for subcommands + const breakingExps = [/^-/, /[\\/:.~ ]/] + + // Extract up to 3 levels maximum + const maxLevels = Math.min(tokens.length, 3) + + for (let i = 1; i < maxLevels; i++) { + const arg = tokens[i] + + if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break + + const pattern = tokens.slice(0, i + 1).join(" ") + patterns.add(pattern) } } From 9a465bbcfd7ab17ce129691903a66d3a20035594 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Fri, 25 Jul 2025 12:31:08 -0500 Subject: [PATCH 31/31] fix: trim whitespace when adding patterns in extractFromTokens function --- webview-ui/src/utils/command-parser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index 562500295e2..bce464f9ad5 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -63,6 +63,6 @@ function extractFromTokens(tokens: string[], patterns: Set): void { if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break const pattern = tokens.slice(0, i + 1).join(" ") - patterns.add(pattern) + patterns.add(pattern.trim()) } }