feat: restore command pattern extraction with editable UI

- Re-added command-parser.ts and its tests
- Modified CommandPatternSelector to show both full command and extracted patterns
- Each pattern (including full command) is now individually editable with click-to-edit functionality
- Updated tests to match new component interface
- Maintains the improved UI with hover effects and focus rings
This commit is contained in:
Daniel Riccio 2025-07-25 10:33:30 -05:00
parent 20f99a2523
commit 851617c0ff
No known key found for this signature in database
GPG key ID: FFD5FD825F8E8209
5 changed files with 505 additions and 176 deletions

View file

@ -13,6 +13,12 @@ import { cn } from "@src/lib/utils"
import { Button } from "@src/components/ui"
import CodeBlock from "../common/CodeBlock"
import { CommandPatternSelector } from "./CommandPatternSelector"
import { extractPatternsFromCommand } from "../../utils/command-parser"
interface CommandPattern {
pattern: string
description?: string
}
interface CommandExecutionProps {
executionId: string
@ -66,11 +72,19 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
// streaming output (this is the case for running commands).
const output = streamingOutput || parsedOutput
// Handle command changes
const handleAllowCommandChange = (cmd: string) => {
const isAllowed = allowedCommands.includes(cmd)
const newAllowed = isAllowed ? allowedCommands.filter((c) => c !== cmd) : [...allowedCommands, cmd]
const newDenied = deniedCommands.filter((c) => c !== cmd)
// Extract command patterns from the actual command that was executed
const commandPatterns = useMemo<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)
@ -78,10 +92,10 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
}
const handleDenyCommandChange = (cmd: string) => {
const isDenied = deniedCommands.includes(cmd)
const newDenied = isDenied ? deniedCommands.filter((c) => c !== cmd) : [...deniedCommands, cmd]
const newAllowed = allowedCommands.filter((c) => c !== cmd)
const handleDenyPatternChange = (pattern: string) => {
const isDenied = deniedCommands.includes(pattern)
const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern]
const newAllowed = allowedCommands.filter((p) => p !== pattern)
setAllowedCommands(newAllowed)
setDeniedCommands(newDenied)
@ -181,10 +195,11 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
{command && (
<CommandPatternSelector
command={command}
patterns={commandPatterns}
allowedCommands={allowedCommands}
deniedCommands={deniedCommands}
onAllowCommandChange={handleAllowCommandChange}
onDenyCommandChange={handleDenyCommandChange}
onAllowPatternChange={handleAllowPatternChange}
onDenyPatternChange={handleDenyPatternChange}
/>
)}
</div>

View file

@ -1,146 +1,179 @@
import React, { useState } from "react"
import React, { useState, useMemo } from "react"
import { Check, ChevronDown, Info, X } from "lucide-react"
import { cn } from "../../lib/utils"
import { useTranslation, Trans } from "react-i18next"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { StandardTooltip } from "../ui/standard-tooltip"
interface CommandPattern {
pattern: string
description?: string
}
interface CommandPatternSelectorProps {
command: string
patterns: CommandPattern[]
allowedCommands: string[]
deniedCommands: string[]
onAllowCommandChange: (command: string) => void
onDenyCommandChange: (command: string) => void
onAllowPatternChange: (pattern: string) => void
onDenyPatternChange: (pattern: string) => void
}
export const CommandPatternSelector: React.FC<CommandPatternSelectorProps> = ({
command,
patterns,
allowedCommands,
deniedCommands,
onAllowCommandChange,
onDenyCommandChange,
onAllowPatternChange,
onDenyPatternChange,
}) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const [editedCommand, setEditedCommand] = useState(command)
const [isEditing, setIsEditing] = useState(false)
const [editingStates, setEditingStates] = useState<Record<string, { isEditing: boolean; value: string }>>({})
const getCommandStatus = (cmd: string): "allowed" | "denied" | "none" => {
if (allowedCommands.includes(cmd)) return "allowed"
if (deniedCommands.includes(cmd)) return "denied"
// Create a combined list with full command first, then patterns
const allPatterns = useMemo(() => {
const fullCommandPattern = { pattern: command, description: "Full command" }
return [fullCommandPattern, ...patterns]
}, [command, patterns])
const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => {
if (allowedCommands.includes(pattern)) return "allowed"
if (deniedCommands.includes(pattern)) return "denied"
return "none"
}
const currentStatus = getCommandStatus(editedCommand)
const getEditState = (pattern: string) => {
return editingStates[pattern] || { isEditing: false, value: pattern }
}
const setEditState = (pattern: string, isEditing: boolean, value?: string) => {
setEditingStates((prev) => ({
...prev,
[pattern]: { isEditing, value: value ?? pattern },
}))
}
return (
<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="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.commandPermissions")}</span>
<StandardTooltip
content={
<div className="space-y-2 max-w-xs">
<p>{t("chat:commandExecution.permissionsTooltip")}</p>
<p>
<Trans
i18nKey="chat:commandExecution.learnMore"
components={{
link: (
<VSCodeLink
href="https://docs.roo-code.com/features/command-permissions"
className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground"
/>
),
}}
className="inline"
/>
),
}}
/>
}>
<Info className="size-3 ml-1" />
</StandardTooltip>
</p>
</div>
}>
<Info className="size-3.5 text-vscode-descriptionForeground" />
</StandardTooltip>
</div>
<div className="flex items-center gap-2 text-xs text-vscode-descriptionForeground">
<span>
{allowedCommands.length} {t("chat:commandExecution.allowed")}
</span>
<span>•</span>
<span>
{deniedCommands.length} {t("chat:commandExecution.denied")}
</span>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-2">
<div className="ml-5 flex items-center gap-2">
<div className="flex-1">
{isEditing ? (
<input
type="text"
value={editedCommand}
onChange={(e) => setEditedCommand(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setIsEditing(false)
}
if (e.key === "Escape") {
setEditedCommand(command)
setIsEditing(false)
}
}}
className="font-mono text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1.5 w-full focus:outline-0"
placeholder={command}
autoFocus
/>
) : (
<div
onClick={() => setIsEditing(true)}
className="font-mono text-xs text-vscode-foreground cursor-pointer hover:bg-vscode-list-hoverBackground px-2 py-1.5 rounded transition-colors border border-transparent"
title="Click to edit command">
{editedCommand}
<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>
<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": currentStatus === "allowed",
"text-vscode-descriptionForeground hover:text-green-500 hover:bg-green-500/10":
currentStatus !== "allowed",
})}
onClick={() => onAllowCommandChange(editedCommand)}
aria-label={t(
currentStatus === "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": currentStatus === "denied",
"text-vscode-descriptionForeground hover:text-red-500 hover:bg-red-500/10":
currentStatus !== "denied",
})}
onClick={() => onDenyCommandChange(editedCommand)}
aria-label={t(
currentStatus === "denied"
? "chat:commandExecution.removeFromDenied"
: "chat:commandExecution.addToDenied",
)}>
<X className="size-3.5" />
</button>
</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

