diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 50c52abd27..37a3faeb31 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -13,6 +13,12 @@ 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 @@ -66,11 +72,19 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec // streaming output (this is the case for running commands). const output = streamingOutput || parsedOutput - // 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) + // 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) @@ -78,10 +92,10 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec vscode.postMessage({ type: "deniedCommands", commands: newDenied }) } - 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) + 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) @@ -181,10 +195,11 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec {command && ( )} diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index 340e819d3e..81ce112376 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>({}) - const getCommandStatus = (cmd: string): "allowed" | "denied" | "none" => { - if (allowedCommands.includes(cmd)) return "allowed" - if (deniedCommands.includes(cmd)) return "denied" + // 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 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 449fd504c6..76b052192b 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,11 @@ describe("CommandPatternSelector", () => { // The component should render without errors expect(container).toBeTruthy() - // Click to expand the component - const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i }) - fireEvent.click(expandButton) - - // Check that the input is rendered with the command - const input = screen.getByDisplayValue("npm install express") - expect(input).toBeInTheDocument() + // Check for the command permissions text + expect(screen.getByText("chat:commandExecution.commandPermissions")).toBeInTheDocument() }) - it("should allow editing the command", () => { + it("should show full command as first pattern when expanded", () => { render( @@ -60,42 +59,79 @@ 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 - const input = screen.getByDisplayValue("npm install express") as HTMLInputElement - fireEvent.change(input, { target: { value: "npm install react" } }) + // Check that the full command is shown with description + expect(screen.getByText("npm install express")).toBeInTheDocument() + expect(screen.getByText("- Full command")).toBeInTheDocument() + }) - // Check that the input value has changed + 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 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" } }) - // Click the allow button - const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i }) - fireEvent.click(allowButton) + // Press Enter to confirm edit + fireEvent.keyDown(input, { key: "Enter" }) - // Check that the callback was called with the edited command - expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install react") + // Click the allow button + 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" }) + + // 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 0000000000..05303f87fc --- /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 0000000000..14a4480ae7 --- /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 + } + } +}