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 <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
This commit is contained in:
Hannes Rudolph 2025-07-25 14:49:51 -06:00 committed by GitHub
parent f45d9be709
commit c68f8f591e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1569 additions and 4 deletions

View file

@ -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>
</>
)

View file

@ -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>
)
}

View file

@ -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")
})
})
})

View file

@ -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()
})
})

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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
})
})

View file

@ -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())
}
}