@ -27,13 +27,17 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => <TooltipPro
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"],
onAllowCommandChange: vi.fn(),
onDenyCommandChange: vi.fn(),
onAllowPatternChange: vi.fn(),
onDenyPatternChange: vi.fn(),
}
it("should render with command input", () => {
it("should render with command permissions header", () => {
const { container } = render(
<TestWrapper>
<CommandPatternSelector {...defaultProps} />
@ -43,16 +47,11 @@ describe("CommandPatternSelector", () => {
// The component should render without errors
expect(container).toBeTruthy()
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
fireEvent.click(expandButton)
// Check that the input is rendered with the command
const input = screen.getByDisplayValue("npm install express")
expect(input).toBeInTheDocument()
// Check for the command permissions text
expect(screen.getByText("chat:commandExecution.commandPermissions")).toBeInTheDocument()
})
it("should allow editing the command", () => {
it("should show full command as first pattern when expanded", () => {
render(
<TestWrapper>
<CommandPatternSelector {...defaultProps} />
@ -60,42 +59,79 @@ describe("CommandPatternSelector", () => {
)
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
const expandButton = screen.getByRole("button")
fireEvent.click(expandButton)
// Get the input and change its value
const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
fireEvent.change(input, { target: { value: "npm install react" } })
// Check that the full command is shown with description
expect(screen.getByText("npm install express")).toBeInTheDocument()
expect(screen.getByText("- Full command")).toBeInTheDocument()
})
// Check that the input value has changed
it("should show extracted patterns when expanded", () => {
render(
<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 commands in allowed list", () => {
const props = {
...defaultProps,
command: "npm install",
}
it("should show allowed status for patterns in allowed list", () => {
render(
<TestWrapper>
<CommandPatternSelector {...props} />
<CommandPatternSelector {...defaultProps} />
</TestWrapper>,
)
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
const expandButton = screen.getByRole("button")
fireEvent.click(expandButton)
// Find the npm install pattern row
const npmInstallPattern = screen.getByText("npm install").closest(".ml-5")
// The allow button should have the active styling (we can check by aria-label)
const allowButton = screen.getByRole("button", { name: /chat:commandExecution.removeFromAllowed/i })
const allowButton = npmInstallPattern?.querySelector('button[aria-label*="removeFromAllowed"]')
expect(allowButton).toBeInTheDocument()
})
it("should show denied status for commands in denied list", () => {
it("should show denied status for patterns in denied list", () => {
const props = {
...defaultProps,
command: "git push",
patterns: [{ pattern: "git push", description: "Push to git" }],
}
render(
@ -105,19 +141,22 @@ describe("CommandPatternSelector", () => {
)
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
const expandButton = screen.getByRole("button")
fireEvent.click(expandButton)
// Find the git push pattern row
const gitPushPattern = screen.getByText("git push").closest(".ml-5")
// The deny button should have the active styling (we can check by aria-label)
const denyButton = screen.getByRole("button", { name: /chat:commandExecution.removeFromDenied/i })
const denyButton = gitPushPattern?.querySelector('button[aria-label*="removeFromDenied"]')
expect(denyButton).toBeInTheDocument()
})
it("should call onAllowCommandChange when allow button is clicked", () => {
const mockOnAllowCommandChange = vi.fn()
it("should call onAllowPatternChange when allow button is clicked", () => {
const mockOnAllowPatternChange = vi.fn()
const props = {
...defaultProps,
onAllowCommandChange: mockOnAllowCommandChange,
onAllowPatternChange: mockOnAllowPatternChange,
}
render(
@ -127,22 +166,23 @@ describe("CommandPatternSelector", () => {
)
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
const expandButton = screen.getByRole("button")
fireEvent.click(expandButton)
// Click the allow button
const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i })
fireEvent.click(allowButton)
// Find the full command pattern row and click allow
const fullCommandPattern = screen.getByText("npm install express").closest(".ml-5")
const allowButton = fullCommandPattern?.querySelector('button[aria-label*="addToAllowed"]')
fireEvent.click(allowButton!)
// Check that the callback was called with the command
expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install express")
// Check that the callback was called with the pattern
expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install express")
})
it("should call onDenyCommandChange when deny button is clicked", () => {
const mockOnDenyCommandChange = vi.fn()
it("should call onDenyPatternChange when deny button is clicked", () => {
const mockOnDenyPatternChange = vi.fn()
const props = {
...defaultProps,
onDenyCommandChange: mockOnDenyCommandChange,
onDenyPatternChange: mockOnDenyPatternChange,
}
render(
@ -152,22 +192,23 @@ describe("CommandPatternSelector", () => {
)
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
const expandButton = screen.getByRole("button")
fireEvent.click(expandButton)
// Click the deny button
const denyButton = screen.getByRole("button", { name: /chat:commandExecution.addToDenied/i })
fireEvent.click(denyButton)
// Find the full command pattern row and click deny
const fullCommandPattern = screen.getByText("npm install express").closest(".ml-5")
const denyButton = fullCommandPattern?.querySelector('button[aria-label*="addToDenied"]')
fireEvent.click(denyButton!)
// Check that the callback was called with the command
expect(mockOnDenyCommandChange).toHaveBeenCalledWith("npm install express")
// Check that the callback was called with the pattern
expect(mockOnDenyPatternChange).toHaveBeenCalledWith("npm install express")
})
it("should use edited command value when buttons are clicked", () => {
const mockOnAllowCommandChange = vi.fn()
it("should use edited pattern value when buttons are clicked", () => {
const mockOnAllowPatternChange = vi.fn()
const props = {
...defaultProps,
onAllowCommandChange: mockOnAllowCommandChange,
onAllowPatternChange: mockOnAllowPatternChange,
}
render(
@ -177,18 +218,53 @@ describe("CommandPatternSelector", () => {
)
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
const expandButton = screen.getByRole("button")
fireEvent.click(expandButton)
// Click on the full command pattern to edit
const fullCommandDiv = screen.getByText("npm install express").closest("div")
fireEvent.click(fullCommandDiv!)
// Edit the command
const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
fireEvent.change(input, { target: { value: "npm install react" } })
// Click the allow button
const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i })
fireEvent.click(allowButton)
// Press Enter to confirm edit
fireEvent.keyDown(input, { key: "Enter" })
// Check that the callback was called with the edited command
expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install react")
// Click the allow button
const fullCommandPattern = screen.getByText("npm install react").closest(".ml-5")
const allowButton = fullCommandPattern?.querySelector('button[aria-label*="addToAllowed"]')
fireEvent.click(allowButton!)
// Check that the callback was called with the edited pattern
expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install react")
})
it("should cancel edit on Escape key", () => {
render(
<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

@ -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 isValidToken(token: string): boolean {
return !!token && !token.startsWith("-") && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/)
}
function extractFromTokens(tokens: string[], patterns: Set<string>): void {
if (tokens.length === 0) return
const mainCmd = tokens[0]
// Skip numeric commands like "0" from "0 total"
if (/^\d+$/.test(mainCmd)) return
// Build patterns progressively up to 3 levels
let pattern = mainCmd
patterns.add(pattern)
for (let i = 1; i < Math.min(tokens.length, 3); i++) {
if (isValidToken(tokens[i])) {
pattern += ` ${tokens[i]}`
patterns.add(pattern)
} else {
break // Stop at first invalid token
}
}
}