Browse Source

feat: Add terminal command permissions UI to chat interface (#5480) (#5798)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Roo Code <[email protected]>
Co-authored-by: Daniel Riccio <[email protected]>
Co-authored-by: Daniel <[email protected]>
Hannes Rudolph 5 months ago
parent
commit
c68f8f591e

+ 61 - 4
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<CommandPattern[]>(() => {
+		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
 				</div>
 			</div>
 
-			<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
-				<CodeBlock source={command} language="shell" />
-				<OutputContainer isExpanded={isExpanded} output={output} />
+			<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs">
+				<div className="p-2">
+					<CodeBlock source={command} language="shell" />
+					<OutputContainer isExpanded={isExpanded} output={output} />
+				</div>
+				{command && command.trim() && (
+					<CommandPatternSelector
+						command={command}
+						patterns={commandPatterns}
+						allowedCommands={allowedCommands}
+						deniedCommands={deniedCommands}
+						onAllowPatternChange={handleAllowPatternChange}
+						onDenyPatternChange={handleDenyPatternChange}
+					/>
+				)}
 			</div>
 		</>
 	)

+ 183 - 0
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<CommandPatternSelectorProps> = ({
+	command,
+	patterns,
+	allowedCommands,
+	deniedCommands,
+	onAllowPatternChange,
+	onDenyPatternChange,
+}) => {
+	const { t } = useTranslation()
+	const [isExpanded, setIsExpanded] = useState(false)
+	const [editingStates, setEditingStates] = useState<Record<string, { isEditing: boolean; value: string }>>({})
+
+	// 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<string>()
+		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 (
+		<div className="border-t border-vscode-panel-border bg-vscode-sideBar-background/30">
+			<button
+				onClick={() => setIsExpanded(!isExpanded)}
+				className="w-full px-3 py-2 flex items-center justify-between hover:bg-vscode-list-hoverBackground transition-colors">
+				<div className="flex items-center gap-2">
+					<ChevronDown
+						className={cn("size-4 transition-transform", {
+							"-rotate-90": !isExpanded,
+						})}
+					/>
+					<span className="text-sm font-medium">{t("chat:commandExecution.manageCommands")}</span>
+					<StandardTooltip
+						content={
+							<div className="max-w-xs">
+								<Trans
+									i18nKey="chat:commandExecution.commandManagementDescription"
+									components={{
+										settingsLink: (
+											<VSCodeLink
+												href="command:workbench.action.openSettings?%5B%22roo-code%22%5D"
+												className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground"
+											/>
+										),
+									}}
+								/>
+							</div>
+						}>
+						<Info className="size-3.5 text-vscode-descriptionForeground" />
+					</StandardTooltip>
+				</div>
+			</button>
+
+			{isExpanded && (
+				<div className="px-3 pb-3 space-y-2">
+					{allPatterns.map((item) => {
+						const editState = getEditState(item.pattern)
+						const status = getPatternStatus(editState.value)
+
+						return (
+							<div key={item.pattern} className="ml-5 flex items-center gap-2">
+								<div className="flex-1">
+									{editState.isEditing ? (
+										<input
+											type="text"
+											value={editState.value}
+											onChange={(e) => 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
+										/>
+									) : (
+										<div
+											onClick={() => 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">
+											<span>{editState.value}</span>
+											{item.description && (
+												<span className="text-vscode-descriptionForeground ml-2">
+													- {item.description}
+												</span>
+											)}
+										</div>
+									)}
+								</div>
+								<div className="flex items-center gap-1">
+									<button
+										className={cn("p-1 rounded transition-all", {
+											"bg-green-500/20 text-green-500 hover:bg-green-500/30":
+												status === "allowed",
+											"text-vscode-descriptionForeground hover:text-green-500 hover:bg-green-500/10":
+												status !== "allowed",
+										})}
+										onClick={() => onAllowPatternChange(editState.value)}
+										aria-label={t(
+											status === "allowed"
+												? "chat:commandExecution.removeFromAllowed"
+												: "chat:commandExecution.addToAllowed",
+										)}>
+										<Check className="size-3.5" />
+									</button>
+									<button
+										className={cn("p-1 rounded transition-all", {
+											"bg-red-500/20 text-red-500 hover:bg-red-500/30": status === "denied",
+											"text-vscode-descriptionForeground hover:text-red-500 hover:bg-red-500/10":
+												status !== "denied",
+										})}
+										onClick={() => onDenyPatternChange(editState.value)}
+										aria-label={t(
+											status === "denied"
+												? "chat:commandExecution.removeFromDenied"
+												: "chat:commandExecution.addToDenied",
+										)}>
+										<X className="size-3.5" />
+									</button>
+								</div>
+							</div>
+						)
+					})}
+				</div>
+			)}
+		</div>
+	)
+}

+ 560 - 0
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 }) => <div data-testid="code-block">{source}</div>,
+}))
+
+vi.mock("../CommandPatternSelector", () => ({
+	CommandPatternSelector: ({ command, onAllowPatternChange, onDenyPatternChange }: any) => (
+		<div data-testid="command-pattern-selector">
+			<span>{command}</span>
+			<button onClick={() => onAllowPatternChange(command)}>Allow {command}</button>
+			<button onClick={() => onDenyPatternChange(command)}>Deny {command}</button>
+		</div>
+	),
+}))
+
+// Mock ExtensionStateContext
+const mockExtensionState = {
+	terminalShellIntegrationDisabled: false,
+	allowedCommands: ["npm"],
+	deniedCommands: ["rm"],
+	setAllowedCommands: vi.fn(),
+	setDeniedCommands: vi.fn(),
+}
+
+const ExtensionStateWrapper = ({ children }: { children: React.ReactNode }) => (
+	<ExtensionStateContext.Provider value={mockExtensionState as any}>{children}</ExtensionStateContext.Provider>
+)
+
+describe("CommandExecution", () => {
+	beforeEach(() => {
+		vi.clearAllMocks()
+	})
+
+	it("should render command without output", () => {
+		render(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="npm install" />
+			</ExtensionStateWrapper>,
+		)
+
+		expect(screen.getByTestId("code-block")).toHaveTextContent("npm install")
+	})
+
+	it("should render command with output", () => {
+		render(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="npm install\nOutput:\nInstalling packages..." />
+			</ExtensionStateWrapper>,
+		)
+
+		const codeBlocks = screen.getAllByTestId("code-block")
+		expect(codeBlocks[0]).toHaveTextContent("npm install")
+	})
+
+	it("should render with custom icon and title", () => {
+		const icon = <span data-testid="custom-icon">📦</span>
+		const title = <span data-testid="custom-title">Installing Dependencies</span>
+
+		render(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="npm install" icon={icon} title={title} />
+			</ExtensionStateWrapper>,
+		)
+
+		expect(screen.getByTestId("custom-icon")).toBeInTheDocument()
+		expect(screen.getByTestId("custom-title")).toBeInTheDocument()
+	})
+
+	it("should show command pattern selector for commands", () => {
+		render(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="npm install express" />
+			</ExtensionStateWrapper>,
+		)
+
+		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(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="git push" />
+			</ExtensionStateWrapper>,
+		)
+
+		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(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="docker run" />
+			</ExtensionStateWrapper>,
+		)
+
+		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(
+			<ExtensionStateContext.Provider value={stateWithNpmTest as any}>
+				<CommandExecution executionId="test-1" text="npm test" />
+			</ExtensionStateContext.Provider>,
+		)
+
+		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(
+			<ExtensionStateContext.Provider value={stateWithRmRf as any}>
+				<CommandExecution executionId="test-1" text="rm -rf" />
+			</ExtensionStateContext.Provider>,
+		)
+
+		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(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text={commandText} />
+			</ExtensionStateWrapper>,
+		)
+
+		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(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text={commandText} />
+			</ExtensionStateWrapper>,
+		)
+
+		// 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(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="ls -la | grep test" />
+			</ExtensionStateWrapper>,
+		)
+
+		const selector = screen.getByTestId("command-pattern-selector")
+		expect(selector).toBeInTheDocument()
+		expect(selector).toHaveTextContent("ls -la | grep test")
+	})
+
+	it("should handle commands with && operator", () => {
+		render(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="npm install && npm test" />
+			</ExtensionStateWrapper>,
+		)
+
+		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(
+			<ExtensionStateWrapper>
+				<CommandExecution executionId="test-1" text="" />
+			</ExtensionStateWrapper>,
+		)
+
+		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(
+			<ExtensionStateContext.Provider value={disabledState as any}>
+				<CommandExecution executionId="test-1" text={commandText} />
+			</ExtensionStateContext.Provider>,
+		)
+
+		// 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(
+			<ExtensionStateContext.Provider value={stateWithUndefined as any}>
+				<CommandExecution executionId="test-1" text="npm install" />
+			</ExtensionStateContext.Provider>,
+		)
+
+		// 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(
+			<ExtensionStateContext.Provider value={stateWithRmInDenied as any}>
+				<CommandExecution executionId="test-1" text="rm file.txt" />
+			</ExtensionStateContext.Provider>,
+		)
+
+		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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-6" text="npm install && npm test || echo 'failed'" />
+				</ExtensionStateWrapper>,
+			)
+
+			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(
+				<ExtensionStateWrapper>
+					<CommandExecution
+						executionId="test-6"
+						text={commandWithOutput}
+						icon={<span>icon</span>}
+						title={<span>Run Command</span>}
+					/>
+				</ExtensionStateWrapper>,
+			)
+
+			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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-7" text="echo $(whoami) && git status" />
+				</ExtensionStateWrapper>,
+			)
+
+			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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-8" text="git commit -m `date`" />
+				</ExtensionStateWrapper>,
+			)
+
+			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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-9" text="cd ~/projects && npm start" />
+				</ExtensionStateWrapper>,
+			)
+
+			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(
+				<ExtensionStateWrapper>
+					<CommandExecution
+						executionId="test-10"
+						text={commandWithMixedContent}
+						icon={<span>icon</span>}
+						title={<span>Run Command</span>}
+					/>
+				</ExtensionStateWrapper>,
+			)
+
+			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(
+				<ExtensionStateContext.Provider value={conflictState as any}>
+					<CommandExecution executionId="test-11" text="git push origin main" />
+				</ExtensionStateContext.Provider>,
+			)
+
+			// 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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-12" text={commandWithQuotes} />
+				</ExtensionStateWrapper>,
+			)
+
+			// 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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-13" text="   " />
+				</ExtensionStateWrapper>,
+			)
+
+			// 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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-14" text={outputOnly} />
+				</ExtensionStateWrapper>,
+			)
+
+			// 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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-15" text={plainCommand} />
+				</ExtensionStateWrapper>,
+			)
+
+			// 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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-16" text={commandWithNumericOutput} />
+				</ExtensionStateWrapper>,
+			)
+
+			// 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(
+				<ExtensionStateWrapper>
+					<CommandExecution executionId="test-17" text={commandWithZeroTotal} />
+				</ExtensionStateWrapper>,
+			)
+
+			// 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")
+		})
+	})
+})

