diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 8c92ec7e7b6..d027e5e604d 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -6,6 +6,7 @@ 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" @@ -13,6 +14,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 { extractPatternsFromCommand } from "../../utils/command-parser" + +interface CommandPattern { + pattern: string + description?: string +} interface CommandExecutionProps { executionId: string @@ -22,7 +30,13 @@ interface CommandExecutionProps { } export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => { - const { terminalShellIntegrationDisabled = false } = useExtensionState() + const { + terminalShellIntegrationDisabled = false, + allowedCommands = [], + deniedCommands = [], + setAllowedCommands, + setDeniedCommands, + } = useExtensionState() const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text]) @@ -37,6 +51,37 @@ 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(() => { + 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) + + 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 +166,21 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec -
- - +
+
+ + +
+ {command && command.trim() && ( + + )}
) diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx new file mode 100644 index 00000000000..431141fa326 --- /dev/null +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -0,0 +1,183 @@ +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[] + onAllowPatternChange: (pattern: string) => void + onDenyPatternChange: (pattern: string) => void +} + +export const CommandPatternSelector: React.FC = ({ + command, + patterns, + allowedCommands, + deniedCommands, + onAllowPatternChange, + onDenyPatternChange, +}) => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + const [editingStates, setEditingStates] = useState>({}) + + // Create a combined list with full command first, then patterns + const allPatterns = useMemo(() => { + const fullCommandPattern: CommandPattern = { pattern: command } + + // 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" => { + if (allowedCommands.includes(pattern)) return "allowed" + if (deniedCommands.includes(pattern)) return "denied" + return "none" + } + + 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 ( +
+
+ + + {isExpanded && ( +
+ {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__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx new file mode 100644 index 00000000000..f59cb9a2eab --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -0,0 +1,560 @@ +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: ({ command, onAllowPatternChange, onDenyPatternChange }: any) => ( +
+ {command} + + +
+ ), +})) + +// 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 commands", () => { + render( + + + , + ) + + expect(screen.getByTestId("command-pattern-selector")).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", () => { + render( + + + , + ) + + const allowButton = screen.getByText("Allow git push") + fireEvent.click(allowButton) + + expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git push"]) + expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"]) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git push"] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] }) + }) + + it("should handle deny command change", () => { + render( + + + , + ) + + const denyButton = screen.getByText("Deny docker run") + fireEvent.click(denyButton) + + expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"]) + expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker run"]) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] }) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker run"] }) + }) + + 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 test") + fireEvent.click(allowButton) + + // "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 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 -rf") + fireEvent.click(denyButton) + + // "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: [] }) + }) + + 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 output", () => { + 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") + + 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", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("ls -la | grep test") + }) + + it("should handle commands with && operator", () => { + render( + + + , + ) + + 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", () => { + 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( + + + , + ) + + // Should show pattern selector when patterns are available + expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument() + }) + + 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 file.txt") + fireEvent.click(allowButton) + + // "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 show complex commands with multiple operators", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("npm install && npm test || echo 'failed'") + }) + + it("should handle commands with output", () => { + const commandWithOutput = `npm install +Output: +Installing packages... +Other output here` + + render( + + icon} + title={Run Command} + /> + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + // Should show the command in the selector + expect(selector).toHaveTextContent("npm install") + }) + + it("should handle commands with subshells", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("echo $(whoami) && git status") + }) + + it("should handle commands with backtick subshells", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("git commit -m `date`") + }) + + it("should handle commands with special characters", () => { + render( + + + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + expect(selector).toHaveTextContent("cd ~/projects && npm start") + }) + + it("should handle commands with mixed content including output", () => { + const commandWithMixedContent = `npm test +Output: +Running tests... +✓ Test 1 passed +✓ Test 2 passed` + + render( + + icon} + title={Run Command} + /> + , + ) + + const selector = screen.getByTestId("command-pattern-selector") + expect(selector).toBeInTheDocument() + // Should show the command in the selector + expect(selector).toHaveTextContent("npm test") + }) + + it("should update both allowed and denied lists when commands conflict", () => { + const conflictState = { + ...mockExtensionState, + allowedCommands: ["git"], + deniedCommands: ["git push origin main"], + } + + render( + + + , + ) + + // 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 origin main"]) + expect(conflictState.setDeniedCommands).toHaveBeenCalledWith([]) + }) + + 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 the full command + 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", () => { + 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 simple commands", () => { + const plainCommand = "docker build ." + + render( + + + , + ) + + // Should render the command + expect(screen.getByTestId("code-block")).toHaveTextContent("docker build .") + + // Should show pattern selector with the full command + 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") + expect(codeBlocks).toHaveLength(1) // Only the command block, no output block + }) + + it("should handle commands with numeric output", () => { + 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 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) + expect(codeBlocks[1].textContent).toContain("45 total") + }) + + it("should handle commands with zero output", () => { + 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 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") + 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..18c5ddd5aae --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -0,0 +1,272 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { describe, it, expect, vi } from "vitest" +import { CommandPatternSelector } from "../CommandPatternSelector" +import { TooltipProvider } from "../../../components/ui/tooltip" + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ i18nKey, children }: any) => {i18nKey || children}, +})) + +// Mock VSCodeLink +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: ({ children, onClick }: any) => ( + + {children} + + ), +})) + +// Wrapper component with TooltipProvider +const TestWrapper = ({ children }: { children: React.ReactNode }) => {children} + +describe("CommandPatternSelector", () => { + 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"], + onAllowPatternChange: vi.fn(), + onDenyPatternChange: vi.fn(), + } + + it("should render with command permissions header", () => { + const { container } = render( + + + , + ) + + // The component should render without errors + expect(container).toBeTruthy() + + // Check for the command permissions text + expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument() + }) + + it("should show full command as first pattern when expanded", () => { + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button") + fireEvent.click(expandButton) + + // Check that the full command is shown + expect(screen.getByText("npm install express")).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 patterns when clicked", () => { + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button") + fireEvent.click(expandButton) + + // 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 + expect(input).toBeInTheDocument() + + // Change the value + fireEvent.change(input, { target: { value: "npm install react" } }) + expect(input.value).toBe("npm install react") + }) + + it("should show allowed status for patterns in allowed list", () => { + render( + + + , + ) + + // Click to expand the component + 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 = npmInstallPattern?.querySelector('button[aria-label*="removeFromAllowed"]') + expect(allowButton).toBeInTheDocument() + }) + + it("should show denied status for patterns in denied list", () => { + const props = { + ...defaultProps, + patterns: [{ pattern: "git push", description: "Push to git" }], + } + + render( + + + , + ) + + // Click to expand the component + 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 = gitPushPattern?.querySelector('button[aria-label*="removeFromDenied"]') + expect(denyButton).toBeInTheDocument() + }) + + it("should call onAllowPatternChange when allow button is clicked", () => { + const mockOnAllowPatternChange = vi.fn() + const props = { + ...defaultProps, + onAllowPatternChange: mockOnAllowPatternChange, + } + + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button") + fireEvent.click(expandButton) + + // 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 pattern + expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install express") + }) + + it("should call onDenyPatternChange when deny button is clicked", () => { + const mockOnDenyPatternChange = vi.fn() + const props = { + ...defaultProps, + onDenyPatternChange: mockOnDenyPatternChange, + } + + render( + + + , + ) + + // Click to expand the component + const expandButton = screen.getByRole("button") + fireEvent.click(expandButton) + + // 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 pattern + expect(mockOnDenyPatternChange).toHaveBeenCalledWith("npm install express") + }) + + it("should use edited pattern value when buttons are clicked", () => { + const mockOnAllowPatternChange = vi.fn() + const props = { + ...defaultProps, + onAllowPatternChange: mockOnAllowPatternChange, + } + + 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" } }) + + // 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 + + // 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 + 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" }) + + // 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/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..b0a9d16e6a5 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 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__/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..bce464f9ad5 --- /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 extractFromTokens(tokens: string[], patterns: Set): void { + 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 + + patterns.add(mainCmd) + + // 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.trim()) + } +}