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

This commit is contained in:
hannesrudolph 2025-07-16 18:27:33 -06:00
parent ba541ab848
commit 6fda13643c
24 changed files with 1512 additions and 5 deletions

View file

@ -13,6 +13,13 @@ import { useExtensionState } from "@src/context/ExtensionStateContext"
import { cn } from "@src/lib/utils"
import { Button } from "@src/components/ui"
import CodeBlock from "../common/CodeBlock"
import { CommandPatternSelector } from "./CommandPatternSelector"
import {
extractCommandPatterns,
getPatternDescription,
parseCommandAndOutput as parseCommandAndOutputUtil,
CommandPattern,
} from "../../utils/commandPatterns"
interface CommandExecutionProps {
executionId: string
@ -22,21 +29,91 @@ interface CommandExecutionProps {
}
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
const { terminalShellIntegrationDisabled = false } = useExtensionState()
const {
terminalShellIntegrationDisabled = false,
allowedCommands = [],
deniedCommands = [],
setAllowedCommands,
setDeniedCommands,
} = useExtensionState()
const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text])
const {
command,
output: parsedOutput,
suggestions,
} = useMemo(() => {
// First try our enhanced parser
const enhanced = parseCommandAndOutputUtil(text || "")
// If it found a command, use it, otherwise fall back to the original parser
if (enhanced.command && enhanced.command !== text) {
return enhanced
}
// Fall back to original parser
const original = parseCommandAndOutput(text)
return { ...original, suggestions: [] }
}, [text])
// If we aren't opening the VSCode terminal for this command then we default
// to expanding the command execution output.
const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
const [streamingOutput, setStreamingOutput] = useState("")
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
const [showSuggestions] = useState(true)
// The command's output can either come from the text associated with the
// task message (this is the case for completed commands) or from the
// streaming output (this is the case for running commands).
const output = streamingOutput || parsedOutput
// Extract command patterns
const commandPatterns = useMemo<CommandPattern[]>(() => {
const patterns: CommandPattern[] = []
// Use AI suggestions if available
if (suggestions.length > 0) {
suggestions.forEach((suggestion) => {
patterns.push({
pattern: suggestion,
description: getPatternDescription(suggestion),
})
})
} else {
// Extract patterns programmatically
const extractedPatterns = extractCommandPatterns(command)
extractedPatterns.forEach((pattern) => {
patterns.push({
pattern,
description: getPatternDescription(pattern),
})
})
}
return patterns
}, [command, suggestions])
// Handle pattern changes
const handleAllowPatternChange = (pattern: string) => {
const isAllowed = allowedCommands.includes(pattern)
const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern]
const newDenied = deniedCommands.filter((p) => p !== pattern)
setAllowedCommands(newAllowed)
setDeniedCommands(newDenied)
vscode.postMessage({ type: "allowedCommands", commands: newAllowed })
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
}
const handleDenyPatternChange = (pattern: string) => {
const isDenied = deniedCommands.includes(pattern)
const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern]
const newAllowed = allowedCommands.filter((p) => p !== pattern)
setAllowedCommands(newAllowed)
setDeniedCommands(newDenied)
vscode.postMessage({ type: "allowedCommands", commands: newAllowed })
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
}
const onMessage = useCallback(
(event: MessageEvent) => {
const message: ExtensionMessage = event.data
@ -121,9 +198,20 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
</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>
{showSuggestions && commandPatterns.length > 0 && (
<CommandPatternSelector
patterns={commandPatterns}
allowedCommands={allowedCommands}
deniedCommands={deniedCommands}
onAllowPatternChange={handleAllowPatternChange}
onDenyPatternChange={handleDenyPatternChange}
/>
)}
</div>
</>
)

View file

@ -0,0 +1,130 @@
import React, { useState } from "react"
import { Check, ChevronDown, Info, X } from "lucide-react"
import { cn } from "../../lib/utils"
import { useTranslation, Trans } from "react-i18next"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { CommandPattern } from "../../utils/commandPatterns"
import { StandardTooltip } from "../ui/standard-tooltip"
interface CommandPatternSelectorProps {
patterns: CommandPattern[]
allowedCommands: string[]
deniedCommands: string[]
onAllowPatternChange: (pattern: string) => void
onDenyPatternChange: (pattern: string) => void
}
export const CommandPatternSelector: React.FC<CommandPatternSelectorProps> = ({
patterns,
allowedCommands,
deniedCommands,
onAllowPatternChange,
onDenyPatternChange,
}) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => {
if (allowedCommands.includes(pattern)) return "allowed"
if (deniedCommands.includes(pattern)) return "denied"
return "none"
}
return (
<div className="border-t border-vscode-panel-border bg-vscode-sideBar-background/30">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-vscode-descriptionForeground hover:text-vscode-foreground hover:bg-vscode-list-hoverBackground transition-all"
aria-expanded={isExpanded}
aria-label={t(
isExpanded ? "chat:commandExecution.collapseManagement" : "chat:commandExecution.expandManagement",
)}>
<ChevronDown
className={cn("size-3 transition-transform duration-200", {
"rotate-0": isExpanded,
"-rotate-90": !isExpanded,
})}
/>
<span className="font-medium">{t("chat:commandExecution.manageCommands")}</span>
<StandardTooltip
content={
<Trans
i18nKey="chat:commandExecution.commandManagementDescription"
components={{
settingsLink: (
<VSCodeLink
href="#"
onClick={(e) => {
e.preventDefault()
window.postMessage(
{
type: "action",
action: "settingsButtonClicked",
values: { section: "autoApprove" },
},
"*",
)
}}
className="inline"
/>
),
}}
/>
}>
<Info className="size-3 ml-1" />
</StandardTooltip>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-2">
{patterns.map((item, index) => {
const status = getPatternStatus(item.pattern)
return (
<div key={`${item.pattern}-${index}`} className="ml-5 flex items-center gap-2">
<div className="flex-1">
<span className="font-mono text-xs text-vscode-foreground">{item.pattern}</span>
{item.description && (
<span className="text-xs text-vscode-descriptionForeground ml-2">
- {item.description}
</span>
)}
</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(item.pattern)}
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(item.pattern)}
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,277 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { CommandExecution } from "../CommandExecution"
import { ExtensionStateContext } from "../../../context/ExtensionStateContext"
// Mock dependencies
vi.mock("react-use", () => ({
useEvent: vi.fn(),
}))
import { vscode } from "../../../utils/vscode"
vi.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
vi.mock("../../common/CodeBlock", () => ({
default: ({ source }: { source: string }) => <div data-testid="code-block">{source}</div>,
}))
vi.mock("../CommandPatternSelector", () => ({
CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => (
<div data-testid="command-pattern-selector">
{patterns.map((p: any, i: number) => (
<div key={i}>
<span>{p.pattern}</span>
<button onClick={() => onAllowPatternChange(p.pattern)}>Allow {p.pattern}</button>
<button onClick={() => onDenyPatternChange(p.pattern)}>Deny {p.pattern}</button>
</div>
))}
</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\n\nCommand Output:\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 simple commands", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="npm install express" />
</ExtensionStateWrapper>,
)
expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
expect(screen.getByText("npm")).toBeInTheDocument()
expect(screen.getByText("npm install")).toBeInTheDocument()
})
it("should handle allow pattern change", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="git push" />
</ExtensionStateWrapper>,
)
const allowButton = screen.getByText("Allow git")
fireEvent.click(allowButton)
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git"])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git"] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] })
})
it("should handle deny pattern change", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="docker run" />
</ExtensionStateWrapper>,
)
const denyButton = screen.getByText("Deny docker")
fireEvent.click(denyButton)
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker"])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker"] })
})
it("should toggle allowed pattern", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="npm test" />
</ExtensionStateWrapper>,
)
const allowButton = screen.getByText("Allow npm")
fireEvent.click(allowButton)
// npm is already in allowedCommands, so it should be removed
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith([])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: [] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] })
})
it("should toggle denied pattern", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="rm -rf" />
</ExtensionStateWrapper>,
)
const denyButton = screen.getByText("Deny rm")
fireEvent.click(denyButton)
// rm is already in deniedCommands, so it should be removed
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] })
})
it("should parse command with $ prefix", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="$ npm install\nInstalling..." />
</ExtensionStateWrapper>,
)
expect(screen.getByTestId("code-block")).toHaveTextContent("npm install")
})
it("should parse command with AI suggestions", () => {
render(
<ExtensionStateWrapper>
<CommandExecution
executionId="test-1"
text="$ npm install\nSuggested patterns: npm, npm install, npm run"
/>
</ExtensionStateWrapper>,
)
expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
// Check that the patterns are present in the mock
expect(screen.getByText("npm")).toBeInTheDocument()
})
it("should handle commands with pipes", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="ls -la | grep test" />
</ExtensionStateWrapper>,
)
expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
expect(screen.getByText("ls")).toBeInTheDocument()
expect(screen.getByText("grep")).toBeInTheDocument()
})
it("should handle commands with && operator", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="npm install && npm test" />
</ExtensionStateWrapper>,
)
expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
expect(screen.getByText("npm")).toBeInTheDocument()
expect(screen.getByText("npm install")).toBeInTheDocument()
expect(screen.getByText("npm test")).toBeInTheDocument()
})
it("should not show pattern selector for empty commands", () => {
render(
<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,
}
render(
<ExtensionStateContext.Provider value={disabledState as any}>
<CommandExecution executionId="test-1" text="npm install\n\nCommand Output:\nOutput here" />
</ExtensionStateContext.Provider>,
)
// Output should be visible when shell integration is disabled
expect(screen.getByText(/Output here/)).toBeInTheDocument()
})
it("should handle undefined allowedCommands and deniedCommands", () => {
const stateWithUndefined = {
...mockExtensionState,
allowedCommands: undefined,
deniedCommands: undefined,
}
render(
<ExtensionStateContext.Provider value={stateWithUndefined as any}>
<CommandExecution executionId="test-1" text="npm install" />
</ExtensionStateContext.Provider>,
)
expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
})
it("should handle pattern change when moving from denied to allowed", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="rm file.txt" />
</ExtensionStateWrapper>,
)
const allowButton = screen.getByText("Allow rm")
fireEvent.click(allowButton)
// rm should be removed from denied and added to allowed
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm"])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith([])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm"] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] })
})
})