+ 272 - 0
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) => <span>{i18nKey || children}</span>,
+}))
+
+// Mock VSCodeLink
+vi.mock("@vscode/webview-ui-toolkit/react", () => ({
+	VSCodeLink: ({ children, onClick }: any) => (
+		<a href="#" onClick={onClick}>
+			{children}
+		</a>
+	),
+}))
+
+// Wrapper component with TooltipProvider
+const TestWrapper = ({ children }: { children: React.ReactNode }) => <TooltipProvider>{children}</TooltipProvider>
+
+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(
+			<TestWrapper>
+				<CommandPatternSelector {...defaultProps} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...defaultProps} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...defaultProps} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...defaultProps} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...defaultProps} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...props} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...props} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...props} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...props} />
+			</TestWrapper>,
+		)
+
+		// 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(
+			<TestWrapper>
+				<CommandPatternSelector {...defaultProps} />
+			</TestWrapper>,
+		)
+
+		// 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()
+	})
+})

+ 16 - 0
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. <settingsLink>Mostra tots els paràmetres</settingsLink>",
+		"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": {

+ 16 - 0
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. <settingsLink>Alle Einstellungen anzeigen</settingsLink>",
+		"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": {

+ 16 - 0
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. <settingsLink>View all settings</settingsLink>",
+		"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": {

+ 16 - 0
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. <settingsLink>Ver todos los ajustes</settingsLink>",
+		"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": {

+ 16 - 0
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. <settingsLink>Voir tous les paramètres</settingsLink>",
+		"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": {

+ 16 - 0
webview-ui/src/i18n/locales/hi/chat.json

@@ -192,6 +192,22 @@
 		"didViewDefinitionsOutsideWorkspace": "Roo ने इस निर्देशिका (कार्यक्षेत्र के बाहर) में उपयोग किए गए सोर्स कोड परिभाषा नामों को देखा:"
 	},
 	"commandOutput": "कमांड आउटपुट",
+	"commandExecution": {
+		"running": "चलाया जा रहा है",
+		"pid": "पीआईडी: {{pid}}",
+		"exited": "बाहर निकल गया ({{exitCode}})",
+		"manageCommands": "कमांड अनुमतियाँ प्रबंधित करें",
+		"commandManagementDescription": "कमांड अनुमतियों का प्रबंधन करें: स्वतः-निष्पादन की अनुमति देने के लिए ✓ पर क्लिक करें, निष्पादन से इनकार करने के लिए ✗ पर क्लिक करें। पैटर्न को चालू/बंद किया जा सकता है या सूचियों से हटाया जा सकता है। <settingsLink>सभी सेटिंग्स देखें</settingsLink>",
+		"addToAllowed": "अनुमत सूची में जोड़ें",
+		"removeFromAllowed": "अनुमत सूची से हटाएं",
+		"addToDenied": "अस्वीकृत सूची में जोड़ें",
+		"removeFromDenied": "अस्वीकृत सूची से हटाएं",
+		"abortCommand": "कमांड निष्पादन रद्द करें",
+		"expandOutput": "आउटपुट का विस्तार करें",
+		"collapseOutput": "आउटपुट संक्षिप्त करें",
+		"expandManagement": "कमांड प्रबंधन अनुभाग का विस्तार करें",
+		"collapseManagement": "कमांड प्रबंधन अनुभाग संक्षिप्त करें"
+	},
 	"response": "प्रतिक्रिया",
 	"arguments": "आर्ग्युमेंट्स",
 	"mcp": {

+ 16 - 0
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. <settingsLink>Lihat semua pengaturan</settingsLink>",
+		"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": {

+ 16 - 0
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. <settingsLink>Visualizza tutte le impostazioni</settingsLink>",
+		"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": {

+ 16 - 0
webview-ui/src/i18n/locales/ja/chat.json

@@ -192,6 +192,22 @@
 		"didViewDefinitionsOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)で使用されているソースコード定義名を表示しました:"
 	},
 	"commandOutput": "コマンド出力",
+	"commandExecution": {
+		"running": "実行中",
+		"pid": "PID: {{pid}}",
+		"exited": "終了しました ({{exitCode}})",
+		"manageCommands": "コマンド権限の管理",
+		"commandManagementDescription": "コマンドの権限を管理します:✓ をクリックして自動実行を許可し、✗ をクリックして実行を拒否します。パターンはオン/オフの切り替えやリストからの削除が可能です。<settingsLink>すべての設定を表示</settingsLink>",
+		"addToAllowed": "許可リストに追加",
+		"removeFromAllowed": "許可リストから削除",
+		"addToDenied": "拒否リストに追加",
+		"removeFromDenied": "拒否リストから削除",
+		"abortCommand": "コマンドの実行を中止",
+		"expandOutput": "出力を展開",
+		"collapseOutput": "出力を折りたたむ",
+		"expandManagement": "コマンド管理セクションを展開",
+		"collapseManagement": "コマンド管理セクションを折りたたむ"
+	},
 	"response": "応答",
 	"arguments": "引数",
 	"mcp": {

+ 16 - 0
webview-ui/src/i18n/locales/ko/chat.json

@@ -192,6 +192,22 @@
 		"didViewDefinitionsOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)에서 사용된 소스 코드 정의 이름을 보았습니다:"
 	},
 	"commandOutput": "명령 출력",
+	"commandExecution": {
+		"running": "실행 중",
+		"pid": "PID: {{pid}}",
+		"exited": "종료됨 ({{exitCode}})",
+		"manageCommands": "명령 권한 관리",
+		"commandManagementDescription": "명령 권한 관리: 자동 실행을 허용하려면 ✓를 클릭하고 실행을 거부하려면 ✗를 클릭하십시오. 패턴은 켜거나 끄거나 목록에서 제거할 수 있습니다. <settingsLink>모든 설정 보기</settingsLink>",
+		"addToAllowed": "허용 목록에 추가",
+		"removeFromAllowed": "허용 목록에서 제거",
+		"addToDenied": "거부 목록에 추가",
+		"removeFromDenied": "거부 목록에서 제거",
+		"abortCommand": "명령 실행 중단",
+		"expandOutput": "출력 확장",
+		"collapseOutput": "출력 축소",
+		"expandManagement": "명령 관리 섹션 확장",
+		"collapseManagement": "명령 관리 섹션 축소"
+	},
 	"response": "응답",
 	"arguments": "인수",
 	"mcp": {

+ 16 - 0
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. <settingsLink>Bekijk alle instellingen</settingsLink>",
+		"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": {

+ 16 - 0
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. <settingsLink>Zobacz wszystkie ustawienia</settingsLink>",
+		"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": {

+ 16 - 0
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. <settingsLink>Ver todas as configurações</settingsLink>",
+		"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": {

+ 16 - 0
webview-ui/src/i18n/locales/ru/chat.json

@@ -187,6 +187,22 @@
 		"didViewDefinitionsOutsideWorkspace": "Roo просмотрел имена определений исходного кода в этой директории (вне рабочего пространства):"
 	},
 	"commandOutput": "Вывод команды",
+	"commandExecution": {
+		"running": "Выполняется",
+		"pid": "PID: {{pid}}",
+		"exited": "Завершено ({{exitCode}})",
+		"manageCommands": "Управление разрешениями команд",
+		"commandManagementDescription": "Управляйте разрешениями команд: Нажмите ✓, чтобы разрешить автоматическое выполнение, ✗, чтобы запретить выполнение. Шаблоны можно включать/выключать или удалять из списков. <settingsLink>Просмотреть все настройки</settingsLink>",
+		"addToAllowed": "Добавить в список разрешенных",
+		"removeFromAllowed": "Удалить из списка разрешенных",
+		"addToDenied": "Добавить в список запрещенных",
+		"removeFromDenied": "Удалить из списка запрещенных",
+		"abortCommand": "Прервать выполнение команды",
+		"expandOutput": "Развернуть вывод",
+		"collapseOutput": "Свернуть вывод",
+		"expandManagement": "Развернуть раздел управления командами",
+		"collapseManagement": "Свернуть раздел управления командами"
+	},
 	"response": "Ответ",
 	"arguments": "Аргументы",
 	"mcp": {

+ 16 - 0
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. <settingsLink>Tüm ayarları görüntüle</settingsLink>",
+		"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": {

+ 16 - 0
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. <settingsLink>Xem tất cả cài đặt</settingsLink>",
+		"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": {

+ 16 - 0
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": "管理命令权限:点击 ✓ 允许自动执行,点击 ✗ 拒绝执行。可以打开/关闭模式或从列表中删除。<settingsLink>查看所有设置</settingsLink>",
+		"addToAllowed": "添加到允许列表",
+		"removeFromAllowed": "从允许列表中删除",
+		"addToDenied": "添加到拒绝列表",
+		"removeFromDenied": "从拒绝列表中删除",
+		"abortCommand": "中止命令执行",
+		"expandOutput": "展开输出",
+		"collapseOutput": "折叠输出",
+		"expandManagement": "展开命令管理部分",
+		"collapseManagement": "折叠命令管理部分"
+	},
 	"response": "响应",
 	"arguments": "参数",
 	"mcp": {

+ 16 - 0
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": "管理命令權限:點擊 ✓ 允許自動執行,點擊 ✗ 拒絕執行。可以開啟/關閉模式或從清單中刪除。<settingsLink>檢視所有設定</settingsLink>",
+		"addToAllowed": "新增至允許清單",
+		"removeFromAllowed": "從允許清單中移除",
+		"addToDenied": "新增至拒絕清單",
+		"removeFromDenied": "從拒絕清單中移除",
+		"abortCommand": "中止命令執行",
+		"expandOutput": "展開輸出",
+		"collapseOutput": "折疊輸出",
+		"expandManagement": "展開命令管理部分",
+		"collapseManagement": "折疊命令管理部分"
+	},
 	"response": "回應",
 	"arguments": "參數",
 	"mcp": {

+ 137 - 0
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
+	})
+})

+ 68 - 0
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<string>()
+
+	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<string>): 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())
+	}
+}