feat: add 'Add and Run' button for allowed commands (#5290)

- Added new 'Add and Run' button in ChatView that appears when hovering over code blocks containing allowed commands
- Button extracts commands from code blocks and adds them to allowed commands list
- Automatically switches to Settings view after adding commands
- Added comprehensive test coverage for the new functionality
- Updated all locale files with new translation keys
- Added utility function to extract command patterns from code blocks

This implementation allows users to quickly add commands from AI responses to their allowed commands list with a single click.
This commit is contained in:
hannesrudolph 2025-07-01 16:11:37 -06:00
parent 7645aad435
commit af8eae5ea1
30 changed files with 1768 additions and 357 deletions

View file

@ -274,6 +274,41 @@ export async function presentAssistantMessage(cline: Task) {
isProtected || false,
)
// Handle "Add & Run" button for command approval
if (response === "addAndRunButtonClicked" && type === "command") {
// The text field contains the extracted command pattern when "Add & Run" is clicked
if (text) {
// Add the command pattern to allowed commands
const provider = cline.providerRef.deref()
if (provider) {
// Get current allowed commands
const currentState = await provider.getState()
const currentAllowedCommands = currentState.allowedCommands || []
// Add the new pattern if it's not already in the list
if (!currentAllowedCommands.includes(text)) {
const updatedCommands = [...currentAllowedCommands, text]
// Update global state using contextProxy
await provider.contextProxy.setValue("allowedCommands", updatedCommands)
// Also update workspace settings
const vscode = await import("vscode")
const { Package } = await import("../../shared/package")
await vscode.workspace
.getConfiguration(Package.name)
.update("allowedCommands", updatedCommands, vscode.ConfigurationTarget.Global)
// Post state update to webview
await provider.postStateToWebview()
}
}
}
// Return true to indicate approval and continue with command execution
return true
}
if (response !== "yesButtonClicked") {
// Handle both messageResponse and noButtonClicked with text.
if (text) {

View file

@ -1,14 +1,27 @@
import type { Mock } from "vitest"
// Mock dependencies - must come before imports
vi.mock("../../../api/providers/fetchers/modelCache")
import { describe, it, expect, vi, beforeEach } from "vitest"
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
import { getModels } from "../../../api/providers/fetchers/modelCache"
import type { ModelRecord } from "../../../shared/api"
import { ClineProvider } from "../ClineProvider"
import * as vscode from "vscode"
import { Package } from "../../../shared/package"
const mockGetModels = getModels as Mock<typeof getModels>
// Mock vscode module
vi.mock("vscode", () => ({
workspace: {
getConfiguration: vi.fn().mockReturnValue({
update: vi.fn().mockResolvedValue(undefined),
}),
},
ConfigurationTarget: {
Global: 1,
},
}))
// Mock Package
vi.mock("../../../shared/package", () => ({
Package: {
name: "roo-cline",
},
}))
// Mock ClineProvider
const mockClineProvider = {
@ -35,16 +48,6 @@ const mockClineProvider = {
import { t } from "../../../i18n"
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
},
}))
vi.mock("../../../i18n", () => ({
t: vi.fn((key: string, args?: Record<string, any>) => {
// For the delete confirmation with rules, we need to return the interpolated string
@ -77,7 +80,6 @@ vi.mock("fs/promises", () => {
}
})
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
@ -90,282 +92,94 @@ vi.mock("../../../utils/fs")
vi.mock("../../../utils/path")
vi.mock("../../../utils/globalContext")
describe("webviewMessageHandler - requestRouterModels", () => {
describe("webviewMessageHandler", () => {
let mockProvider: any
let mockContextProxy: any
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
mockClineProvider.getState = vi.fn().mockResolvedValue({
apiConfiguration: {
openRouterApiKey: "openrouter-key",
requestyApiKey: "requesty-key",
glamaApiKey: "glama-key",
unboundApiKey: "unbound-key",
litellmApiKey: "litellm-key",
litellmBaseUrl: "http://localhost:4000",
},
})
})
it("successfully fetches models from all providers", async () => {
const mockModels: ModelRecord = {
"model-1": {
maxTokens: 4096,
contextWindow: 8192,
supportsPromptCache: false,
description: "Test model 1",
},
"model-2": {
maxTokens: 8192,
contextWindow: 16384,
supportsPromptCache: false,
description: "Test model 2",
},
// Create mock context proxy
mockContextProxy = {
getValue: vi.fn(),
setValue: vi.fn().mockResolvedValue(undefined),
}
mockGetModels.mockResolvedValue(mockModels)
await webviewMessageHandler(mockClineProvider, {
type: "requestRouterModels",
})
// Verify getModels was called for each provider
expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" })
expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" })
expect(mockGetModels).toHaveBeenCalledWith({ provider: "glama" })
expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" })
expect(mockGetModels).toHaveBeenCalledWith({
provider: "litellm",
apiKey: "litellm-key",
baseUrl: "http://localhost:4000",
})
// Verify response was sent
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "routerModels",
routerModels: {
openrouter: mockModels,
requesty: mockModels,
glama: mockModels,
unbound: mockModels,
litellm: mockModels,
ollama: {},
lmstudio: {},
},
})
})
it("handles LiteLLM models with values from message when config is missing", async () => {
mockClineProvider.getState = vi.fn().mockResolvedValue({
apiConfiguration: {
openRouterApiKey: "openrouter-key",
requestyApiKey: "requesty-key",
glamaApiKey: "glama-key",
unboundApiKey: "unbound-key",
// Missing litellm config
},
})
const mockModels: ModelRecord = {
"model-1": {
maxTokens: 4096,
contextWindow: 8192,
supportsPromptCache: false,
description: "Test model 1",
},
// Create mock provider
mockProvider = {
contextProxy: mockContextProxy,
postStateToWebview: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
}
mockGetModels.mockResolvedValue(mockModels)
await webviewMessageHandler(mockClineProvider, {
type: "requestRouterModels",
values: {
litellmApiKey: "message-litellm-key",
litellmBaseUrl: "http://message-url:4000",
},
})
// Verify LiteLLM was called with values from message
expect(mockGetModels).toHaveBeenCalledWith({
provider: "litellm",
apiKey: "message-litellm-key",
baseUrl: "http://message-url:4000",
})
})
it("skips LiteLLM when both config and message values are missing", async () => {
mockClineProvider.getState = vi.fn().mockResolvedValue({
apiConfiguration: {
openRouterApiKey: "openrouter-key",
requestyApiKey: "requesty-key",
glamaApiKey: "glama-key",
unboundApiKey: "unbound-key",
// Missing litellm config
},
describe("allowedCommands", () => {
it("should update global state, workspace settings, and call postStateToWebview", async () => {
const testCommands = ["npm test", "npm run build", "git status"]
await webviewMessageHandler(mockProvider, {
type: "allowedCommands",
commands: testCommands,
})
// Verify global state was updated
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", testCommands)
// Verify workspace settings were updated
const mockConfig = vscode.workspace.getConfiguration()
expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name)
expect(mockConfig.update).toHaveBeenCalledWith(
"allowedCommands",
testCommands,
vscode.ConfigurationTarget.Global,
)
// Verify postStateToWebview was called
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
const mockModels: ModelRecord = {
"model-1": {
maxTokens: 4096,
contextWindow: 8192,
supportsPromptCache: false,
description: "Test model 1",
},
}
it("should filter out invalid commands", async () => {
const testCommands = ["npm test", "", " ", null, undefined, "git status", 123]
mockGetModels.mockResolvedValue(mockModels)
await webviewMessageHandler(mockProvider, {
type: "allowedCommands",
commands: testCommands as any,
})
await webviewMessageHandler(mockClineProvider, {
type: "requestRouterModels",
// No values provided
// Should only include valid string commands
const expectedCommands = ["npm test", "git status"]
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", expectedCommands)
})
// Verify LiteLLM was NOT called
expect(mockGetModels).not.toHaveBeenCalledWith(
expect.objectContaining({
provider: "litellm",
}),
)
it("should handle empty commands array", async () => {
await webviewMessageHandler(mockProvider, {
type: "allowedCommands",
commands: [],
})
// Verify response includes empty object for LiteLLM
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "routerModels",
routerModels: {
openrouter: mockModels,
requesty: mockModels,
glama: mockModels,
unbound: mockModels,
litellm: {},
ollama: {},
lmstudio: {},
},
})
})
it("handles individual provider failures gracefully", async () => {
const mockModels: ModelRecord = {
"model-1": {
maxTokens: 4096,
contextWindow: 8192,
supportsPromptCache: false,
description: "Test model 1",
},
}
// Mock some providers to succeed and others to fail
mockGetModels
.mockResolvedValueOnce(mockModels) // openrouter
.mockRejectedValueOnce(new Error("Requesty API error")) // requesty
.mockResolvedValueOnce(mockModels) // glama
.mockRejectedValueOnce(new Error("Unbound API error")) // unbound
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
await webviewMessageHandler(mockClineProvider, {
type: "requestRouterModels",
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", [])
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
// Verify successful providers are included
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "routerModels",
routerModels: {
openrouter: mockModels,
requesty: {},
glama: mockModels,
unbound: {},
litellm: {},
ollama: {},
lmstudio: {},
},
it("should handle undefined commands", async () => {
await webviewMessageHandler(mockProvider, {
type: "allowedCommands",
commands: undefined,
})
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", [])
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
// Verify error messages were sent for failed providers
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
error: "Requesty API error",
values: { provider: "requesty" },
})
it("should handle non-array commands", async () => {
await webviewMessageHandler(mockProvider, {
type: "allowedCommands",
commands: "not an array" as any,
})
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
error: "Unbound API error",
values: { provider: "unbound" },
})
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
error: "LiteLLM connection failed",
values: { provider: "litellm" },
})
})
it("handles Error objects and string errors correctly", async () => {
// Mock providers to fail with different error types
mockGetModels
.mockRejectedValueOnce(new Error("Structured error message")) // openrouter
.mockRejectedValueOnce(new Error("Requesty API error")) // requesty
.mockRejectedValueOnce(new Error("Glama API error")) // glama
.mockRejectedValueOnce(new Error("Unbound API error")) // unbound
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
await webviewMessageHandler(mockClineProvider, {
type: "requestRouterModels",
})
// Verify error handling for different error types
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
error: "Structured error message",
values: { provider: "openrouter" },
})
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
error: "Requesty API error",
values: { provider: "requesty" },
})
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
error: "Glama API error",
values: { provider: "glama" },
})
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
error: "Unbound API error",
values: { provider: "unbound" },
})
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
error: "LiteLLM connection failed",
values: { provider: "litellm" },
})
})
it("prefers config values over message values for LiteLLM", async () => {
const mockModels: ModelRecord = {}
mockGetModels.mockResolvedValue(mockModels)
await webviewMessageHandler(mockClineProvider, {
type: "requestRouterModels",
values: {
litellmApiKey: "message-key",
litellmBaseUrl: "http://message-url",
},
})
// Verify config values are used over message values
expect(mockGetModels).toHaveBeenCalledWith({
provider: "litellm",
apiKey: "litellm-key", // From config
baseUrl: "http://localhost:4000", // From config
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", [])
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
})
})

View file

@ -582,6 +582,9 @@ export const webviewMessageHandler = async (
.getConfiguration(Package.name)
.update("allowedCommands", validCommands, vscode.ConfigurationTarget.Global)
// Post state update to webview to reflect changes in UI
await provider.postStateToWebview()
break
}
case "openCustomModesSettings": {

View file

@ -118,7 +118,13 @@ export interface ExtensionMessage {
| "didBecomeVisible"
| "focusInput"
| "switchTab"
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
invoke?:
| "newChat"
| "sendMessage"
| "primaryButtonClick"
| "secondaryButtonClick"
| "tertiaryButtonClick"
| "setChatBoxMessage"
state?: ExtensionState
images?: string[]
filePaths?: string[]

View file

@ -12,7 +12,12 @@ import { marketplaceItemSchema } from "@roo-code/types"
import { Mode } from "./modes"
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
export type ClineAskResponse =
| "yesButtonClicked"
| "noButtonClicked"
| "addAndRunButtonClicked"
| "messageResponse"
| "objectResponse"
export type PromptMode = Mode | "enhance"
@ -232,6 +237,7 @@ export interface WebviewMessage {
visibility?: ShareVisibility // For share visibility
hasContent?: boolean // For checkRulesDirectoryResult
checkOnly?: boolean // For deleteCustomMode check
commandPattern?: string // For "Add & Run" button - the extracted command pattern to whitelist
codeIndexSettings?: {
// Global state settings
codebaseIndexEnabled: boolean

View file

@ -25,6 +25,7 @@ import { ProfileValidator } from "@roo/ProfileValidator"
import { vscode } from "@src/utils/vscode"
import { validateCommand } from "@src/utils/command-validation"
import { extractCommandPattern, getPatternDescription } from "@src/utils/extract-command-pattern"
import { buildDocLink } from "@src/utils/docLinks"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
@ -149,6 +150,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
const [tertiaryButtonText, setTertiaryButtonText] = useState<string | undefined>(undefined)
const [didClickCancel, setDidClickCancel] = useState(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
@ -247,6 +249,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setEnableButtons(true)
setPrimaryButtonText(t("chat:retry.title"))
setSecondaryButtonText(t("chat:startNewTask.title"))
setTertiaryButtonText(undefined)
break
case "mistake_limit_reached":
playSound("progress_loop")
@ -255,6 +258,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setEnableButtons(true)
setPrimaryButtonText(t("chat:proceedAnyways.title"))
setSecondaryButtonText(t("chat:startNewTask.title"))
setTertiaryButtonText(undefined)
break
case "followup":
if (!isPartial) {
@ -285,15 +289,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "insertContent":
setPrimaryButtonText(t("chat:save.title"))
setSecondaryButtonText(t("chat:reject.title"))
setTertiaryButtonText(undefined)
break
case "finishTask":
setPrimaryButtonText(t("chat:completeSubtaskAndReturn"))
setSecondaryButtonText(undefined)
setTertiaryButtonText(undefined)
break
case "readFile":
if (tool.batchFiles && Array.isArray(tool.batchFiles)) {
setPrimaryButtonText(t("chat:read-batch.approve.title"))
setSecondaryButtonText(t("chat:read-batch.deny.title"))
setTertiaryButtonText(undefined)
} else {
setPrimaryButtonText(t("chat:approve.title"))
setSecondaryButtonText(t("chat:reject.title"))
@ -323,7 +330,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setClineAsk("command")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:runCommand.title"))
setSecondaryButtonText(t("chat:reject.title"))
setSecondaryButtonText(t("chat:addAndRunCommand.title"))
setTertiaryButtonText(t("chat:reject.title"))
break
case "command_output":
setSendingDisabled(false)
@ -331,6 +339,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setEnableButtons(true)
setPrimaryButtonText(t("chat:proceedWhileRunning.title"))
setSecondaryButtonText(t("chat:killCommand.title"))
setTertiaryButtonText(undefined)
break
case "use_mcp_server":
if (!isAutoApproved(lastMessage) && !isPartial) {
@ -341,6 +350,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:approve.title"))
setSecondaryButtonText(t("chat:reject.title"))
setTertiaryButtonText(undefined)
setTertiaryButtonText(undefined)
setTertiaryButtonText(undefined)
setTertiaryButtonText(undefined)
break
case "completion_result":
// extension waiting for feedback. but we can just present a new task button
@ -359,6 +372,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setEnableButtons(true)
setPrimaryButtonText(t("chat:resumeTask.title"))
setSecondaryButtonText(t("chat:terminate.title"))
setTertiaryButtonText(undefined)
setDidClickCancel(false) // special case where we reset the cancel button state
break
case "resume_completed_task":
@ -367,6 +381,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setEnableButtons(true)
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
setTertiaryButtonText(undefined)
setTertiaryButtonText(undefined)
setDidClickCancel(false)
break
}
@ -409,6 +425,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setEnableButtons(false)
setPrimaryButtonText(undefined)
setSecondaryButtonText(undefined)
setTertiaryButtonText(undefined)
setTertiaryButtonText(undefined)
}
}, [messages.length])
@ -597,6 +615,38 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[clineAsk, startNewTask],
)
const handleTertiaryButtonClick = useCallback(
(_text?: string, images?: string[]) => {
switch (clineAsk) {
case "command":
// For the "Add & Run" button on command approval
// Extract the command pattern for whitelisting
const commandMessage = findLast(
messagesRef.current,
(msg) => msg.type === "ask" && msg.ask === "command",
)
const commandText = commandMessage?.text || ""
const pattern = extractCommandPattern(commandText)
// Send the pattern in the text field as expected by the backend
vscode.postMessage({
type: "askResponse",
askResponse: "addAndRunButtonClicked",
text: pattern, // Send pattern in text field
images: images || [],
})
// Clear input state after sending
setInputValue("")
setSelectedImages([])
break
}
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
},
[clineAsk], // messagesRef is stable
)
const handleSecondaryButtonClick = useCallback(
(text?: string, images?: string[]) => {
const trimmedInput = text?.trim()
@ -613,7 +663,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "resume_task":
startNewTask()
break
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
@ -633,6 +682,29 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setInputValue("")
setSelectedImages([])
break
case "command":
// When there are three buttons, secondary button is "Add & Run"
// This case should not happen as we're using tertiary button for reject
// But keeping for backward compatibility
if (tertiaryButtonText) {
// Three button mode - this shouldn't be called
console.warn("Secondary button clicked in three-button command mode")
} else {
// Two button mode - reject
if (trimmedInput || (images && images.length > 0)) {
vscode.postMessage({
type: "askResponse",
askResponse: "noButtonClicked",
text: trimmedInput,
images: images,
})
} else {
vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
}
setInputValue("")
setSelectedImages([])
}
break
case "command_output":
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
break
@ -641,7 +713,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setClineAsk(undefined)
setEnableButtons(false)
},
[clineAsk, startNewTask, isStreaming],
[clineAsk, startNewTask, isStreaming, tertiaryButtonText],
)
const handleTaskCloseButtonClick = useCallback(() => startNewTask(), [startNewTask])
@ -695,6 +767,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "secondaryButtonClick":
handleSecondaryButtonClick(message.text ?? "", message.images ?? [])
break
case "tertiaryButtonClick":
handleTertiaryButtonClick(message.text ?? "", message.images ?? [])
break
}
break
case "condenseTaskContextResponse":
@ -721,6 +796,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSetChatBoxMessage,
handlePrimaryButtonClick,
handleSecondaryButtonClick,
handleTertiaryButtonClick,
],
)
@ -1592,12 +1668,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
</div>
{areButtonsVisible && (
<div
className={`flex h-9 items-center mb-1 px-[15px] ${
showScrollToBottom
? "opacity-100"
: enableButtons || (isStreaming && !didClickCancel)
className={`${
primaryButtonText || secondaryButtonText || tertiaryButtonText || isStreaming
? "px-[15px] pt-[10px]"
: "p-0"
} ${
primaryButtonText || secondaryButtonText || tertiaryButtonText || isStreaming
? enableButtons || (isStreaming && !didClickCancel)
? "opacity-100"
: "opacity-50"
: ""
}`}>
{showScrollToBottom ? (
<StandardTooltip content={t("chat:scrollToBottom")}>
@ -1613,59 +1693,111 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
</StandardTooltip>
) : (
<>
{primaryButtonText && !isStreaming && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText ===
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
}>
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
{(secondaryButtonText || isStreaming) && (
<StandardTooltip
content={
isStreaming
? t("chat:cancel.tooltip")
: secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: undefined
}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
</VSCodeButton>
</StandardTooltip>
{/* Three button layout for command approval */}
{tertiaryButtonText && clineAsk === "command" && !isStreaming ? (
<div className="flex flex-col gap-[6px]">
{/* Top row: Run and Add & Run */}
<div className="flex gap-[6px]">
<StandardTooltip content={t("chat:runCommand.tooltip")}>
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
className="flex-1"
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>
<StandardTooltip
content={(() => {
const commandMessage = findLast(
messagesRef.current,
(msg) => msg.type === "ask" && msg.ask === "command",
)
const commandText = commandMessage?.text || ""
const pattern = extractCommandPattern(commandText)
const description = getPatternDescription(pattern)
return pattern
? `${t("chat:addAndRunCommand.tooltip")} Will whitelist: "${pattern}" (${description})`
: t("chat:addAndRunCommand.tooltip")
})()}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons}
className="flex-1"
onClick={() => handleTertiaryButtonClick(inputValue, selectedImages)}>
{secondaryButtonText}
</VSCodeButton>
</StandardTooltip>
</div>
{/* Bottom row: Reject */}
<StandardTooltip content={t("chat:reject.tooltip")}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons}
className="w-full"
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
{tertiaryButtonText}
</VSCodeButton>
</StandardTooltip>
</div>
) : (
/* Standard two button layout */
<div className="flex">
{primaryButtonText && !isStreaming && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText ===
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
}>
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
{(secondaryButtonText || isStreaming) && (
<StandardTooltip
content={
isStreaming
? t("chat:cancel.tooltip")
: secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: undefined
}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
</div>
)}
</>
)}

View file

@ -0,0 +1,424 @@
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { vi } from "vitest"
import ChatView from "../ChatView"
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
import { TranslationProvider } from "@src/i18n/TranslationContext"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { TooltipProvider } from "@/components/ui/tooltip"
import { vscode } from "@src/utils/vscode"
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock extract-command-pattern
vi.mock("@src/utils/extract-command-pattern", () => ({
extractCommandPattern: vi.fn((command: string) => {
// Simple mock implementation
if (command === "npm test") return "npm test"
if (command === "cd /path/to/project && npm run build:prod --verbose") return "cd * && npm run *"
return command
}),
getPatternDescription: vi.fn(() => "matches similar commands"),
}))
// Mock use-sound
vi.mock("use-sound", () => ({
default: () => [vi.fn()],
}))
// Mock react-use
vi.mock("react-use", () => ({
useEvent: vi.fn(),
useMount: vi.fn(),
useDeepCompareEffect: (fn: () => void, deps: any[]) => {
// Use regular useEffect for testing
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require("react")
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(fn, deps)
},
useWindowSize: () => ({ width: 1024, height: 768 }),
}))
// Mock debounce
vi.mock("debounce", () => ({
default: (fn: any) => fn,
}))
// Mock react-virtuoso
vi.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent }: any) => (
<div data-testid="virtuoso">
{data?.map((item: any, index: number) => <div key={index}>{itemContent(index, item)}</div>)}
</div>
),
}))
// Mock all problematic dependencies
vi.mock("rehype-highlight", () => ({
default: () => () => {},
}))
vi.mock("hast-util-to-text", () => ({
default: () => "",
}))
// Mock components that use ESM dependencies
vi.mock("../BrowserSessionRow", () => ({
default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
},
}))
vi.mock("../ChatRow", () => ({
default: function MockChatRow({ message }: { message: any }) {
// Render the buttons if this is a command ask message
if (message.type === "ask" && message.ask === "command") {
return (
<div data-testid="chat-row">
<div>Command: {message.text}</div>
</div>
)
}
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
},
}))
vi.mock("../TaskHeader", () => ({
default: function MockTaskHeader({ task }: { task: any }) {
return <div data-testid="task-header">Task: {task.text}</div>
},
}))
vi.mock("../AutoApproveMenu", () => ({
default: () => null,
}))
vi.mock("@src/components/common/CodeBlock", () => ({
default: () => null,
CODE_BLOCK_BG_COLOR: "rgb(30, 30, 30)",
}))
vi.mock("@src/components/common/CodeAccordian", () => ({
default: () => null,
}))
vi.mock("@src/components/chat/ContextMenu", () => ({
default: () => null,
}))
// Mock i18n setup
vi.mock("@src/i18n/setup", () => {
const mockT = (key: string, _options?: any) => {
const translations: Record<string, string> = {
"chat:runCommand.title": "Run Command",
"chat:addAndRunCommand.title": "Add & Run",
"chat:reject.title": "Reject",
"chat:typeMessage": "Type a message...",
"chat:typeTask": "Type a task...",
}
return translations[key] || key
}
const mockI18n = {
language: "en",
changeLanguage: vi.fn(),
t: mockT,
use: vi.fn().mockReturnThis(),
init: vi.fn().mockReturnThis(),
}
return {
default: mockI18n,
loadTranslations: vi.fn(),
}
})
// Mock react-i18next
vi.mock("react-i18next", () => {
const mockT = (key: string, _options?: any) => {
const translations: Record<string, string> = {
"chat:runCommand.title": "Run Command",
"chat:addAndRunCommand.title": "Add & Run",
"chat:reject.title": "Reject",
"chat:typeMessage": "Type a message...",
"chat:typeTask": "Type a task...",
}
return translations[key] || key
}
const mockI18n = {
language: "en",
changeLanguage: vi.fn(),
t: mockT,
use: vi.fn().mockReturnThis(),
init: vi.fn().mockReturnThis(),
}
return {
useTranslation: () => ({
t: mockT,
i18n: mockI18n,
}),
Trans: ({ i18nKey, children: _children }: any) => <span>{i18nKey}</span>,
initReactI18next: {
type: "3rdParty",
init: vi.fn(),
},
}
})
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
autoApprovalEnabled: false,
alwaysAllowBrowser: false,
alwaysAllowReadOnly: false,
alwaysAllowReadOnlyOutsideWorkspace: false,
alwaysAllowWrite: false,
alwaysAllowWriteOutsideWorkspace: false,
alwaysAllowWriteProtected: false,
alwaysAllowMcp: false,
alwaysAllowModeSwitch: false,
alwaysAllowSubtasks: false,
writeDelayMs: 0,
mode: "code",
customModes: [],
telemetrySetting: "enabled",
hasSystemPromptOverride: false,
historyPreviewCollapsed: false,
soundEnabled: false,
soundVolume: 0.5,
cwd: "/test",
filePaths: [],
openedTabs: [],
currentApiConfigName: "test-config",
listApiConfigMeta: [],
pinnedApiConfigs: {},
customModePrompts: {},
codebaseIndexConfig: { codebaseIndexEnabled: false },
...state,
},
},
"*",
)
}
describe("ChatView - Add & Run Button", () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderChatView = () => {
return render(
<ExtensionStateContextProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<TranslationProvider>
<ChatView isHidden={false} showAnnouncement={false} hideAnnouncement={() => {}} />
</TranslationProvider>
</TooltipProvider>
</QueryClientProvider>
</ExtensionStateContextProvider>,
)
}
beforeEach(() => {
vi.clearAllMocks()
// Mock window.AUDIO_BASE_URI
;(window as any).AUDIO_BASE_URI = ""
})
it("should display three buttons for command approval", async () => {
renderChatView()
// Hydrate state with a task message first, then a command ask
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
partial: false,
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "npm test",
partial: false,
},
],
})
// Wait for the buttons to appear
await waitFor(
() => {
expect(screen.getByText("Run Command")).toBeInTheDocument()
},
{ timeout: 5000 },
)
expect(screen.getByText("Add & Run")).toBeInTheDocument()
expect(screen.getByText("Reject")).toBeInTheDocument()
})
it("should send the command pattern when Add & Run button is clicked", async () => {
renderChatView()
// Hydrate state with a task message first, then a command ask
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
partial: false,
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "npm test",
partial: false,
},
],
})
// Wait for the buttons to appear
await waitFor(
() => {
expect(screen.getByText("Run Command")).toBeInTheDocument()
expect(screen.getByText("Add & Run")).toBeInTheDocument()
expect(screen.getByText("Reject")).toBeInTheDocument()
},
{ timeout: 5000 },
)
// Click the Add & Run button
const addAndRunButton = screen.getByText("Add & Run")
fireEvent.click(addAndRunButton)
// Verify the correct message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "addAndRunButtonClicked",
text: "npm test", // The extracted pattern
images: [],
})
})
it("should extract and send the correct pattern for complex commands", async () => {
const complexCommand = "cd /path/to/project && npm run build:prod --verbose"
renderChatView()
// Hydrate state with a task message first, then a command ask
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Build project",
partial: false,
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: complexCommand,
partial: false,
},
],
})
// Wait for the buttons to appear
await waitFor(
() => {
expect(screen.getByText("Add & Run")).toBeInTheDocument()
},
{ timeout: 5000 },
)
// Click the Add & Run button
const addAndRunButton = screen.getByText("Add & Run")
fireEvent.click(addAndRunButton)
// Verify the correct pattern was extracted and sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "addAndRunButtonClicked",
text: "cd * && npm run *", // The extracted pattern
images: [],
})
})
it("should handle commands with user input", async () => {
renderChatView()
// Hydrate state with a task message first, then a command ask
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
partial: false,
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "npm test",
partial: false,
},
],
})
// Wait for the buttons and input to appear
await waitFor(
() => {
expect(screen.getByText("Add & Run")).toBeInTheDocument()
},
{ timeout: 5000 },
)
// Type some user input
const textarea = screen.getByPlaceholderText(/Type a message/i)
fireEvent.change(textarea, { target: { value: "additional feedback" } })
// Click the Add & Run button
const addAndRunButton = screen.getByText("Add & Run")
fireEvent.click(addAndRunButton)
// Verify the pattern was sent (not the user input)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "addAndRunButtonClicked",
text: "npm test", // The extracted pattern, not the user input
images: [],
})
})
})

View file

@ -200,6 +200,17 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
}
}, [settingsImportedAt, extensionState])
// Update cached state when allowedCommands changes from external sources (e.g., "Add & Run")
useEffect(() => {
// Only update if the allowedCommands have actually changed
if (JSON.stringify(cachedState.allowedCommands) !== JSON.stringify(extensionState.allowedCommands)) {
setCachedState((prevCachedState) => ({
...prevCachedState,
allowedCommands: extensionState.allowedCommands,
}))
}
}, [extensionState.allowedCommands, cachedState.allowedCommands])
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
setCachedState((prevState) => {
if (prevState[field] === value) {

View file

@ -0,0 +1,410 @@
import React from "react"
import { render, screen, waitFor, fireEvent } from "@testing-library/react"
import { vi } from "vitest"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import SettingsView from "../SettingsView"
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock ApiConfigManager
vi.mock("../ApiConfigManager", () => ({
__esModule: true,
default: ({ currentApiConfigName }: any) => (
<div data-testid="api-config-management">
<span>Current config: {currentApiConfigName}</span>
</div>
),
}))
// Mock VSCode UI toolkit components
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: ({ children, onClick, appearance, "data-testid": dataTestId }: any) =>
appearance === "icon" ? (
<button
onClick={onClick}
className="codicon codicon-close"
aria-label="Remove command"
data-testid={dataTestId}>
<span className="codicon codicon-close" />
</button>
) : (
<button onClick={onClick} data-appearance={appearance} data-testid={dataTestId}>
{children}
</button>
),
VSCodeCheckbox: ({ children, onChange, checked, "data-testid": dataTestId }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
aria-label={typeof children === "string" ? children : undefined}
data-testid={dataTestId}
/>
{children}
</label>
),
VSCodeTextField: ({ value, onInput, placeholder, "data-testid": dataTestId }: any) => (
<input
type="text"
value={value}
onChange={(e) => onInput({ target: { value: e.target.value } })}
placeholder={placeholder}
data-testid={dataTestId}
/>
),
VSCodeLink: ({ children, href }: any) => <a href={href || "#"}>{children}</a>,
VSCodeRadio: ({ value, checked, onChange }: any) => (
<input type="radio" value={value} checked={checked} onChange={onChange} />
),
VSCodeRadioGroup: ({ children, onChange }: any) => <div onChange={onChange}>{children}</div>,
}))
// Mock Tab components
vi.mock("../../../components/common/Tab", () => ({
...vi.importActual("../../../components/common/Tab"),
Tab: ({ children }: any) => <div data-testid="tab-container">{children}</div>,
TabHeader: ({ children }: any) => <div data-testid="tab-header">{children}</div>,
TabContent: ({ children }: any) => <div data-testid="tab-content">{children}</div>,
TabList: ({ children, value, onValueChange, "data-testid": dataTestId }: any) => {
// Store onValueChange in a global variable so TabTrigger can access it
;(window as any).__onValueChange = onValueChange
return (
<div data-testid={dataTestId} data-value={value}>
{children}
</div>
)
},
TabTrigger: ({ children, value, "data-testid": dataTestId, onClick, isSelected }: any) => {
// This function simulates clicking on a tab and making its content visible
const handleClick = () => {
if (onClick) onClick()
// Access onValueChange from the global variable
const onValueChange = (window as any).__onValueChange
if (onValueChange) onValueChange(value)
// Make all tab contents invisible
document.querySelectorAll("[data-tab-content]").forEach((el) => {
;(el as HTMLElement).style.display = "none"
})
// Make this tab's content visible
const tabContent = document.querySelector(`[data-tab-content="${value}"]`)
if (tabContent) {
;(tabContent as HTMLElement).style.display = "block"
}
}
return (
<button data-testid={dataTestId} data-value={value} data-selected={isSelected} onClick={handleClick}>
{children}
</button>
)
},
}))
// Mock UI components
vi.mock("@/components/ui", () => ({
...vi.importActual("@/components/ui"),
Slider: ({ value, onValueChange, "data-testid": dataTestId }: any) => (
<input
type="range"
value={value[0]}
onChange={(e) => onValueChange([parseFloat(e.target.value)])}
data-testid={dataTestId}
/>
),
Button: ({ children, onClick, variant, className, "data-testid": dataTestId }: any) => (
<button onClick={onClick} data-variant={variant} className={className} data-testid={dataTestId}>
{children}
</button>
),
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
TooltipProvider: ({ children }: any) => <>{children}</>,
Input: ({ value, onChange, placeholder, "data-testid": dataTestId }: any) => (
<input type="text" value={value} onChange={onChange} placeholder={placeholder} data-testid={dataTestId} />
),
Select: ({ children, value, onValueChange }: any) => (
<div data-testid="select" data-value={value}>
<button onClick={() => onValueChange && onValueChange("test-change")}>{value}</button>
{children}
</div>
),
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectGroup: ({ children }: any) => <div data-testid="select-group">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid={`select-item-${value}`} data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
AlertDialog: ({ children, open }: any) => (
<div data-testid="alert-dialog" data-open={open}>
{children}
</div>
),
AlertDialogContent: ({ children }: any) => <div data-testid="alert-dialog-content">{children}</div>,
AlertDialogHeader: ({ children }: any) => <div data-testid="alert-dialog-header">{children}</div>,
AlertDialogTitle: ({ children }: any) => <div data-testid="alert-dialog-title">{children}</div>,
AlertDialogDescription: ({ children }: any) => <div data-testid="alert-dialog-description">{children}</div>,
AlertDialogFooter: ({ children }: any) => <div data-testid="alert-dialog-footer">{children}</div>,
AlertDialogAction: ({ children, onClick }: any) => (
<button data-testid="alert-dialog-action" onClick={onClick}>
{children}
</button>
),
AlertDialogCancel: ({ children, onClick }: any) => (
<button data-testid="alert-dialog-cancel" onClick={onClick}>
{children}
</button>
),
}))
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
ttsEnabled: false,
ttsSpeed: 1,
soundEnabled: false,
soundVolume: 0.5,
...state,
},
},
"*",
)
}
const renderSettingsView = () => {
const onDone = vi.fn()
const queryClient = new QueryClient()
render(
<ExtensionStateContextProvider>
<QueryClientProvider client={queryClient}>
<SettingsView onDone={onDone} />
</QueryClientProvider>
</ExtensionStateContextProvider>,
)
// Hydrate initial state.
mockPostMessage({})
// Helper function to activate a tab by clicking it
const activateTab = async (tabId: string) => {
const tabButton = screen.getByTestId(`tab-${tabId}`)
fireEvent.click(tabButton)
// Wait for the tab content to be visible
await waitFor(() => {
// The tab should be marked as selected
expect(tabButton).toHaveAttribute("data-selected", "true")
})
}
return { onDone, activateTab }
}
describe("SettingsView - allowedCommands external updates", () => {
const mockVscodePostMessage = vi.mocked(vscode.postMessage)
beforeEach(() => {
vi.clearAllMocks()
})
it("should update cached allowedCommands when extension state changes externally", async () => {
const { activateTab } = renderSettingsView()
// Activate the autoApprove tab
await activateTab("autoApprove")
// Enable always allow execute
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
fireEvent.click(executeCheckbox)
// Wait for allowed commands section to appear
await waitFor(() => {
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
})
// Initially, there should be no allowed commands
expect(screen.queryByText("npm test")).not.toBeInTheDocument()
// Simulate external state update (like from "Add & Run")
mockPostMessage({
allowedCommands: ["npm test", "git status"],
alwaysAllowExecute: true,
})
// Wait for the UI to update
await waitFor(() => {
// Check that the new commands appear in the UI
expect(screen.getByText("npm test")).toBeInTheDocument()
expect(screen.getByText("git status")).toBeInTheDocument()
})
// Verify that no save message was sent (since this was an external update)
expect(mockVscodePostMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "allowedCommands",
}),
)
})
it("should handle multiple external updates to allowedCommands", async () => {
const { activateTab } = renderSettingsView()
// Activate the autoApprove tab
await activateTab("autoApprove")
// Enable always allow execute
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
fireEvent.click(executeCheckbox)
await waitFor(() => {
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
})
// First update
mockPostMessage({
allowedCommands: ["npm test"],
alwaysAllowExecute: true,
})
await waitFor(() => {
expect(screen.getByText("npm test")).toBeInTheDocument()
})
// Second update (adding more commands)
mockPostMessage({
allowedCommands: ["npm test", "npm run build", "echo hello"],
alwaysAllowExecute: true,
})
await waitFor(() => {
expect(screen.getByText("npm test")).toBeInTheDocument()
expect(screen.getByText("npm run build")).toBeInTheDocument()
expect(screen.getByText("echo hello")).toBeInTheDocument()
})
})
it("should not mark settings as changed when allowedCommands update externally", async () => {
const { activateTab } = renderSettingsView()
// Activate the autoApprove tab
await activateTab("autoApprove")
// Enable always allow execute
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
fireEvent.click(executeCheckbox)
await waitFor(() => {
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
})
// Get initial state of save button
const saveButton = screen.getByTestId("save-button")
const initialClasses = saveButton.className
// External update
mockPostMessage({
allowedCommands: ["npm test"],
alwaysAllowExecute: true,
})
await waitFor(() => {
expect(screen.getByText("npm test")).toBeInTheDocument()
})
// Save button should maintain its initial state (not change due to external update)
expect(saveButton.className).toBe(initialClasses)
// Verify that no save message was sent (since this was an external update)
expect(mockVscodePostMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "allowedCommands",
}),
)
})
it("should replace user changes when external updates occur", async () => {
const { activateTab } = renderSettingsView()
// Activate the autoApprove tab
await activateTab("autoApprove")
// Enable always allow execute
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
fireEvent.click(executeCheckbox)
// Wait for allowed commands section to appear
await waitFor(() => {
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
})
// Add a command manually
const input = screen.getByTestId("command-input")
fireEvent.change(input, { target: { value: "npm start" } })
const addButton = screen.getByTestId("add-command-button")
fireEvent.click(addButton)
// Wait for VSCode message to be sent
await waitFor(() => {
expect(mockVscodePostMessage).toHaveBeenCalledWith({
type: "allowedCommands",
commands: ["npm start"],
})
})
// Simulate the state update that would come from VSCode after adding the command
mockPostMessage({
allowedCommands: ["npm start"],
alwaysAllowExecute: true,
})
// The command should appear in the UI
await waitFor(() => {
expect(screen.getByText("npm start")).toBeInTheDocument()
})
// Clear the mock to ensure we don't count the manual addition
mockVscodePostMessage.mockClear()
// External update with different commands
mockPostMessage({
allowedCommands: ["npm test", "git status"],
alwaysAllowExecute: true,
})
// Wait for external commands to appear
await waitFor(() => {
expect(screen.getByText("npm test")).toBeInTheDocument()
expect(screen.getByText("git status")).toBeInTheDocument()
})
// The manually added command should be replaced by the external update
expect(screen.queryByText("npm start")).not.toBeInTheDocument()
// Verify that no save message was sent (since this was an external update)
expect(mockVscodePostMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "allowedCommands",
}),
)
})
})

View file

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@/utils/test-utils"
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { vscode } from "@/utils/vscode"
@ -412,17 +412,22 @@ describe("SettingsView - Allowed Commands", () => {
expect(screen.getByTestId("command-input")).toBeInTheDocument()
})
it("adds new command to the list", () => {
it("adds new command to the list", async () => {
// Render once and get the activateTab helper
const { activateTab } = renderSettingsView()
// Activate the autoApprove tab
activateTab("autoApprove")
await activateTab("autoApprove")
// Enable always allow execute
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
fireEvent.click(executeCheckbox)
// Wait for allowed commands section to appear
await waitFor(() => {
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
})
// Add a new command
const input = screen.getByTestId("command-input")
fireEvent.change(input, { target: { value: "npm test" } })
@ -430,40 +435,61 @@ describe("SettingsView - Allowed Commands", () => {
const addButton = screen.getByTestId("add-command-button")
fireEvent.click(addButton)
// Verify command was added
expect(screen.getByText("npm test")).toBeInTheDocument()
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "allowedCommands",
commands: ["npm test"],
})
// Simulate the state update that would come from VSCode
mockPostMessage({
allowedCommands: ["npm test"],
alwaysAllowExecute: true,
})
// Wait for command to appear
await waitFor(() => {
expect(screen.getByText("npm test")).toBeInTheDocument()
})
})
it("removes command from the list", () => {
it("removes command from the list", async () => {
// Render once and get the activateTab helper
const { activateTab } = renderSettingsView()
// Activate the autoApprove tab
activateTab("autoApprove")
await activateTab("autoApprove")
// Enable always allow execute
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
fireEvent.click(executeCheckbox)
// Wait for allowed commands section to appear
await waitFor(() => {
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
})
// Add a command
const input = screen.getByTestId("command-input")
fireEvent.change(input, { target: { value: "npm test" } })
const addButton = screen.getByTestId("add-command-button")
fireEvent.click(addButton)
// Simulate the state update after adding
mockPostMessage({
allowedCommands: ["npm test"],
alwaysAllowExecute: true,
})
// Wait for command to appear
await waitFor(() => {
expect(screen.getByText("npm test")).toBeInTheDocument()
})
// Remove the command
const removeButton = screen.getByTestId("remove-command-0")
fireEvent.click(removeButton)
// Verify command was removed
expect(screen.queryByText("npm test")).not.toBeInTheDocument()
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: "allowedCommands",
@ -530,32 +556,65 @@ describe("SettingsView - Duplicate Commands", () => {
vi.clearAllMocks()
})
it("prevents duplicate commands", () => {
it("prevents duplicate commands", async () => {
// Render once and get the activateTab helper
const { activateTab } = renderSettingsView()
// Activate the autoApprove tab
activateTab("autoApprove")
await activateTab("autoApprove")
// Enable always allow execute
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
fireEvent.click(executeCheckbox)
// Add a command twice
// Wait for allowed commands section to appear
await waitFor(() => {
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
})
// Add a command
const input = screen.getByTestId("command-input")
const addButton = screen.getByTestId("add-command-button")
// First addition
fireEvent.change(input, { target: { value: "npm test" } })
fireEvent.click(addButton)
// Second addition attempt
// Verify the postMessage was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "allowedCommands",
commands: ["npm test"],
})
// Simulate the state update from VSCode
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ["npm test"],
})
// Wait for the command to appear
await waitFor(() => {
expect(screen.getByText("npm test")).toBeInTheDocument()
})
// Try to add the same command again
fireEvent.change(input, { target: { value: "npm test" } })
fireEvent.click(addButton)
// Verify command appears only once
const commands = screen.getAllByText("npm test")
expect(commands).toHaveLength(1)
// The postMessage should not add a duplicate
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: "allowedCommands",
commands: ["npm test"],
})
// Add a different command
fireEvent.change(input, { target: { value: "npm run build" } })
fireEvent.click(addButton)
// Now it should have both commands
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: "allowedCommands",
commands: ["npm test", "npm run build"],
})
})
it("saves allowed commands when clicking Save", () => {
@ -575,6 +634,12 @@ describe("SettingsView - Duplicate Commands", () => {
const addButton = screen.getByTestId("add-command-button")
fireEvent.click(addButton)
// Simulate the state update after adding
mockPostMessage({
allowedCommands: ["npm test"],
alwaysAllowExecute: true,
})
// Click Save - use getAllByTestId to handle multiple elements
const saveButtons = screen.getAllByTestId("save-button")
fireEvent.click(saveButtons[0])

View file

@ -59,6 +59,10 @@
"title": "Executar ordre",
"tooltip": "Executa aquesta ordre"
},
"addAndRunCommand": {
"title": "Afegeix i executa",
"tooltip": "Afegeix el patró de comanda a la llista blanca i executa-la"
},
"proceedWhileRunning": {
"title": "Continuar mentre s'executa",
"tooltip": "Continua malgrat els advertiments"

View file

@ -59,6 +59,10 @@
"title": "Befehl ausführen",
"tooltip": "Diesen Befehl ausführen"
},
"addAndRunCommand": {
"title": "Hinzufügen & Ausführen",
"tooltip": "Befehlsmuster zur Whitelist hinzufügen und ausführen"
},
"proceedWhileRunning": {
"title": "Während Ausführung fortfahren",
"tooltip": "Trotz Warnungen fortfahren"

View file

@ -67,6 +67,10 @@
"title": "Run Command",
"tooltip": "Execute this command"
},
"addAndRunCommand": {
"title": "Add & Run",
"tooltip": "Add command pattern to whitelist and run"
},
"proceedWhileRunning": {
"title": "Proceed While Running",
"tooltip": "Continue despite warnings"

View file

@ -59,6 +59,10 @@
"title": "Ejecutar comando",
"tooltip": "Ejecutar este comando"
},
"addAndRunCommand": {
"title": "Añadir y ejecutar",
"tooltip": "Añadir patrón de comando a la lista blanca y ejecutar"
},
"proceedWhileRunning": {
"title": "Continuar mientras se ejecuta",
"tooltip": "Continuar a pesar de las advertencias"

View file

@ -59,6 +59,10 @@
"title": "Exécuter la commande",
"tooltip": "Exécuter cette commande"
},
"addAndRunCommand": {
"title": "Ajouter et exécuter",
"tooltip": "Ajouter le modèle de commande à la liste blanche et exécuter"
},
"proceedWhileRunning": {
"title": "Continuer pendant l'exécution",
"tooltip": "Continuer malgré les avertissements"

View file

@ -59,6 +59,10 @@
"title": "कमांड चलाएँ",
"tooltip": "इस कमांड को निष्पादित करें"
},
"addAndRunCommand": {
"title": "जोड़ें और चलाएं",
"tooltip": "कमांड पैटर्न को व्हाइटलिस्ट में जोड़ें और चलाएं"
},
"proceedWhileRunning": {
"title": "चलते समय आगे बढ़ें",
"tooltip": "चेतावनियों के बावजूद जारी रखें"

View file

@ -73,6 +73,10 @@
"title": "Jalankan Perintah",
"tooltip": "Eksekusi perintah ini"
},
"addAndRunCommand": {
"title": "Tambah & Jalankan",
"tooltip": "Tambahkan pola perintah ke daftar putih dan jalankan"
},
"proceedWhileRunning": {
"title": "Lanjutkan Saat Berjalan",
"tooltip": "Lanjutkan meskipun ada peringatan"

View file

@ -59,6 +59,10 @@
"title": "Esegui comando",
"tooltip": "Esegui questo comando"
},
"addAndRunCommand": {
"title": "Aggiungi ed esegui",
"tooltip": "Aggiungi il pattern del comando alla whitelist ed eseguilo"
},
"proceedWhileRunning": {
"title": "Procedi durante l'esecuzione",
"tooltip": "Continua nonostante gli avvisi"

View file

@ -59,6 +59,10 @@
"title": "コマンド実行",
"tooltip": "このコマンドを実行"
},
"addAndRunCommand": {
"title": "追加して実行",
"tooltip": "コマンドパターンをホワイトリストに追加して実行します"
},
"proceedWhileRunning": {
"title": "実行中も続行",
"tooltip": "警告にもかかわらず続行"

View file

@ -59,6 +59,10 @@
"title": "명령 실행",
"tooltip": "이 명령 실행"
},
"addAndRunCommand": {
"title": "추가 및 실행",
"tooltip": "허용 목록에 명령 패턴을 추가하고 실행합니다."
},
"proceedWhileRunning": {
"title": "실행 중에도 계속",
"tooltip": "경고에도 불구하고 계속 진행"

View file

@ -59,6 +59,10 @@
"title": "Commando uitvoeren",
"tooltip": "Voer dit commando uit"
},
"addAndRunCommand": {
"title": "Toevoegen & uitvoeren",
"tooltip": "Commandopatroon toevoegen aan de witte lijst en uitvoeren"
},
"proceedWhileRunning": {
"title": "Doorgaan tijdens uitvoeren",
"tooltip": "Ga door ondanks waarschuwingen"

View file

@ -59,6 +59,10 @@
"title": "Uruchom polecenie",
"tooltip": "Wykonaj to polecenie"
},
"addAndRunCommand": {
"title": "Dodaj i uruchom",
"tooltip": "Dodaj wzorzec polecenia do białej listy i uruchom"
},
"proceedWhileRunning": {
"title": "Kontynuuj podczas wykonywania",
"tooltip": "Kontynuuj pomimo ostrzeżeń"

View file

@ -59,6 +59,10 @@
"title": "Executar comando",
"tooltip": "Executar este comando"
},
"addAndRunCommand": {
"title": "Adicionar & Executar",
"tooltip": "Adicionar padrão de comando à lista de permissões e executar"
},
"proceedWhileRunning": {
"title": "Prosseguir durante execução",
"tooltip": "Continuar apesar dos avisos"

View file

@ -59,6 +59,10 @@
"title": "Выполнить команду",
"tooltip": "Выполнить эту команду"
},
"addAndRunCommand": {
"title": "Добавить и выполнить",
"tooltip": "Добавить шаблон команды в белый список и выполнить"
},
"proceedWhileRunning": {
"title": "Продолжить во время выполнения",
"tooltip": "Продолжить несмотря на предупреждения"

View file

@ -59,6 +59,10 @@
"title": "Komutu Çalıştır",
"tooltip": "Bu komutu çalıştır"
},
"addAndRunCommand": {
"title": "Ekle ve Çalıştır",
"tooltip": "Komut desenini beyaz listeye ekle ve çalıştır"
},
"proceedWhileRunning": {
"title": "Çalışırken Devam Et",
"tooltip": "Uyarılara rağmen devam et"

View file

@ -59,6 +59,10 @@
"title": "Chạy lệnh",
"tooltip": "Thực thi lệnh này"
},
"addAndRunCommand": {
"title": "Thêm & Chạy",
"tooltip": "Thêm mẫu lệnh vào danh sách trắng và chạy"
},
"proceedWhileRunning": {
"title": "Tiếp tục trong khi chạy",
"tooltip": "Tiếp tục bất chấp cảnh báo"

View file

@ -59,6 +59,10 @@
"title": "运行命令",
"tooltip": "执行此命令"
},
"addAndRunCommand": {
"title": "添加并运行",
"tooltip": "将命令模式添加到白名单并运行"
},
"proceedWhileRunning": {
"title": "强制继续",
"tooltip": "忽略运行中的命令并继续"

View file

@ -59,6 +59,10 @@
"title": "執行命令",
"tooltip": "執行此命令"
},
"addAndRunCommand": {
"title": "新增並執行",
"tooltip": "將命令模式新增至白名單並執行"
},
"proceedWhileRunning": {
"title": "執行時繼續",
"tooltip": "儘管有警告仍繼續執行"

View file

@ -0,0 +1,201 @@
import { describe, it, expect } from "vitest"
import { extractCommandPattern, getPatternDescription } from "../extract-command-pattern"
describe("extractCommandPattern", () => {
it("handles empty or null input", () => {
expect(extractCommandPattern("")).toBe("")
expect(extractCommandPattern(" ")).toBe("")
expect(extractCommandPattern(null as any)).toBe("")
expect(extractCommandPattern(undefined as any)).toBe("")
})
describe("npm/yarn/pnpm/bun commands", () => {
it("extracts npm run patterns", () => {
expect(extractCommandPattern("npm run build")).toBe("npm run")
expect(extractCommandPattern("npm run test:unit")).toBe("npm run *")
expect(extractCommandPattern("yarn run dev")).toBe("yarn run")
expect(extractCommandPattern("pnpm run lint")).toBe("pnpm run")
expect(extractCommandPattern("bun run start")).toBe("bun run")
})
it("extracts npm script patterns", () => {
expect(extractCommandPattern("npm test")).toBe("npm test")
expect(extractCommandPattern("npm build")).toBe("npm build")
expect(extractCommandPattern("npm start")).toBe("npm start")
expect(extractCommandPattern("yarn test")).toBe("yarn test")
expect(extractCommandPattern("pnpm build")).toBe("pnpm build")
})
it("handles npm with flags", () => {
expect(extractCommandPattern("npm install --save-dev")).toBe("npm install")
expect(extractCommandPattern("npm test -- --coverage")).toBe("npm test")
expect(extractCommandPattern("npm -v")).toBe("npm")
})
})
describe("git commands", () => {
it("extracts git subcommands", () => {
expect(extractCommandPattern("git commit -m 'message'")).toBe("git commit")
expect(extractCommandPattern("git push origin main")).toBe("git push")
expect(extractCommandPattern("git pull --rebase")).toBe("git pull")
expect(extractCommandPattern("git checkout -b feature")).toBe("git checkout")
})
it("handles git with flags only", () => {
expect(extractCommandPattern("git --version")).toBe("git")
})
})
describe("script files", () => {
it("preserves full script paths", () => {
expect(extractCommandPattern("./scripts/deploy.sh production")).toBe("./scripts/deploy.sh")
expect(extractCommandPattern("/usr/local/bin/backup.sh")).toBe("/usr/local/bin/backup.sh")
expect(extractCommandPattern("scripts/test.py --verbose")).toBe("scripts/test.py")
expect(extractCommandPattern("./build.js --watch")).toBe("./build.js")
})
})
describe("interpreters", () => {
it("extracts just the interpreter", () => {
expect(extractCommandPattern("python script.py --arg value")).toBe("python")
expect(extractCommandPattern("python3 -m pytest")).toBe("python3")
expect(extractCommandPattern("node index.js --port 3000")).toBe("node")
expect(extractCommandPattern("ruby app.rb")).toBe("ruby")
expect(extractCommandPattern("java -jar app.jar")).toBe("java")
})
})
describe("dangerous commands", () => {
it("extracts just the base command", () => {
expect(extractCommandPattern("rm -rf node_modules")).toBe("rm")
expect(extractCommandPattern("mv old.txt new.txt")).toBe("mv")
expect(extractCommandPattern("chmod 755 script.sh")).toBe("chmod")
expect(extractCommandPattern("find . -name '*.log' -delete")).toBe("find")
})
})
describe("chained commands", () => {
it("extracts patterns from all commands in chain", () => {
expect(extractCommandPattern("cd /path && npm install")).toBe("cd * && npm install")
expect(extractCommandPattern("npm test || echo 'failed'")).toBe("npm test || echo")
expect(extractCommandPattern("git pull; npm install; npm run build")).toBe(
"git pull ; npm install ; npm run",
)
expect(extractCommandPattern("echo 'start' | grep start")).toBe("echo | grep")
})
it("handles complex chained commands with wildcards", () => {
expect(extractCommandPattern("cd /path/to/project && npm run build:prod --verbose")).toBe(
"cd * && npm run *",
)
})
})
describe("docker/kubectl commands", () => {
it("extracts docker subcommands", () => {
expect(extractCommandPattern("docker run -it ubuntu")).toBe("docker run")
expect(extractCommandPattern("docker build -t myapp .")).toBe("docker build")
expect(extractCommandPattern("kubectl get pods")).toBe("kubectl get")
expect(extractCommandPattern("kubectl apply -f config.yaml")).toBe("kubectl apply")
expect(extractCommandPattern("helm install myapp ./chart")).toBe("helm install")
})
})
describe("make commands", () => {
it("extracts make targets", () => {
expect(extractCommandPattern("make build")).toBe("make build")
expect(extractCommandPattern("make test")).toBe("make test")
expect(extractCommandPattern("make clean install")).toBe("make clean")
expect(extractCommandPattern("make -j4")).toBe("make")
})
})
describe("quoted arguments", () => {
it("handles single quotes", () => {
expect(extractCommandPattern("echo 'hello world'")).toBe("echo")
expect(extractCommandPattern("git commit -m 'feat: add feature'")).toBe("git commit")
})
it("handles double quotes", () => {
expect(extractCommandPattern('echo "hello world"')).toBe("echo")
expect(extractCommandPattern('npm run "test:unit"')).toBe("npm run *")
})
it("handles quotes with spaces", () => {
expect(extractCommandPattern('git commit -m "fix: resolve issue #123"')).toBe("git commit")
expect(extractCommandPattern("echo 'multiple spaces'")).toBe("echo")
})
})
describe("edge cases", () => {
it("handles commands with redirects", () => {
expect(extractCommandPattern("npm test > output.log")).toBe("npm test")
expect(extractCommandPattern("echo hello 2>&1")).toBe("echo")
})
it("handles cd command", () => {
expect(extractCommandPattern("cd /home/user/project")).toBe("cd *")
expect(extractCommandPattern("cd ..")).toBe("cd *")
expect(extractCommandPattern("cd")).toBe("cd *")
})
it("handles commands with environment variables", () => {
expect(extractCommandPattern("NODE_ENV=production npm start")).toBe("NODE_ENV=production")
expect(extractCommandPattern("PORT=3000 node server.js")).toBe("PORT=3000")
})
})
})
describe("getPatternDescription", () => {
it("describes npm patterns", () => {
expect(getPatternDescription("npm run")).toBe("npm run scripts")
expect(getPatternDescription("npm test")).toBe("npm test commands")
expect(getPatternDescription("npm")).toBe("npm commands")
expect(getPatternDescription("yarn run")).toBe("yarn run scripts")
expect(getPatternDescription("pnpm build")).toBe("pnpm build commands")
})
it("describes git patterns", () => {
expect(getPatternDescription("git commit")).toBe("git commit commands")
expect(getPatternDescription("git push")).toBe("git push commands")
expect(getPatternDescription("git")).toBe("git commands")
})
it("describes script patterns", () => {
expect(getPatternDescription("./scripts/deploy.sh")).toBe("this specific script")
expect(getPatternDescription("/usr/bin/backup.py")).toBe("this specific script")
})
it("describes interpreter patterns", () => {
expect(getPatternDescription("python")).toBe("python scripts")
expect(getPatternDescription("node")).toBe("node scripts")
expect(getPatternDescription("ruby")).toBe("ruby scripts")
})
it("describes docker/kubectl patterns", () => {
expect(getPatternDescription("docker run")).toBe("docker run commands")
expect(getPatternDescription("kubectl get")).toBe("kubectl get commands")
expect(getPatternDescription("helm install")).toBe("helm install commands")
})
it("describes make patterns", () => {
expect(getPatternDescription("make build")).toBe("make build target")
expect(getPatternDescription("make test")).toBe("make test target")
expect(getPatternDescription("make")).toBe("make commands")
})
it("describes cd pattern", () => {
expect(getPatternDescription("cd")).toBe("directory navigation")
})
it("describes generic patterns", () => {
expect(getPatternDescription("echo")).toBe("echo commands")
expect(getPatternDescription("rm")).toBe("rm commands")
expect(getPatternDescription("custom-tool")).toBe("custom-tool commands")
})
it("handles empty input", () => {
expect(getPatternDescription("")).toBe("")
expect(getPatternDescription(null as any)).toBe("")
})
})

View file

@ -0,0 +1,232 @@
/**
* Extracts a generalizable command pattern from a specific command.
* This function creates patterns that can be used for whitelisting similar commands.
*
* Examples:
* - "npm test" -> "npm test"
* - "npm run build" -> "npm run"
* - "git commit -m 'message'" -> "git commit"
* - "echo 'hello world'" -> "echo"
* - "python script.py --arg value" -> "python"
* - "./scripts/deploy.sh production" -> "./scripts/deploy.sh"
* - "cd /path/to/dir && npm install" -> "cd * && npm install"
* - "rm -rf node_modules" -> "rm"
*/
export function extractCommandPattern(command: string): string {
if (!command?.trim()) return ""
// Remove leading/trailing whitespace
const trimmedCommand = command.trim()
// Check if this is a chained command
const chainMatch = trimmedCommand.match(/^(.+?)\s*(&&|\|\||;|\|)\s*(.+)$/)
if (chainMatch) {
// Handle chained commands by processing each part
const [, firstPart, operator, restPart] = chainMatch
const firstPattern = extractSingleCommandPattern(firstPart.trim())
const restPattern = extractCommandPattern(restPart.trim())
return `${firstPattern} ${operator} ${restPattern}`
}
// Not a chained command, process normally
return extractSingleCommandPattern(trimmedCommand)
}
/**
* Extracts pattern from a single command (not chained)
*/
function extractSingleCommandPattern(command: string): string {
const firstCommand = command
// Split the command into tokens, respecting quotes
const tokens: string[] = []
let currentToken = ""
let inSingleQuote = false
let inDoubleQuote = false
let escapeNext = false
for (let i = 0; i < firstCommand.length; i++) {
const char = firstCommand[i]
if (escapeNext) {
currentToken += char
escapeNext = false
continue
}
if (char === "\\") {
escapeNext = true
currentToken += char
continue
}
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote
currentToken += char
continue
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote
currentToken += char
continue
}
if (char === " " && !inSingleQuote && !inDoubleQuote) {
if (currentToken) {
tokens.push(currentToken)
currentToken = ""
}
} else {
currentToken += char
}
}
if (currentToken) {
tokens.push(currentToken)
}
if (tokens.length === 0) return ""
const baseCommand = tokens[0]
// Special handling for common patterns
// 1. npm/yarn/pnpm commands - include subcommand with wildcards for scripts
if (["npm", "yarn", "pnpm", "bun"].includes(baseCommand) && tokens.length > 1) {
const subCommand = tokens[1]
// For "run" commands, include "run" with wildcard for script names
if (subCommand === "run" && tokens.length > 2) {
// Check if the script name contains special characters like colons
const scriptName = tokens[2]
if (scriptName && (scriptName.includes(":") || scriptName.includes("-"))) {
return `${baseCommand} run *`
}
return `${baseCommand} run`
}
// For direct scripts like "npm test", "npm build", include the script name
if (!subCommand.startsWith("-")) {
return `${baseCommand} ${subCommand}`
}
}
// 2. git commands - include subcommand
if (baseCommand === "git" && tokens.length > 1) {
const subCommand = tokens[1]
if (!subCommand.startsWith("-")) {
return `${baseCommand} ${subCommand}`
}
}
// 3. Script files - include the full script path
if (
baseCommand.includes("/") ||
baseCommand.endsWith(".sh") ||
baseCommand.endsWith(".py") ||
baseCommand.endsWith(".js") ||
baseCommand.endsWith(".rb")
) {
return baseCommand
}
// 4. Python/node/ruby/etc interpreters - just the interpreter
if (["python", "python3", "node", "ruby", "perl", "php", "java", "go"].includes(baseCommand)) {
return baseCommand
}
// 5. Common shell commands with dangerous flags - just the command
if (["rm", "mv", "cp", "chmod", "chown", "find", "grep", "sed", "awk"].includes(baseCommand)) {
return baseCommand
}
// 6. cd command - include wildcard for paths
if (baseCommand === "cd") {
return "cd *"
}
// 7. Docker/kubectl commands - include subcommand
if (["docker", "kubectl", "helm"].includes(baseCommand) && tokens.length > 1) {
const subCommand = tokens[1]
if (!subCommand.startsWith("-")) {
return `${baseCommand} ${subCommand}`
}
}
// 8. Make commands - include target if present
if (baseCommand === "make" && tokens.length > 1) {
const target = tokens[1]
if (!target.startsWith("-")) {
return `${baseCommand} ${target}`
}
}
// Default: just return the base command
return baseCommand
}
/**
* Get a human-readable description of what the pattern will allow
*
* Examples:
* - "npm test" -> "npm test commands"
* - "npm run" -> "npm run scripts"
* - "git commit" -> "git commit commands"
* - "python" -> "python scripts"
* - "./scripts/deploy.sh" -> "this specific script"
*/
export function getPatternDescription(pattern: string): string {
if (!pattern) return ""
const tokens = pattern.split(" ")
const baseCommand = tokens[0]
// npm/yarn/pnpm patterns
if (["npm", "yarn", "pnpm", "bun"].includes(baseCommand)) {
if (tokens[1] === "run") {
return `${baseCommand} run scripts`
}
if (tokens[1]) {
return `${baseCommand} ${tokens[1]} commands`
}
return `${baseCommand} commands`
}
// git patterns
if (baseCommand === "git" && tokens[1]) {
return `git ${tokens[1]} commands`
}
// Script files
if (
baseCommand.includes("/") ||
baseCommand.endsWith(".sh") ||
baseCommand.endsWith(".py") ||
baseCommand.endsWith(".js") ||
baseCommand.endsWith(".rb")
) {
return "this specific script"
}
// Interpreters
if (["python", "python3", "node", "ruby", "perl", "php", "java", "go"].includes(baseCommand)) {
return `${baseCommand} scripts`
}
// Docker/kubectl
if (["docker", "kubectl", "helm"].includes(baseCommand) && tokens[1]) {
return `${baseCommand} ${tokens[1]} commands`
}
// Make
if (baseCommand === "make" && tokens[1]) {
return `make ${tokens[1]} target`
}
// cd
if (baseCommand === "cd") {
return "directory navigation"
}
// Default
return `${baseCommand} commands`
}