View file

@ -0,0 +1,252 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { CommandPatternSelector } from "../CommandPatternSelector"
import { CommandPattern } from "../../../utils/commandPatterns"
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey, components }: any) => {
if (i18nKey === "chat:commandExecution.commandManagementDescription") {
return (
<span>
Manage command permissions: Click to allow auto-execution, to deny execution. Patterns can be
toggled on/off or removed from lists. {components.settingsLink}
</span>
)
}
return <span>{i18nKey}</span>
},
}))
// Mock VSCodeLink
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeLink: ({ children, onClick }: any) => (
<a href="#" onClick={onClick}>
{children || "View all settings"}
</a>
),
}))
// Mock StandardTooltip
vi.mock("../../ui/standard-tooltip", () => ({
StandardTooltip: ({ children, content }: any) => (
<div title={typeof content === "string" ? content : "tooltip"}>
{children}
{/* Render the content to make it testable */}
<div style={{ display: "none" }}>{content}</div>
</div>
),
}))
// Mock window.postMessage
const mockPostMessage = vi.fn()
window.postMessage = mockPostMessage
describe("CommandPatternSelector", () => {
const mockPatterns: CommandPattern[] = [
{ pattern: "npm", description: "npm commands" },
{ pattern: "npm install", description: "npm install commands" },
{ pattern: "git", description: "git commands" },
]
const defaultProps = {
patterns: mockPatterns,
allowedCommands: ["npm"],
deniedCommands: ["git"],
onAllowPatternChange: vi.fn(),
onDenyPatternChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it("should render collapsed by default", () => {
render(<CommandPatternSelector {...defaultProps} />)
expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument()
expect(screen.queryByText("npm commands")).not.toBeInTheDocument()
})
it("should expand when clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Check for the patterns themselves
expect(screen.getByText("npm")).toBeInTheDocument()
expect(screen.getByText("npm install")).toBeInTheDocument()
expect(screen.getByText("git")).toBeInTheDocument()
// Check for the descriptions
expect(screen.getByText("- npm commands")).toBeInTheDocument()
expect(screen.getByText("- npm install commands")).toBeInTheDocument()
expect(screen.getByText("- git commands")).toBeInTheDocument()
})
it("should collapse when clicked again", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
const collapseButton = screen.getByRole("button", { name: "chat:commandExecution.collapseManagement" })
fireEvent.click(collapseButton)
expect(screen.queryByText("npm commands")).not.toBeInTheDocument()
})
it("should show correct status for patterns", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Check that npm has allowed styling (green)
const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0]
expect(npmAllowButton).toHaveClass("bg-green-500/20")
// Check that git has denied styling (red)
const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0]
expect(gitDenyButton).toHaveClass("bg-red-500/20")
})
it("should call onAllowPatternChange when allow button is clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Find all allow buttons with the "add to allowed" label
const allowButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToAllowed" })
// The second one should be for npm install (first is npm which is already allowed)
fireEvent.click(allowButtons[0])
expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm install")
})
it("should call onDenyPatternChange when deny button is clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Find all deny buttons with the "add to denied" label
const denyButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToDenied" })
// The second one should be for npm install (first is npm, third is git which is already denied)
fireEvent.click(denyButtons[1])
expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("npm install")
})
it("should toggle allowed pattern when clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Find the allow button for npm (which is already allowed)
const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0]
fireEvent.click(npmAllowButton)
expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm")
})
it("should toggle denied pattern when clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Find the deny button for git (which is already denied)
const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0]
fireEvent.click(gitDenyButton)
expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("git")
})
it("should have tooltip with settings link", () => {
const { container } = render(<CommandPatternSelector {...defaultProps} />)
// The info icon should have a tooltip
const tooltipWrapper = container.querySelector('[title="tooltip"]')
expect(tooltipWrapper).toBeTruthy()
// The tooltip content includes a settings link (mocked as VSCodeLink)
// It's rendered in a hidden div for testing purposes
const settingsLink = container.querySelector('a[href="#"]')
expect(settingsLink).toBeTruthy()
expect(settingsLink?.textContent).toBe("View all settings")
// Test that clicking the link posts the correct message
if (settingsLink) {
fireEvent.click(settingsLink)
expect(mockPostMessage).toHaveBeenCalledWith(
{
type: "action",
action: "settingsButtonClicked",
values: { section: "autoApprove" },
},
"*",
)
}
})
it("should render with empty patterns", () => {
render(<CommandPatternSelector {...defaultProps} patterns={[]} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// The expanded view should exist but be empty since there are no patterns
const expandedContent = screen
.getByRole("button", { name: "chat:commandExecution.collapseManagement" })
.parentElement?.querySelector(".px-3.pb-3")
expect(expandedContent).toBeInTheDocument()
expect(expandedContent?.children.length).toBe(0)
})
it("should render patterns without descriptions", () => {
const patternsWithoutDesc: CommandPattern[] = [{ pattern: "custom-command" }]
render(<CommandPatternSelector {...defaultProps} patterns={patternsWithoutDesc} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
expect(screen.getByText("custom-command")).toBeInTheDocument()
})
it("should always show info icon with tooltip", () => {
const { container } = render(<CommandPatternSelector {...defaultProps} />)
// Info icon should always be visible (not just when expanded)
// Look for the Info icon which is wrapped in StandardTooltip
const infoIcon = container.querySelector(".ml-1")
expect(infoIcon).toBeTruthy()
})
it("should apply correct classes for chevron rotation", () => {
const { container } = render(<CommandPatternSelector {...defaultProps} />)
// Initially collapsed - chevron should be rotated
let chevron = container.querySelector(".size-3.transition-transform")
expect(chevron).toHaveClass("-rotate-90")
// Click to expand
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// When expanded - chevron should not be rotated
chevron = container.querySelector(".size-3.transition-transform")
expect(chevron).toHaveClass("rotate-0")
})
})

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 list. <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,275 @@
import { describe, it, expect } from "vitest"
import { extractCommandPatterns, getPatternDescription, parseCommandAndOutput } from "../commandPatterns"
describe("extractCommandPatterns", () => {
it("should extract simple command", () => {
const patterns = extractCommandPatterns("ls")
expect(patterns).toEqual(["ls"])
})
it("should extract command with arguments", () => {
const patterns = extractCommandPatterns("npm install express")
expect(patterns).toEqual(["npm", "npm install", "npm install express"])
})
it("should handle piped commands", () => {
const patterns = extractCommandPatterns("ls -la | grep test")
expect(patterns).toContain("ls")
expect(patterns).toContain("grep")
expect(patterns).toContain("grep test")
})
it("should handle chained commands with &&", () => {
const patterns = extractCommandPatterns("npm install && npm run build")
expect(patterns).toContain("npm")
expect(patterns).toContain("npm install")
expect(patterns).toContain("npm run")
expect(patterns).toContain("npm run build")
})
it("should handle chained commands with ||", () => {
const patterns = extractCommandPatterns("npm test || npm run test:ci")
expect(patterns).toContain("npm")
expect(patterns).toContain("npm test")
expect(patterns).toContain("npm run")
expect(patterns).toContain("npm run test:ci")
})
it("should handle semicolon separated commands", () => {
const patterns = extractCommandPatterns("cd src; npm install")
expect(patterns).toContain("cd")
expect(patterns).toContain("cd src")
expect(patterns).toContain("npm")
expect(patterns).toContain("npm install")
})
it("should stop at flags", () => {
const patterns = extractCommandPatterns('git commit -m "test message"')
expect(patterns).toContain("git")
expect(patterns).toContain("git commit")
expect(patterns).not.toContain("git commit -m")
})
it("should stop at paths with slashes", () => {
const patterns = extractCommandPatterns("cd /usr/local/bin")
expect(patterns).toContain("cd")
expect(patterns).not.toContain("cd /usr/local/bin")
})
it("should handle empty or null input", () => {
expect(extractCommandPatterns("")).toEqual([])
expect(extractCommandPatterns(" ")).toEqual([])
expect(extractCommandPatterns(null as any)).toEqual([])
expect(extractCommandPatterns(undefined as any)).toEqual([])
})
it("should handle complex command with multiple operators", () => {
const patterns = extractCommandPatterns('npm install && npm test | grep success || echo "failed"')
expect(patterns).toContain("npm")
expect(patterns).toContain("npm install")
expect(patterns).toContain("npm test")
expect(patterns).toContain("grep")
expect(patterns).toContain("grep success")
expect(patterns).toContain("echo")
})
it("should handle malformed commands gracefully", () => {
const patterns = extractCommandPatterns("npm install && ")
expect(patterns).toContain("npm")
expect(patterns).toContain("npm install")
})
it("should extract main command even if parsing fails", () => {
// Create a command that might cause parsing issues
const patterns = extractCommandPatterns('echo "unclosed quote')
expect(patterns).toContain("echo")
})
it("should handle commands with special characters in arguments", () => {
const patterns = extractCommandPatterns("git add .")
expect(patterns).toContain("git")
expect(patterns).toContain("git add")
expect(patterns).not.toContain("git add .")
})
it("should return sorted patterns", () => {
const patterns = extractCommandPatterns("npm run build && git push")
expect(patterns).toEqual([...patterns].sort())
})
})
describe("getPatternDescription", () => {
it("should return descriptions for common commands", () => {
expect(getPatternDescription("cd")).toBe("directory navigation")
expect(getPatternDescription("npm")).toBe("npm commands")
expect(getPatternDescription("npm install")).toBe("npm install commands")
expect(getPatternDescription("git")).toBe("git commands")
expect(getPatternDescription("git push")).toBe("git push commands")
expect(getPatternDescription("python")).toBe("python scripts")
})
it("should return default description for unknown commands", () => {
expect(getPatternDescription("unknowncommand")).toBe("unknowncommand commands")
expect(getPatternDescription("custom-tool")).toBe("custom-tool commands")
})
it("should handle package managers", () => {
expect(getPatternDescription("yarn")).toBe("yarn commands")
expect(getPatternDescription("pnpm")).toBe("pnpm commands")
expect(getPatternDescription("bun")).toBe("bun scripts")
})
it("should handle build tools", () => {
expect(getPatternDescription("make")).toBe("build automation")
expect(getPatternDescription("cmake")).toBe("CMake build system")
expect(getPatternDescription("cargo")).toBe("Rust cargo commands")
expect(getPatternDescription("go build")).toBe("go build commands")
})
})
describe("parseCommandAndOutput", () => {
it("should parse command with $ prefix", () => {
const text = "$ npm install\nInstalling packages..."
const result = parseCommandAndOutput(text)
expect(result.command).toBe("npm install")
expect(result.output).toBe("Installing packages...")
})
it("should parse command with prefix", () => {
const text = " git status\nOn branch main"
const result = parseCommandAndOutput(text)
expect(result.command).toBe("git status")
expect(result.output).toBe("On branch main")
})
it("should parse command with > prefix", () => {
const text = "> echo hello\nhello"
const result = parseCommandAndOutput(text)
expect(result.command).toBe("echo hello")
expect(result.output).toBe("hello")
})
it("should return original text if no command prefix found", () => {
const text = "npm install"
const result = parseCommandAndOutput(text)
expect(result.command).toBe("npm install")
expect(result.output).toBe("")
})
it("should extract AI suggestions from output", () => {
const text = "$ npm install\nSuggested patterns: npm, npm install, npm run"
const result = parseCommandAndOutput(text)
expect(result.suggestions).toEqual(["npm", "npm install", "npm run"])
})
it("should extract suggestions with different formats", () => {
const text = "$ git push\nCommand patterns: git, git push"
const result = parseCommandAndOutput(text)
expect(result.suggestions).toEqual(["git", "git push"])
})
it('should extract suggestions from "you can allow" format', () => {
const text = "$ docker run\nYou can allow: docker, docker run"
const result = parseCommandAndOutput(text)
expect(result.suggestions).toEqual(["docker", "docker run"])
})
it("should extract suggestions from bullet points", () => {
const text = `$ npm test
Output here...
- npm
- npm test
- npm run`
const result = parseCommandAndOutput(text)
expect(result.suggestions).toContain("npm")
expect(result.suggestions).toContain("npm test")
expect(result.suggestions).toContain("npm run")
})
it("should extract suggestions from various bullet formats", () => {
const text = `$ command
npm
* git
- docker
python`
const result = parseCommandAndOutput(text)
expect(result.suggestions).toContain("npm")
expect(result.suggestions).toContain("git")
expect(result.suggestions).toContain("docker")
expect(result.suggestions).toContain("python")
})
it("should extract suggestions with backticks", () => {
const text = "$ npm install\n- `npm`\n- `npm install`"
const result = parseCommandAndOutput(text)
expect(result.suggestions).toContain("npm")
expect(result.suggestions).toContain("npm install")
})
it("should handle empty text", () => {
const result = parseCommandAndOutput("")
expect(result.command).toBe("")
expect(result.output).toBe("")
expect(result.suggestions).toEqual([])
})
it("should handle multiline commands", () => {
const text = `$ npm install \\
express \\
mongoose
Installing...`
const result = parseCommandAndOutput(text)
expect(result.command).toBe("npm install \\")
expect(result.output).toContain("express")
})
it("should include all suggestions from comma-separated list", () => {
const text = "$ test\nSuggested patterns: npm, npm install, npm run"
const result = parseCommandAndOutput(text)
expect(result.suggestions).toEqual(["npm", "npm install", "npm run"])
})
it("should handle case variations in suggestion patterns", () => {
const text = "$ test\nSuggested Patterns: npm, git\nCommand Patterns: docker"
const result = parseCommandAndOutput(text)
// Now it should accumulate all suggestions
expect(result.suggestions).toContain("npm")
expect(result.suggestions).toContain("git")
expect(result.suggestions).toContain("docker")
})
it("should handle text already split by Output:", () => {
const text = "npm install && cd backend\nOutput:\ngithub-pr-contributors-tracker@1.0.0 prepare"
const result = parseCommandAndOutput(text)
expect(result.command).toBe("npm install && cd backend")
expect(result.output).toBe("github-pr-contributors-tracker@1.0.0 prepare")
})
it("should preserve original command when Output: separator is present", () => {
const text = "npm install\nOutput:\n$ npm install\nInstalling packages..."
const result = parseCommandAndOutput(text)
expect(result.command).toBe("npm install")
expect(result.output).toBe("$ npm install\nInstalling packages...")
})
it("should handle Output: separator with no output", () => {
const text = "ls -la\nOutput:"
const result = parseCommandAndOutput(text)
expect(result.command).toBe("ls -la")
expect(result.output).toBe("")
})
it("should handle Output: separator with whitespace", () => {
const text = "git status\nOutput: \n On branch main "
const result = parseCommandAndOutput(text)
expect(result.command).toBe("git status")
expect(result.output).toBe("On branch main")
})
it("should only use first Output: occurrence as separator", () => {
const text = 'echo "test"\nOutput:\nFirst output\nOutput: Second output'
const result = parseCommandAndOutput(text)
expect(result.command).toBe('echo "test"')
expect(result.output).toBe("First output\nOutput: Second output")
})
})

View file

@ -0,0 +1,197 @@
import { parse } from "shell-quote"
export interface CommandPattern {
pattern: string
description?: string
}
export function extractCommandPatterns(command: string): string[] {
if (!command?.trim()) return []
const patterns = new Set<string>()
try {
const parsed = parse(command)
const commandSeparators = new Set(["|", "&&", "||", ";"])
let current: any[] = []
for (const token of parsed) {
if (typeof token === "object" && "op" in token && token.op && commandSeparators.has(token.op)) {
if (current.length) processCommand(current, patterns)
current = []
} else {
current.push(token)
}
}
if (current.length) processCommand(current, patterns)
} catch (_error) {
// If parsing fails, try to extract at least the main command
const mainCommand = command.trim().split(/\s+/)[0]
if (mainCommand) patterns.add(mainCommand)
}
return Array.from(patterns).sort()
}
function processCommand(cmd: any[], patterns: Set<string>) {
if (!cmd.length || typeof cmd[0] !== "string") return
const mainCmd = cmd[0]
patterns.add(mainCmd)
// Patterns that indicate we should stop looking for subcommands
const breakingExps = [/^-/, /[\\/.~ ]/]
// Build up patterns progressively
for (let i = 1; i < cmd.length; i++) {
const arg = cmd[i]
if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break
const pattern = cmd.slice(0, i + 1).join(" ")
patterns.add(pattern)
}
}
export function getPatternDescription(pattern: string): string {
// Generate human-readable descriptions for common patterns
const descriptions: Record<string, string> = {
cd: "directory navigation",
ls: "list directory contents",
pwd: "print working directory",
mkdir: "create directories",
rm: "remove files/directories",
cp: "copy files/directories",
mv: "move/rename files",
cat: "display file contents",
echo: "display text",
npm: "npm commands",
"npm install": "npm install commands",
"npm run": "all npm run scripts",
"npm test": "npm test commands",
"npm start": "npm start commands",
"npm build": "npm build commands",
yarn: "yarn commands",
"yarn install": "yarn install commands",
"yarn run": "all yarn run scripts",
pnpm: "pnpm commands",
"pnpm install": "pnpm install commands",
"pnpm run": "all pnpm run scripts",
git: "git commands",
"git add": "git add commands",
"git commit": "git commit commands",
"git push": "git push commands",
"git pull": "git pull commands",
"git clone": "git clone commands",
"git checkout": "git checkout commands",
"git branch": "git branch commands",
"git merge": "git merge commands",
"git status": "git status commands",
"git log": "git log commands",
python: "python scripts",
python3: "python3 scripts",
node: "node.js scripts",
deno: "deno scripts",
bun: "bun scripts",
docker: "docker commands",
"docker run": "docker run commands",
"docker build": "docker build commands",
"docker compose": "docker compose commands",
curl: "HTTP requests",
wget: "download files",
grep: "search text patterns",
find: "find files/directories",
sed: "stream editor",
awk: "text processing",
make: "build automation",
cmake: "CMake build system",
go: "go commands",
"go run": "go run commands",
"go build": "go build commands",
"go test": "go test commands",
cargo: "Rust cargo commands",
"cargo run": "cargo run commands",
"cargo build": "cargo build commands",
"cargo test": "cargo test commands",
dotnet: ".NET commands",
"dotnet run": "dotnet run commands",
"dotnet build": "dotnet build commands",
"dotnet test": "dotnet test commands",
}
return descriptions[pattern] || `${pattern} commands`
}
export function parseCommandAndOutput(text: string): {
command: string
output: string
suggestions: string[]
} {
// Default result
const result = {
command: text,
output: "",
suggestions: [] as string[],
}
// First check if the text already has been split by COMMAND_OUTPUT_STRING
// This happens when the command has already been executed and we have the output
const outputSeparator = "Output:"
const outputIndex = text.indexOf(outputSeparator)
if (outputIndex !== -1) {
// Text is already split into command and output
result.command = text.slice(0, outputIndex).trim()
result.output = text.slice(outputIndex + outputSeparator.length).trim()
} else {
// Try to extract command from the text
// Look for patterns like "$ command" or " command" at the start
const commandMatch = text.match(/^[$>]\s*(.+?)(?:\n|$)/m)
if (commandMatch) {
result.command = commandMatch[1].trim()
result.output = text.substring(commandMatch.index! + commandMatch[0].length).trim()
}
}
// Look for AI suggestions in the output
// These might be in a format like:
// "Suggested patterns: npm, npm install, npm run"
// or as a list
const suggestionPatterns = [
/Suggested patterns?:\s*(.+?)(?:\n|$)/i,
/Command patterns?:\s*(.+?)(?:\n|$)/i,
/You (?:can|may|might) (?:want to )?(?:allow|add):\s*(.+?)(?:\n|$)/i,
]
for (const pattern of suggestionPatterns) {
const match = result.output.match(pattern)
if (match) {
// Split by common delimiters and clean up
const suggestions = match[1]
.split(/[,;]/)
.map((s) => s.trim())
.filter((s) => s) // Allow multi-word patterns like "npm install"
if (suggestions.length > 0) {
// Add to existing suggestions instead of replacing
result.suggestions.push(...suggestions)
}
}
}
// Remove duplicates
result.suggestions = Array.from(new Set(result.suggestions))
// Also look for bullet points or numbered lists
// const listPattern = /^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/gm
const lines = result.output.split("\n")
for (const line of lines) {
const match = line.match(/^[\s\-*•·▪▫◦‣⁃]\s*`?([a-zA-Z0-9_-]+(?:\s+[a-zA-Z0-9_-]+)?)`?$/)
if (match && match[1] && !result.suggestions.includes(match[1])) {
result.suggestions.push(match[1])
}
}
return result
}