Prettier backfill

This commit is contained in:
Matt Rubens
2025-01-17 14:11:28 -05:00
parent 3bcb4ff8c5
commit 60a0a824b9
174 changed files with 15715 additions and 15428 deletions

View File

@@ -1,22 +1,22 @@
const { override } = require('customize-cra');
const { override } = require("customize-cra")
module.exports = override();
module.exports = override()
// Jest configuration override
module.exports.jest = function(config) {
// Configure reporters
config.reporters = [["jest-simple-dot-reporter", {}]];
// Configure module name mapper for CSS modules
config.moduleNameMapper = {
...config.moduleNameMapper,
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
};
// Configure transform ignore patterns for ES modules
config.transformIgnorePatterns = [
'/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|unist-util-find-after|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text|@vscode/webview-ui-toolkit|@microsoft/fast-react-wrapper|@microsoft/fast-element|@microsoft/fast-foundation|@microsoft/fast-web-utilities|exenv-es6|vscrui)/)'
];
return config;
}
module.exports.jest = function (config) {
// Configure reporters
config.reporters = [["jest-simple-dot-reporter", {}]]
// Configure module name mapper for CSS modules
config.moduleNameMapper = {
...config.moduleNameMapper,
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
}
// Configure transform ignore patterns for ES modules
config.transformIgnorePatterns = [
"/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|unist-util-find-after|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text|@vscode/webview-ui-toolkit|@microsoft/fast-react-wrapper|@microsoft/fast-element|@microsoft/fast-foundation|@microsoft/fast-web-utilities|exenv-es6|vscrui)/)",
]
return config
}

View File

@@ -33,25 +33,28 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
🎉{" "}Introducing Roo Cline v{minorVersion}
</h2>
<h3 style={{ margin: "0 0 8px" }}>
Agent Modes Customization
</h3>
<h3 style={{ margin: "0 0 8px" }}>Agent Modes Customization</h3>
<p style={{ margin: "5px 0px" }}>
Click the new <span className="codicon codicon-notebook" style={{ fontSize: "10px" }}></span> icon in the menu bar to open the Prompts Settings and customize Agent Modes for new levels of productivity.
Click the new <span className="codicon codicon-notebook" style={{ fontSize: "10px" }}></span> icon in
the menu bar to open the Prompts Settings and customize Agent Modes for new levels of productivity.
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
<li>Tailor how Roo Cline behaves in different modes: Code, Architect, and Ask.</li>
<li>Preview and verify your changes using the Preview System Prompt button.</li>
</ul>
</p>
<h3 style={{ margin: "0 0 8px" }}>
Prompt Enhancement Configuration
</h3>
<h3 style={{ margin: "0 0 8px" }}>Prompt Enhancement Configuration</h3>
<p style={{ margin: "5px 0px" }}>
Now available for all providers! Access it directly in the chat box by clicking the <span className="codicon codicon-sparkle" style={{ fontSize: "10px" }}></span> sparkle icon next to the input field. From there, you can customize the enhancement logic and provider to best suit your workflow.
Now available for all providers! Access it directly in the chat box by clicking the{" "}
<span className="codicon codicon-sparkle" style={{ fontSize: "10px" }}></span> sparkle icon next to the
input field. From there, you can customize the enhancement logic and provider to best suit your
workflow.
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
<li>Customize how prompts are enhanced for better results in your workflow.</li>
<li>Use the sparkle icon in the chat box to select a API configuration and provider (e.g., GPT-4) and configure your own enhancement logic.</li>
<li>
Use the sparkle icon in the chat box to select a API configuration and provider (e.g., GPT-4)
and configure your own enhancement logic.
</li>
<li>Test your changes instantly with the Preview Prompt Enhancement tool.</li>
</ul>
</p>

View File

@@ -127,7 +127,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
}, [alwaysApproveResubmit, setAlwaysApproveResubmit])
// Map action IDs to their specific handlers
const actionHandlers: Record<AutoApproveAction['id'], () => void> = {
const actionHandlers: Record<AutoApproveAction["id"], () => void> = {
readFiles: handleReadOnlyChange,
editFiles: handleWriteChange,
executeCommands: handleExecuteChange,
@@ -166,25 +166,30 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
}}
/>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
flex: 1,
minWidth: 0
}}>
<span style={{
color: "var(--vscode-foreground)",
flexShrink: 0
}}>Auto-approve:</span>
<span style={{
color: "var(--vscode-descriptionForeground)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flex: 1,
minWidth: 0
minWidth: 0,
}}>
<span
style={{
color: "var(--vscode-foreground)",
flexShrink: 0,
}}>
Auto-approve:
</span>
<span
style={{
color: "var(--vscode-descriptionForeground)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
}}>
{enabledActionsList || "None"}
</span>
<span
@@ -210,9 +215,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
{actions.map((action) => (
<div key={action.id} style={{ margin: "6px 0" }}>
<div onClick={(e) => e.stopPropagation()}>
<VSCodeCheckbox
checked={action.enabled}
onChange={actionHandlers[action.id]}>
<VSCodeCheckbox checked={action.enabled} onChange={actionHandlers[action.id]}>
{action.label}
</VSCodeCheckbox>
</div>

View File

@@ -31,8 +31,8 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
const { browserViewportSize = "900x600" } = useExtensionState()
const [viewportWidth, viewportHeight] = browserViewportSize.split("x").map(Number)
const aspectRatio = (viewportHeight / viewportWidth * 100).toFixed(2)
const defaultMousePosition = `${Math.round(viewportWidth/2)},${Math.round(viewportHeight/2)}`
const aspectRatio = ((viewportHeight / viewportWidth) * 100).toFixed(2)
const defaultMousePosition = `${Math.round(viewportWidth / 2)},${Math.round(viewportHeight / 2)}`
const isLastApiReqInterrupted = useMemo(() => {
// Check if last api_req_started is cancelled
@@ -171,7 +171,8 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
const displayState = isLastPage
? {
url: currentPage?.currentState.url || latestState.url || initialUrl,
mousePosition: currentPage?.currentState.mousePosition || latestState.mousePosition || defaultMousePosition,
mousePosition:
currentPage?.currentState.mousePosition || latestState.mousePosition || defaultMousePosition,
consoleLogs: currentPage?.currentState.consoleLogs,
screenshot: currentPage?.currentState.screenshot || latestState.screenshot,
}
@@ -226,7 +227,9 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
}, [isBrowsing, currentPage?.nextAction?.messages])
// Use latest click position while browsing, otherwise use display state
const mousePosition = isBrowsing ? latestClickPosition || displayState.mousePosition : displayState.mousePosition || defaultMousePosition
const mousePosition = isBrowsing
? latestClickPosition || displayState.mousePosition
: displayState.mousePosition || defaultMousePosition
const [browserSessionRow, { height: rowHeight }] = useSize(
<div style={{ padding: "10px 6px 10px 15px", marginBottom: -10 }}>

View File

@@ -565,7 +565,13 @@ export const ChatRowContent = ({
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "10px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "10px",
}}>
<span style={{ display: "block", flexGrow: 1 }}>{highlightMentions(message.text)}</span>
<VSCodeButton
appearance="icon"
@@ -574,17 +580,16 @@ export const ChatRowContent = ({
flexShrink: 0,
height: "24px",
marginTop: "-6px",
marginRight: "-6px"
marginRight: "-6px",
}}
disabled={isStreaming}
onClick={(e) => {
e.stopPropagation();
e.stopPropagation()
vscode.postMessage({
type: "deleteMessage",
value: message.ts
});
}}
>
value: message.ts,
})
}}>
<span className="codicon codicon-trash"></span>
</VSCodeButton>
</div>
@@ -835,10 +840,13 @@ export const ChatRowContent = ({
tool={{
name: useMcpServer.toolName || "",
description:
server?.tools?.find((tool) => tool.name === useMcpServer.toolName)
?.description || "",
alwaysAllow: server?.tools?.find((tool) => tool.name === useMcpServer.toolName)
?.alwaysAllow || false,
server?.tools?.find(
(tool) => tool.name === useMcpServer.toolName,
)?.description || "",
alwaysAllow:
server?.tools?.find(
(tool) => tool.name === useMcpServer.toolName,
)?.alwaysAllow || false,
}}
serverName={useMcpServer.serverName}
/>
@@ -919,14 +927,13 @@ export const ProgressIndicator = () => (
)
const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => {
const [isHovering, setIsHovering] = useState(false);
const [isHovering, setIsHovering] = useState(false)
return (
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{ position: "relative" }}
>
style={{ position: "relative" }}>
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere", marginBottom: -15, marginTop: -15 }}>
<MarkdownBlock markdown={markdown} />
</div>
@@ -938,9 +945,8 @@ const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boo
right: "8px",
opacity: 0,
animation: "fadeIn 0.2s ease-in-out forwards",
borderRadius: "4px"
}}
>
borderRadius: "4px",
}}>
<style>
{`
@keyframes fadeIn {
@@ -956,21 +962,20 @@ const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boo
height: "24px",
border: "none",
background: "var(--vscode-editor-background)",
transition: "background 0.2s ease-in-out"
transition: "background 0.2s ease-in-out",
}}
onClick={() => {
navigator.clipboard.writeText(markdown);
navigator.clipboard.writeText(markdown)
// Flash the button background briefly to indicate success
const button = document.activeElement as HTMLElement;
const button = document.activeElement as HTMLElement
if (button) {
button.style.background = "var(--vscode-button-background)";
button.style.background = "var(--vscode-button-background)"
setTimeout(() => {
button.style.background = "";
}, 200);
button.style.background = ""
}, 200)
}
}}
title="Copy as markdown"
>
title="Copy as markdown">
<span className="codicon codicon-copy"></span>
</VSCodeButton>
</div>

View File

@@ -69,24 +69,24 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
useEffect(() => {
const messageHandler = (event: MessageEvent) => {
const message = event.data
if (message.type === 'enhancedPrompt') {
if (message.type === "enhancedPrompt") {
if (message.text) {
setInputValue(message.text)
}
setIsEnhancingPrompt(false)
} else if (message.type === 'commitSearchResults') {
} else if (message.type === "commitSearchResults") {
const commits = message.commits.map((commit: any) => ({
type: ContextMenuOptionType.Git,
value: commit.hash,
label: commit.subject,
description: `${commit.shortHash} by ${commit.author} on ${commit.date}`,
icon: "$(git-commit)"
icon: "$(git-commit)",
}))
setGitCommits(commits)
}
}
window.addEventListener('message', messageHandler)
return () => window.removeEventListener('message', messageHandler)
window.addEventListener("message", messageHandler)
return () => window.removeEventListener("message", messageHandler)
}, [setInputValue])
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
@@ -109,12 +109,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) {
const message: WebviewMessage = {
type: "searchCommits",
query: searchQuery || ""
query: searchQuery || "",
} as const
vscode.postMessage(message)
}
}, [selectedType, searchQuery])
const handleEnhancePrompt = useCallback(() => {
if (!textAreaDisabled) {
const trimmedInput = inputValue.trim()
@@ -126,7 +126,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
vscode.postMessage(message)
} else {
const promptDescription = "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works."
const promptDescription =
"The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works."
setInputValue(promptDescription)
}
}
@@ -170,9 +171,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return
}
if (type === ContextMenuOptionType.File ||
if (
type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder ||
type === ContextMenuOptionType.Git) {
type === ContextMenuOptionType.Git
) {
if (!value) {
setSelectedType(type)
setSearchQuery("")
@@ -505,7 +508,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
paddingRight: "6px",
WebkitAppearance: "none" as const,
MozAppearance: "none" as const,
appearance: "none" as const
appearance: "none" as const,
}
const caretContainerStyle = {
@@ -514,11 +517,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
top: "50%",
transform: "translateY(-45%)",
pointerEvents: "none" as const,
opacity: textAreaDisabled ? 0.5 : 0.8
opacity: textAreaDisabled ? 0.5 : 0.8,
}
return (
<div
<div
className="chat-text-area"
style={{
opacity: textAreaDisabled ? 0.5 : 1,
@@ -528,15 +531,14 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
gap: "8px",
backgroundColor: "var(--vscode-input-background)",
margin: "10px 15px",
padding: "8px"
padding: "8px",
}}
onDrop={async (e) => {
e.preventDefault()
const files = Array.from(e.dataTransfer.files)
const text = e.dataTransfer.getData("text")
if (text) {
const newValue =
inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition)
const newValue = inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition)
setInputValue(newValue)
const newCursorPosition = cursorPosition + text.length
setCursorPosition(newCursorPosition)
@@ -567,11 +569,13 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const imageDataArray = await Promise.all(imagePromises)
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
if (dataUrls.length > 0) {
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
if (typeof vscode !== 'undefined') {
setSelectedImages((prevImages) =>
[...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE),
)
if (typeof vscode !== "undefined") {
vscode.postMessage({
type: 'draggedImages',
dataUrls: dataUrls
type: "draggedImages",
dataUrls: dataUrls,
})
}
} else {
@@ -581,8 +585,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}}
onDragOver={(e) => {
e.preventDefault()
}}
>
}}>
{showContextMenu && (
<div ref={contextMenuContainerRef}>
<ContextMenu
@@ -596,15 +599,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
</div>
)}
<div style={{
position: "relative",
flex: "1 1 auto",
display: "flex",
flexDirection: "column-reverse",
minHeight: 0,
overflow: "hidden"
}}>
<div
style={{
position: "relative",
flex: "1 1 auto",
display: "flex",
flexDirection: "column-reverse",
minHeight: 0,
overflow: "hidden",
}}>
<div
ref={highlightLayerRef}
style={{
@@ -620,7 +624,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
lineHeight: "var(--vscode-editor-line-height)",
padding: "8px",
marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
zIndex: 1
zIndex: 1,
}}
/>
<DynamicTextArea
@@ -671,7 +675,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
cursor: textAreaDisabled ? "not-allowed" : undefined,
flex: "0 1 auto",
zIndex: 2
zIndex: 2,
}}
onScroll={() => updateHighlights()}
/>
@@ -687,22 +691,24 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
bottom: "36px",
left: "16px",
zIndex: 2,
marginBottom: "8px"
marginBottom: "8px",
}}
/>
)}
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "auto",
paddingTop: "8px"
}}>
<div style={{
<div
style={{
display: "flex",
alignItems: "center"
justifyContent: "space-between",
alignItems: "center",
marginTop: "auto",
paddingTop: "8px",
}}>
<div
style={{
display: "flex",
alignItems: "center",
}}>
<div style={{ position: "relative", display: "inline-block" }}>
<select
value={mode}
@@ -712,24 +718,22 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setMode(newMode)
vscode.postMessage({
type: "mode",
text: newMode
text: newMode,
})
}}
style={{
...selectStyle,
minWidth: "70px",
flex: "0 0 auto"
}}
>
{modes.map(mode => (
flex: "0 0 auto",
}}>
{modes.map((mode) => (
<option
key={mode.slug}
value={mode.slug}
style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}
>
color: "var(--vscode-dropdown-foreground)",
}}>
{mode.name}
</option>
))}
@@ -739,36 +743,37 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
</div>
<div style={{
position: "relative",
display: "inline-block",
flex: "1 1 auto",
minWidth: 0,
maxWidth: "150px",
overflow: "hidden"
}}>
<div
style={{
position: "relative",
display: "inline-block",
flex: "1 1 auto",
minWidth: 0,
maxWidth: "150px",
overflow: "hidden",
}}>
<select
value={currentApiConfigName}
disabled={textAreaDisabled}
onChange={(e) => vscode.postMessage({
type: "loadApiConfiguration",
text: e.target.value
})}
onChange={(e) =>
vscode.postMessage({
type: "loadApiConfiguration",
text: e.target.value,
})
}
style={{
...selectStyle,
width: "100%",
textOverflow: "ellipsis"
}}
>
textOverflow: "ellipsis",
}}>
{(listApiConfigMeta || [])?.map((config) => (
<option
key={config.name}
value={config.name}
style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}
>
color: "var(--vscode-dropdown-foreground)",
}}>
{config.name}
</option>
))}
@@ -779,19 +784,23 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
</div>
<div style={{
display: "flex",
alignItems: "center",
gap: "12px"
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
}}>
<div style={{ display: "flex", alignItems: "center" }}>
{isEnhancingPrompt ? (
<span className="codicon codicon-loading codicon-modifier-spin" style={{
color: "var(--vscode-input-foreground)",
opacity: 0.5,
fontSize: 16.5,
marginRight: 10
}} />
<span
className="codicon codicon-loading codicon-modifier-spin"
style={{
color: "var(--vscode-input-foreground)",
opacity: 0.5,
fontSize: 16.5,
marginRight: 10,
}}
/>
) : (
<span
role="button"
@@ -803,12 +812,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
)}
</div>
<span
<span
className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`}
onClick={() => !shouldDisableImages && onSelectImages()}
style={{ fontSize: 16.5 }}
/>
<span
<span
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
onClick={() => !textAreaDisabled && onSend()}
style={{ fontSize: 15 }}

View File

@@ -39,7 +39,23 @@ interface ChatViewProps {
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands, writeDelayMs, mode, setMode, autoApprovalEnabled } = useExtensionState()
const {
version,
clineMessages: messages,
taskHistory,
apiConfiguration,
mcpServers,
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowMcp,
allowedCommands,
writeDelayMs,
mode,
setMode,
autoApprovalEnabled,
} = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
@@ -178,7 +194,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setEnableButtons(true)
setPrimaryButtonText("Resume Task")
setSecondaryButtonText("Terminate")
setDidClickCancel(false) // special case where we reset the cancel button state
setDidClickCancel(false) // special case where we reset the cancel button state
break
case "resume_completed_task":
setTextAreaDisabled(false)
@@ -490,7 +506,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
return true
}
const tool = JSON.parse(message.text)
return ["readFile", "listFiles", "listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool)
return [
"readFile",
"listFiles",
"listFilesTopLevel",
"listFilesRecursive",
"listCodeDefinitionNames",
"searchFiles",
].includes(tool.tool)
}
return false
}, [])
@@ -506,26 +529,32 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
return false
}, [])
const isMcpToolAlwaysAllowed = useCallback((message: ClineMessage | undefined) => {
if (message?.type === "ask" && message.ask === "use_mcp_server") {
if (!message.text) {
return true
const isMcpToolAlwaysAllowed = useCallback(
(message: ClineMessage | undefined) => {
if (message?.type === "ask" && message.ask === "use_mcp_server") {
if (!message.text) {
return true
}
const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string }
if (mcpServerUse.type === "use_mcp_tool") {
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
return tool?.alwaysAllow || false
}
}
const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string }
if (mcpServerUse.type === "use_mcp_tool") {
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
return tool?.alwaysAllow || false
}
}
return false
}, [mcpServers])
return false
},
[mcpServers],
)
// Check if a command message is allowed
const isAllowedCommand = useCallback((message: ClineMessage | undefined): boolean => {
if (message?.type !== "ask") return false
return validateCommand(message.text || '', allowedCommands || [])
}, [allowedCommands])
const isAllowedCommand = useCallback(
(message: ClineMessage | undefined): boolean => {
if (message?.type !== "ask") return false
return validateCommand(message.text || "", allowedCommands || [])
},
[allowedCommands],
)
const isAutoApproved = useCallback(
(message: ClineMessage | undefined) => {
@@ -539,7 +568,18 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message))
)
},
[autoApprovalEnabled, alwaysAllowBrowser, alwaysAllowReadOnly, isReadOnlyToolAction, alwaysAllowWrite, isWriteToolAction, alwaysAllowExecute, isAllowedCommand, alwaysAllowMcp, isMcpToolAlwaysAllowed]
[
autoApprovalEnabled,
alwaysAllowBrowser,
alwaysAllowReadOnly,
isReadOnlyToolAction,
alwaysAllowWrite,
isWriteToolAction,
alwaysAllowExecute,
isAllowedCommand,
alwaysAllowMcp,
isMcpToolAlwaysAllowed,
],
)
useEffect(() => {
@@ -812,7 +852,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
/>
)
},
[expandedRows, modifiedMessages, groupedMessages.length, handleRowHeightChange, isStreaming, toggleRowExpansion],
[
expandedRows,
modifiedMessages,
groupedMessages.length,
handleRowHeightChange,
isStreaming,
toggleRowExpansion,
],
)
useEffect(() => {
@@ -823,13 +870,29 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
if (isAutoApproved(lastMessage)) {
// Add delay for write operations
if (lastMessage?.ask === "tool" && isWriteToolAction(lastMessage)) {
await new Promise(resolve => setTimeout(resolve, writeDelayMs))
await new Promise((resolve) => setTimeout(resolve, writeDelayMs))
}
handlePrimaryButtonClick()
}
}
autoApprove()
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, messages, allowedCommands, mcpServers, isAutoApproved, lastMessage, writeDelayMs, isWriteToolAction])
}, [
clineAsk,
enableButtons,
handlePrimaryButtonClick,
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowMcp,
messages,
allowedCommands,
mcpServers,
isAutoApproved,
lastMessage,
writeDelayMs,
isWriteToolAction,
])
return (
<div
@@ -868,11 +931,11 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
<div style={{ padding: "0 20px", flexShrink: 0 }}>
<h2>What can I do for you?</h2>
<p>
Thanks to the latest breakthroughs in agentic coding capabilities,
I can handle complex software development tasks step-by-step. With tools that let me create
& edit files, explore complex projects, use the browser, and execute terminal commands
(after you grant permission), I can assist you in ways that go beyond code completion or
tech support. I can even use MCP to create new tools and extend my own capabilities.
Thanks to the latest breakthroughs in agentic coding capabilities, I can handle complex
software development tasks step-by-step. With tools that let me create & edit files, explore
complex projects, use the browser, and execute terminal commands (after you grant
permission), I can assist you in ways that go beyond code completion or tech support. I can
even use MCP to create new tools and extend my own capabilities.
</p>
</div>
{taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}

View File

@@ -55,16 +55,17 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
case ContextMenuOptionType.Git:
if (option.value) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<span style={{ lineHeight: '1.2' }}>{option.label}</span>
<span style={{
fontSize: '0.85em',
opacity: 0.7,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '1.2'
}}>
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
<span style={{ lineHeight: "1.2" }}>{option.label}</span>
<span
style={{
fontSize: "0.85em",
opacity: 0.7,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
lineHeight: "1.2",
}}>
{option.description}
</span>
</div>
@@ -168,33 +169,33 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
flex: 1,
minWidth: 0,
overflow: "hidden",
paddingTop: 0
paddingTop: 0,
}}>
<i
className={`codicon codicon-${getIconForOption(option)}`}
style={{
style={{
marginRight: "6px",
flexShrink: 0,
fontSize: "14px",
marginTop: 0
marginTop: 0,
}}
/>
{renderOptionContent(option)}
</div>
{((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
!option.value) && (
<i
className="codicon codicon-chevron-right"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
/>
)}
{(option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
!option.value && (
<i
className="codicon codicon-chevron-right"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
/>
)}
{(option.type === ContextMenuOptionType.Problems ||
((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
option.value)) && (
((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
option.value)) && (
<i
className="codicon codicon-add"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}

View File

@@ -9,190 +9,190 @@ jest.mock("../../../context/ExtensionStateContext")
const mockUseExtensionState = useExtensionState as jest.MockedFunction<typeof useExtensionState>
describe("AutoApproveMenu", () => {
const defaultMockState = {
// Required state properties
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
soundEnabled: false,
soundVolume: 0.5,
diffEnabled: false,
fuzzyMatchThreshold: 1.0,
preferredLanguage: "English",
writeDelayMs: 1000,
browserViewportSize: "900x600",
screenshotQuality: 75,
terminalOutputLineLimit: 500,
mcpEnabled: true,
requestDelaySeconds: 5,
currentApiConfigName: "default",
listApiConfigMeta: [],
mode: codeMode,
customPrompts: defaultPrompts,
enhancementApiConfigId: "",
didHydrateState: true,
showWelcome: false,
theme: {},
glamaModels: {},
openRouterModels: {},
openAiModels: [],
mcpServers: [],
filePaths: [],
const defaultMockState = {
// Required state properties
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
soundEnabled: false,
soundVolume: 0.5,
diffEnabled: false,
fuzzyMatchThreshold: 1.0,
preferredLanguage: "English",
writeDelayMs: 1000,
browserViewportSize: "900x600",
screenshotQuality: 75,
terminalOutputLineLimit: 500,
mcpEnabled: true,
requestDelaySeconds: 5,
currentApiConfigName: "default",
listApiConfigMeta: [],
mode: codeMode,
customPrompts: defaultPrompts,
enhancementApiConfigId: "",
didHydrateState: true,
showWelcome: false,
theme: {},
glamaModels: {},
openRouterModels: {},
openAiModels: [],
mcpServers: [],
filePaths: [],
// Auto-approve specific properties
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowMcp: false,
alwaysApproveResubmit: false,
autoApprovalEnabled: false,
// Auto-approve specific properties
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowMcp: false,
alwaysApproveResubmit: false,
autoApprovalEnabled: false,
// Required setter functions
setApiConfiguration: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowReadOnly: jest.fn(),
setAlwaysAllowWrite: jest.fn(),
setAlwaysAllowExecute: jest.fn(),
setAlwaysAllowBrowser: jest.fn(),
setAlwaysAllowMcp: jest.fn(),
setShowAnnouncement: jest.fn(),
setAllowedCommands: jest.fn(),
setSoundEnabled: jest.fn(),
setSoundVolume: jest.fn(),
setDiffEnabled: jest.fn(),
setBrowserViewportSize: jest.fn(),
setFuzzyMatchThreshold: jest.fn(),
setPreferredLanguage: jest.fn(),
setWriteDelayMs: jest.fn(),
setScreenshotQuality: jest.fn(),
setTerminalOutputLineLimit: jest.fn(),
setMcpEnabled: jest.fn(),
setAlwaysApproveResubmit: jest.fn(),
setRequestDelaySeconds: jest.fn(),
setCurrentApiConfigName: jest.fn(),
setListApiConfigMeta: jest.fn(),
onUpdateApiConfig: jest.fn(),
setMode: jest.fn(),
setCustomPrompts: jest.fn(),
setEnhancementApiConfigId: jest.fn(),
setAutoApprovalEnabled: jest.fn(),
}
// Required setter functions
setApiConfiguration: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowReadOnly: jest.fn(),
setAlwaysAllowWrite: jest.fn(),
setAlwaysAllowExecute: jest.fn(),
setAlwaysAllowBrowser: jest.fn(),
setAlwaysAllowMcp: jest.fn(),
setShowAnnouncement: jest.fn(),
setAllowedCommands: jest.fn(),
setSoundEnabled: jest.fn(),
setSoundVolume: jest.fn(),
setDiffEnabled: jest.fn(),
setBrowserViewportSize: jest.fn(),
setFuzzyMatchThreshold: jest.fn(),
setPreferredLanguage: jest.fn(),
setWriteDelayMs: jest.fn(),
setScreenshotQuality: jest.fn(),
setTerminalOutputLineLimit: jest.fn(),
setMcpEnabled: jest.fn(),
setAlwaysApproveResubmit: jest.fn(),
setRequestDelaySeconds: jest.fn(),
setCurrentApiConfigName: jest.fn(),
setListApiConfigMeta: jest.fn(),
onUpdateApiConfig: jest.fn(),
setMode: jest.fn(),
setCustomPrompts: jest.fn(),
setEnhancementApiConfigId: jest.fn(),
setAutoApprovalEnabled: jest.fn(),
}
beforeEach(() => {
mockUseExtensionState.mockReturnValue(defaultMockState)
})
beforeEach(() => {
mockUseExtensionState.mockReturnValue(defaultMockState)
})
afterEach(() => {
jest.clearAllMocks()
})
afterEach(() => {
jest.clearAllMocks()
})
it("renders with initial collapsed state", () => {
render(<AutoApproveMenu />)
// Check for main checkbox and label
expect(screen.getByText("Auto-approve:")).toBeInTheDocument()
expect(screen.getByText("None")).toBeInTheDocument()
// Verify the menu is collapsed (actions not visible)
expect(screen.queryByText("Read files and directories")).not.toBeInTheDocument()
})
it("renders with initial collapsed state", () => {
render(<AutoApproveMenu />)
it("expands menu when clicked", () => {
render(<AutoApproveMenu />)
// Click to expand
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify menu items are visible
expect(screen.getByText("Read files and directories")).toBeInTheDocument()
expect(screen.getByText("Edit files")).toBeInTheDocument()
expect(screen.getByText("Execute approved commands")).toBeInTheDocument()
expect(screen.getByText("Use the browser")).toBeInTheDocument()
expect(screen.getByText("Use MCP servers")).toBeInTheDocument()
expect(screen.getByText("Retry failed requests")).toBeInTheDocument()
})
// Check for main checkbox and label
expect(screen.getByText("Auto-approve:")).toBeInTheDocument()
expect(screen.getByText("None")).toBeInTheDocument()
it("toggles main auto-approval checkbox", () => {
render(<AutoApproveMenu />)
const mainCheckbox = screen.getByRole("checkbox")
fireEvent.click(mainCheckbox)
expect(defaultMockState.setAutoApprovalEnabled).toHaveBeenCalledWith(true)
})
// Verify the menu is collapsed (actions not visible)
expect(screen.queryByText("Read files and directories")).not.toBeInTheDocument()
})
it("toggles individual permissions", () => {
render(<AutoApproveMenu />)
// Expand menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Click read files checkbox
fireEvent.click(screen.getByText("Read files and directories"))
expect(defaultMockState.setAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
// Click edit files checkbox
fireEvent.click(screen.getByText("Edit files"))
expect(defaultMockState.setAlwaysAllowWrite).toHaveBeenCalledWith(true)
// Click execute commands checkbox
fireEvent.click(screen.getByText("Execute approved commands"))
expect(defaultMockState.setAlwaysAllowExecute).toHaveBeenCalledWith(true)
})
it("expands menu when clicked", () => {
render(<AutoApproveMenu />)
it("displays enabled actions in summary", () => {
mockUseExtensionState.mockReturnValue({
...defaultMockState,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
autoApprovalEnabled: true,
})
render(<AutoApproveMenu />)
// Check that enabled actions are shown in summary
expect(screen.getByText("Read, Edit")).toBeInTheDocument()
})
// Click to expand
fireEvent.click(screen.getByText("Auto-approve:"))
it("preserves checkbox states", () => {
// Mock state with some permissions enabled
const mockState = {
...defaultMockState,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
}
// Update mock to return our state
mockUseExtensionState.mockReturnValue(mockState)
render(<AutoApproveMenu />)
// Expand menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify read and edit checkboxes are checked
expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
// Verify the setters haven't been called yet
expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
// Collapse menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Expand again
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify checkboxes are still present
expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
// Verify the setters still haven't been called
expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
})
})
// Verify menu items are visible
expect(screen.getByText("Read files and directories")).toBeInTheDocument()
expect(screen.getByText("Edit files")).toBeInTheDocument()
expect(screen.getByText("Execute approved commands")).toBeInTheDocument()
expect(screen.getByText("Use the browser")).toBeInTheDocument()
expect(screen.getByText("Use MCP servers")).toBeInTheDocument()
expect(screen.getByText("Retry failed requests")).toBeInTheDocument()
})
it("toggles main auto-approval checkbox", () => {
render(<AutoApproveMenu />)
const mainCheckbox = screen.getByRole("checkbox")
fireEvent.click(mainCheckbox)
expect(defaultMockState.setAutoApprovalEnabled).toHaveBeenCalledWith(true)
})
it("toggles individual permissions", () => {
render(<AutoApproveMenu />)
// Expand menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Click read files checkbox
fireEvent.click(screen.getByText("Read files and directories"))
expect(defaultMockState.setAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
// Click edit files checkbox
fireEvent.click(screen.getByText("Edit files"))
expect(defaultMockState.setAlwaysAllowWrite).toHaveBeenCalledWith(true)
// Click execute commands checkbox
fireEvent.click(screen.getByText("Execute approved commands"))
expect(defaultMockState.setAlwaysAllowExecute).toHaveBeenCalledWith(true)
})
it("displays enabled actions in summary", () => {
mockUseExtensionState.mockReturnValue({
...defaultMockState,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
autoApprovalEnabled: true,
})
render(<AutoApproveMenu />)
// Check that enabled actions are shown in summary
expect(screen.getByText("Read, Edit")).toBeInTheDocument()
})
it("preserves checkbox states", () => {
// Mock state with some permissions enabled
const mockState = {
...defaultMockState,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
}
// Update mock to return our state
mockUseExtensionState.mockReturnValue(mockState)
render(<AutoApproveMenu />)
// Expand menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify read and edit checkboxes are checked
expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
// Verify the setters haven't been called yet
expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
// Collapse menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Expand again
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify checkboxes are still present
expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
// Verify the setters still haven't been called
expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
})
})

View File

@@ -1,159 +1,159 @@
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ChatTextArea from '../ChatTextArea';
import { useExtensionState } from '../../../context/ExtensionStateContext';
import { vscode } from '../../../utils/vscode';
import { defaultModeSlug } from '../../../../../src/shared/modes';
import { render, fireEvent, screen } from "@testing-library/react"
import "@testing-library/jest-dom"
import ChatTextArea from "../ChatTextArea"
import { useExtensionState } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
import { defaultModeSlug } from "../../../../../src/shared/modes"
// Mock modules
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn()
}
}));
jest.mock('../../../components/common/CodeBlock');
jest.mock('../../../components/common/MarkdownBlock');
jest.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
jest.mock("../../../components/common/CodeBlock")
jest.mock("../../../components/common/MarkdownBlock")
// Get the mocked postMessage function
const mockPostMessage = vscode.postMessage as jest.Mock;
const mockPostMessage = vscode.postMessage as jest.Mock
/* eslint-enable import/first */
// Mock ExtensionStateContext
jest.mock('../../../context/ExtensionStateContext');
jest.mock("../../../context/ExtensionStateContext")
describe('ChatTextArea', () => {
const defaultProps = {
inputValue: '',
setInputValue: jest.fn(),
onSend: jest.fn(),
textAreaDisabled: false,
onSelectImages: jest.fn(),
shouldDisableImages: false,
placeholderText: 'Type a message...',
selectedImages: [],
setSelectedImages: jest.fn(),
onHeightChange: jest.fn(),
mode: defaultModeSlug,
setMode: jest.fn(),
};
describe("ChatTextArea", () => {
const defaultProps = {
inputValue: "",
setInputValue: jest.fn(),
onSend: jest.fn(),
textAreaDisabled: false,
onSelectImages: jest.fn(),
shouldDisableImages: false,
placeholderText: "Type a message...",
selectedImages: [],
setSelectedImages: jest.fn(),
onHeightChange: jest.fn(),
mode: defaultModeSlug,
setMode: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementation for useExtensionState
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'anthropic',
},
});
});
beforeEach(() => {
jest.clearAllMocks()
// Default mock implementation for useExtensionState
;(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: "anthropic",
},
})
})
describe('enhance prompt button', () => {
it('should be disabled when textAreaDisabled is true', () => {
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
});
describe("enhance prompt button", () => {
it("should be disabled when textAreaDisabled is true", () => {
;(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
})
render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />);
const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i });
expect(enhanceButton).toHaveClass('disabled');
});
});
render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />)
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
expect(enhanceButton).toHaveClass("disabled")
})
})
describe('handleEnhancePrompt', () => {
it('should send message with correct configuration when clicked', () => {
const apiConfiguration = {
apiProvider: 'openrouter',
apiKey: 'test-key',
};
describe("handleEnhancePrompt", () => {
it("should send message with correct configuration when clicked", () => {
const apiConfiguration = {
apiProvider: "openrouter",
apiKey: "test-key",
}
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration,
});
;(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration,
})
render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />);
const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i });
fireEvent.click(enhanceButton);
render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
expect(mockPostMessage).toHaveBeenCalledWith({
type: 'enhancePrompt',
text: 'Test prompt',
});
});
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
fireEvent.click(enhanceButton)
it('should not send message when input is empty', () => {
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'openrouter',
},
});
expect(mockPostMessage).toHaveBeenCalledWith({
type: "enhancePrompt",
text: "Test prompt",
})
})
render(<ChatTextArea {...defaultProps} inputValue="" />);
const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i });
fireEvent.click(enhanceButton);
it("should not send message when input is empty", () => {
;(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: "openrouter",
},
})
expect(mockPostMessage).not.toHaveBeenCalled();
});
render(<ChatTextArea {...defaultProps} inputValue="" />)
it('should show loading state while enhancing', () => {
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'openrouter',
},
});
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
fireEvent.click(enhanceButton)
render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />);
const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i });
fireEvent.click(enhanceButton);
expect(mockPostMessage).not.toHaveBeenCalled()
})
const loadingSpinner = screen.getByText('', { selector: '.codicon-loading' });
expect(loadingSpinner).toBeInTheDocument();
});
});
it("should show loading state while enhancing", () => {
;(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: "openrouter",
},
})
describe('effect dependencies', () => {
it('should update when apiConfiguration changes', () => {
const { rerender } = render(<ChatTextArea {...defaultProps} />);
render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
// Update apiConfiguration
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'openrouter',
newSetting: 'test',
},
});
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
fireEvent.click(enhanceButton)
rerender(<ChatTextArea {...defaultProps} />);
// Verify the enhance button appears after apiConfiguration changes
expect(screen.getByRole('button', { name: /enhance prompt/i })).toBeInTheDocument();
});
});
const loadingSpinner = screen.getByText("", { selector: ".codicon-loading" })
expect(loadingSpinner).toBeInTheDocument()
})
})
describe('enhanced prompt response', () => {
it('should update input value when receiving enhanced prompt', () => {
const setInputValue = jest.fn();
render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} />);
describe("effect dependencies", () => {
it("should update when apiConfiguration changes", () => {
const { rerender } = render(<ChatTextArea {...defaultProps} />)
// Simulate receiving enhanced prompt message
window.dispatchEvent(
new MessageEvent('message', {
data: {
type: 'enhancedPrompt',
text: 'Enhanced test prompt',
},
})
);
// Update apiConfiguration
;(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: "openrouter",
newSetting: "test",
},
})
expect(setInputValue).toHaveBeenCalledWith('Enhanced test prompt');
});
});
});
rerender(<ChatTextArea {...defaultProps} />)
// Verify the enhance button appears after apiConfiguration changes
expect(screen.getByRole("button", { name: /enhance prompt/i })).toBeInTheDocument()
})
})
describe("enhanced prompt response", () => {
it("should update input value when receiving enhanced prompt", () => {
const setInputValue = jest.fn()
render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} />)
// Simulate receiving enhanced prompt message
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "enhancedPrompt",
text: "Enhanced test prompt",
},
}),
)
expect(setInputValue).toHaveBeenCalledWith("Enhanced test prompt")
})
})
})

View File

@@ -1,313 +1,316 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import ChatView from '../ChatView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import React from "react"
import { render, waitFor } from "@testing-library/react"
import ChatView from "../ChatView"
import { ExtensionStateContextProvider } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn(),
},
jest.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
// Mock all problematic dependencies
jest.mock('rehype-highlight', () => ({
__esModule: true,
default: () => () => {},
jest.mock("rehype-highlight", () => ({
__esModule: true,
default: () => () => {},
}))
jest.mock('hast-util-to-text', () => ({
__esModule: true,
default: () => '',
jest.mock("hast-util-to-text", () => ({
__esModule: true,
default: () => "",
}))
// Mock components that use ESM dependencies
jest.mock('../BrowserSessionRow', () => ({
__esModule: true,
default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
}
jest.mock("../BrowserSessionRow", () => ({
__esModule: true,
default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
},
}))
jest.mock('../ChatRow', () => ({
__esModule: true,
default: function MockChatRow({ message }: { message: any }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
}
jest.mock("../ChatRow", () => ({
__esModule: true,
default: function MockChatRow({ message }: { message: any }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
},
}))
jest.mock('../TaskHeader', () => ({
__esModule: true,
default: function MockTaskHeader({ task }: { task: any }) {
return <div data-testid="task-header">{JSON.stringify(task)}</div>
}
jest.mock("../TaskHeader", () => ({
__esModule: true,
default: function MockTaskHeader({ task }: { task: any }) {
return <div data-testid="task-header">{JSON.stringify(task)}</div>
},
}))
jest.mock('../AutoApproveMenu', () => ({
__esModule: true,
default: () => null,
jest.mock("../AutoApproveMenu", () => ({
__esModule: true,
default: () => null,
}))
jest.mock('../../common/CodeBlock', () => ({
__esModule: true,
default: () => null,
CODE_BLOCK_BG_COLOR: 'rgb(30, 30, 30)',
jest.mock("../../common/CodeBlock", () => ({
__esModule: true,
default: () => null,
CODE_BLOCK_BG_COLOR: "rgb(30, 30, 30)",
}))
jest.mock('../../common/CodeAccordian', () => ({
__esModule: true,
default: () => null,
jest.mock("../../common/CodeAccordian", () => ({
__esModule: true,
default: () => null,
}))
jest.mock('../ContextMenu', () => ({
__esModule: true,
default: () => null,
jest.mock("../ContextMenu", () => ({
__esModule: true,
default: () => null,
}))
// 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: true,
...state
}
}, '*')
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
autoApprovalEnabled: true,
...state,
},
},
"*",
)
}
describe('ChatView - Auto Approval Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("ChatView - Auto Approval Tests", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('auto-approves read operations when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
it("auto-approves read operations when enabled", async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>,
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }),
partial: false
}
]
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it('does not auto-approve when autoApprovalEnabled is false', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
it("does not auto-approve when autoApprovalEnabled is false", async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>,
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }),
partial: false
}
]
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: false,
},
],
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
it('auto-approves write operations when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
it("auto-approves write operations when enabled", async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>,
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the write tool ask message
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }),
partial: false
}
]
})
// Then send the write tool ask message
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "editedExistingFile", path: "test.txt" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
it('auto-approves browser actions when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
it("auto-approves browser actions when enabled", async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>,
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// First hydrate state with initial task
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
],
})
// Then send the browser action ask message
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Then send the browser action ask message
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
partial: false,
},
],
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,15 @@
import React from 'react'
import React from "react"
export const CaretIcon = () => (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
)
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
)

View File

@@ -1,12 +1,10 @@
import * as React from 'react';
import * as React from "react"
interface CodeBlockProps {
children?: React.ReactNode;
language?: string;
children?: React.ReactNode
language?: string
}
const CodeBlock: React.FC<CodeBlockProps> = () => (
<div data-testid="mock-code-block">Mocked Code Block</div>
);
const CodeBlock: React.FC<CodeBlockProps> = () => <div data-testid="mock-code-block">Mocked Code Block</div>
export default CodeBlock;
export default CodeBlock

View File

@@ -1,12 +1,12 @@
import * as React from 'react';
import * as React from "react"
interface MarkdownBlockProps {
children?: React.ReactNode;
content?: string;
children?: React.ReactNode
content?: string
}
const MarkdownBlock: React.FC<MarkdownBlockProps> = ({ content }) => (
<div data-testid="mock-markdown-block">{content}</div>
);
<div data-testid="mock-markdown-block">{content}</div>
)
export default MarkdownBlock;
export default MarkdownBlock

View File

@@ -45,7 +45,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
setShowCopyModal(true)
setTimeout(() => setShowCopyModal(false), 2000)
} catch (error) {
console.error('Failed to copy to clipboard:', error)
console.error("Failed to copy to clipboard:", error)
}
}
@@ -70,7 +70,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
const fzf = useMemo(() => {
return new Fzf(presentableTasks, {
selector: item => item.task
selector: (item) => item.task,
})
}, [presentableTasks])
@@ -78,34 +78,34 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
let results = presentableTasks
if (searchQuery) {
const searchResults = fzf.find(searchQuery)
results = searchResults.map(result => ({
results = searchResults.map((result) => ({
...result.item,
task: highlightFzfMatch(result.item.task, Array.from(result.positions))
task: highlightFzfMatch(result.item.task, Array.from(result.positions)),
}))
}
// First apply search if needed
const searchResults = searchQuery ? results : presentableTasks;
const searchResults = searchQuery ? results : presentableTasks
// Then sort the results
return [...searchResults].sort((a, b) => {
switch (sortOption) {
case "oldest":
return (a.ts || 0) - (b.ts || 0);
return (a.ts || 0) - (b.ts || 0)
case "mostExpensive":
return (b.totalCost || 0) - (a.totalCost || 0);
return (b.totalCost || 0) - (a.totalCost || 0)
case "mostTokens":
const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0);
const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0);
return bTokens - aTokens;
const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0)
const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0)
return bTokens - aTokens
case "mostRelevant":
// Keep fuse order if searching, otherwise sort by newest
return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0);
return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0)
case "newest":
default:
return (b.ts || 0) - (a.ts || 0);
return (b.ts || 0) - (a.ts || 0)
}
});
})
}, [presentableTasks, searchQuery, fzf, sortOption])
return (
@@ -144,11 +144,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}
`}
</style>
{showCopyModal && (
<div className="copy-modal">
Prompt Copied to Clipboard
</div>
)}
{showCopyModal && <div className="copy-modal">Prompt Copied to Clipboard</div>}
<div
style={{
position: "fixed",
@@ -231,7 +227,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
components={{
List: React.forwardRef((props, ref) => (
<div {...props} ref={ref} data-testid="virtuoso-item-list" />
))
)),
}}
itemContent={(index, item) => (
<div
@@ -271,21 +267,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</span>
<div style={{ display: "flex", gap: "4px" }}>
<button
title="Copy Prompt"
className="copy-button"
data-appearance="icon"
onClick={(e) => handleCopyTask(e, item.task)}>
<span className="codicon codicon-copy"></span>
title="Copy Prompt"
className="copy-button"
data-appearance="icon"
onClick={(e) => handleCopyTask(e, item.task)}>
<span className="codicon codicon-copy"></span>
</button>
<button
title="Delete Task"
className="delete-button"
data-appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}>
<span className="codicon codicon-trash"></span>
title="Delete Task"
className="delete-button"
data-appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}>
<span className="codicon codicon-trash"></span>
</button>
</div>
</div>

View File

@@ -1,232 +1,235 @@
import React from 'react'
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import HistoryView from '../HistoryView'
import { useExtensionState } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import React from "react"
import { render, screen, fireEvent, within, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import HistoryView from "../HistoryView"
import { useExtensionState } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
// Mock dependencies
jest.mock('../../../context/ExtensionStateContext')
jest.mock('../../../utils/vscode')
jest.mock('react-virtuoso', () => ({
Virtuoso: ({ data, itemContent }: any) => (
<div data-testid="virtuoso-container">
{data.map((item: any, index: number) => (
<div key={item.id} data-testid={`virtuoso-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
),
jest.mock("../../../context/ExtensionStateContext")
jest.mock("../../../utils/vscode")
jest.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent }: any) => (
<div data-testid="virtuoso-container">
{data.map((item: any, index: number) => (
<div key={item.id} data-testid={`virtuoso-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
),
}))
const mockTaskHistory = [
{
id: '1',
task: 'Test task 1',
ts: new Date('2022-02-16T00:00:00').getTime(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
},
{
id: '2',
task: 'Test task 2',
ts: new Date('2022-02-17T00:00:00').getTime(),
tokensIn: 200,
tokensOut: 100,
cacheWrites: 50,
cacheReads: 25,
},
{
id: "1",
task: "Test task 1",
ts: new Date("2022-02-16T00:00:00").getTime(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
},
{
id: "2",
task: "Test task 2",
ts: new Date("2022-02-17T00:00:00").getTime(),
tokensIn: 200,
tokensOut: 100,
cacheWrites: 50,
cacheReads: 25,
},
]
describe('HistoryView', () => {
beforeEach(() => {
// Reset all mocks before each test
jest.clearAllMocks()
jest.useFakeTimers()
// Mock useExtensionState implementation
;(useExtensionState as jest.Mock).mockReturnValue({
taskHistory: mockTaskHistory,
})
})
describe("HistoryView", () => {
beforeEach(() => {
// Reset all mocks before each test
jest.clearAllMocks()
jest.useFakeTimers()
afterEach(() => {
jest.useRealTimers()
})
// Mock useExtensionState implementation
;(useExtensionState as jest.Mock).mockReturnValue({
taskHistory: mockTaskHistory,
})
})
it('renders history items correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
afterEach(() => {
jest.useRealTimers()
})
// Check if both tasks are rendered
expect(screen.getByTestId('virtuoso-item-1')).toBeInTheDocument()
expect(screen.getByTestId('virtuoso-item-2')).toBeInTheDocument()
expect(screen.getByText('Test task 1')).toBeInTheDocument()
expect(screen.getByText('Test task 2')).toBeInTheDocument()
})
it("renders history items correctly", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
it('handles search functionality', async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Check if both tasks are rendered
expect(screen.getByTestId("virtuoso-item-1")).toBeInTheDocument()
expect(screen.getByTestId("virtuoso-item-2")).toBeInTheDocument()
expect(screen.getByText("Test task 1")).toBeInTheDocument()
expect(screen.getByText("Test task 2")).toBeInTheDocument()
})
// Get search input and radio group
const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
const radioGroup = screen.getByRole('radiogroup')
// Type in search
await userEvent.type(searchInput, 'task 1')
it("handles search functionality", async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Check if sort option automatically changes to "Most Relevant"
const mostRelevantRadio = within(radioGroup).getByLabelText('Most Relevant')
expect(mostRelevantRadio).not.toBeDisabled()
// Click and wait for radio update
fireEvent.click(mostRelevantRadio)
// Get search input and radio group
const searchInput = screen.getByPlaceholderText("Fuzzy search history...")
const radioGroup = screen.getByRole("radiogroup")
// Wait for radio button to be checked
const updatedRadio = await within(radioGroup).findByRole('radio', { name: 'Most Relevant', checked: true })
expect(updatedRadio).toBeInTheDocument()
})
// Type in search
await userEvent.type(searchInput, "task 1")
it('handles sort options correctly', async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Check if sort option automatically changes to "Most Relevant"
const mostRelevantRadio = within(radioGroup).getByLabelText("Most Relevant")
expect(mostRelevantRadio).not.toBeDisabled()
const radioGroup = screen.getByRole('radiogroup')
// Click and wait for radio update
fireEvent.click(mostRelevantRadio)
// Test changing sort options
const oldestRadio = within(radioGroup).getByLabelText('Oldest')
fireEvent.click(oldestRadio)
// Wait for oldest radio to be checked
const checkedOldestRadio = await within(radioGroup).findByRole('radio', { name: 'Oldest', checked: true })
expect(checkedOldestRadio).toBeInTheDocument()
// Wait for radio button to be checked
const updatedRadio = await within(radioGroup).findByRole("radio", { name: "Most Relevant", checked: true })
expect(updatedRadio).toBeInTheDocument()
})
const mostExpensiveRadio = within(radioGroup).getByLabelText('Most Expensive')
fireEvent.click(mostExpensiveRadio)
// Wait for most expensive radio to be checked
const checkedExpensiveRadio = await within(radioGroup).findByRole('radio', { name: 'Most Expensive', checked: true })
expect(checkedExpensiveRadio).toBeInTheDocument()
})
it("handles sort options correctly", async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
it('handles task selection', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
const radioGroup = screen.getByRole("radiogroup")
// Click on first task
fireEvent.click(screen.getByText('Test task 1'))
// Test changing sort options
const oldestRadio = within(radioGroup).getByLabelText("Oldest")
fireEvent.click(oldestRadio)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'showTaskWithId',
text: '1',
})
})
// Wait for oldest radio to be checked
const checkedOldestRadio = await within(radioGroup).findByRole("radio", { name: "Oldest", checked: true })
expect(checkedOldestRadio).toBeInTheDocument()
it('handles task deletion', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
const mostExpensiveRadio = within(radioGroup).getByLabelText("Most Expensive")
fireEvent.click(mostExpensiveRadio)
// Find and hover over first task
const taskContainer = screen.getByTestId('virtuoso-item-1')
fireEvent.mouseEnter(taskContainer)
const deleteButton = within(taskContainer).getByTitle('Delete Task')
fireEvent.click(deleteButton)
// Wait for most expensive radio to be checked
const checkedExpensiveRadio = await within(radioGroup).findByRole("radio", {
name: "Most Expensive",
checked: true,
})
expect(checkedExpensiveRadio).toBeInTheDocument()
})
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'deleteTaskWithId',
text: '1',
})
})
it("handles task selection", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
it('handles task copying', async () => {
const mockClipboard = {
writeText: jest.fn().mockResolvedValue(undefined),
}
Object.assign(navigator, { clipboard: mockClipboard })
// Click on first task
fireEvent.click(screen.getByText("Test task 1"))
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "showTaskWithId",
text: "1",
})
})
// Find and hover over first task
const taskContainer = screen.getByTestId('virtuoso-item-1')
fireEvent.mouseEnter(taskContainer)
const copyButton = within(taskContainer).getByTitle('Copy Prompt')
await userEvent.click(copyButton)
it("handles task deletion", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Verify clipboard API was called
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test task 1')
// Wait for copy modal to appear
const copyModal = await screen.findByText('Prompt Copied to Clipboard')
expect(copyModal).toBeInTheDocument()
// Find and hover over first task
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
// Fast-forward timers and wait for modal to disappear
jest.advanceTimersByTime(2000)
await waitFor(() => {
expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
})
})
const deleteButton = within(taskContainer).getByTitle("Delete Task")
fireEvent.click(deleteButton)
it('formats dates correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: "1",
})
})
// Find first task container and check date format
const taskContainer = screen.getByTestId('virtuoso-item-1')
const dateElement = within(taskContainer).getByText((content) => {
return content.includes('FEBRUARY 16') && content.includes('12:00 AM')
})
expect(dateElement).toBeInTheDocument()
})
it("handles task copying", async () => {
const mockClipboard = {
writeText: jest.fn().mockResolvedValue(undefined),
}
Object.assign(navigator, { clipboard: mockClipboard })
it('displays token counts correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container
const taskContainer = screen.getByTestId('virtuoso-item-1')
// Find and hover over first task
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
// Find token counts within the task container
const tokensContainer = within(taskContainer).getByTestId('tokens-container')
expect(within(tokensContainer).getByTestId('tokens-in')).toHaveTextContent('100')
expect(within(tokensContainer).getByTestId('tokens-out')).toHaveTextContent('50')
})
const copyButton = within(taskContainer).getByTitle("Copy Prompt")
await userEvent.click(copyButton)
it('displays cache information when available', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Verify clipboard API was called
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Test task 1")
// Find second task container
const taskContainer = screen.getByTestId('virtuoso-item-2')
// Wait for copy modal to appear
const copyModal = await screen.findByText("Prompt Copied to Clipboard")
expect(copyModal).toBeInTheDocument()
// Find cache info within the task container
const cacheContainer = within(taskContainer).getByTestId('cache-container')
expect(within(cacheContainer).getByTestId('cache-writes')).toHaveTextContent('+50')
expect(within(cacheContainer).getByTestId('cache-reads')).toHaveTextContent('25')
})
// Fast-forward timers and wait for modal to disappear
jest.advanceTimersByTime(2000)
await waitFor(() => {
expect(screen.queryByText("Prompt Copied to Clipboard")).not.toBeInTheDocument()
})
})
it('handles export functionality', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
it("formats dates correctly", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over second task
const taskContainer = screen.getByTestId('virtuoso-item-2')
fireEvent.mouseEnter(taskContainer)
const exportButton = within(taskContainer).getByText('EXPORT')
fireEvent.click(exportButton)
// Find first task container and check date format
const taskContainer = screen.getByTestId("virtuoso-item-1")
const dateElement = within(taskContainer).getByText((content) => {
return content.includes("FEBRUARY 16") && content.includes("12:00 AM")
})
expect(dateElement).toBeInTheDocument()
})
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'exportTaskWithId',
text: '2',
})
})
})
it("displays token counts correctly", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container
const taskContainer = screen.getByTestId("virtuoso-item-1")
// Find token counts within the task container
const tokensContainer = within(taskContainer).getByTestId("tokens-container")
expect(within(tokensContainer).getByTestId("tokens-in")).toHaveTextContent("100")
expect(within(tokensContainer).getByTestId("tokens-out")).toHaveTextContent("50")
})
it("displays cache information when available", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find second task container
const taskContainer = screen.getByTestId("virtuoso-item-2")
// Find cache info within the task container
const cacheContainer = within(taskContainer).getByTestId("cache-container")
expect(within(cacheContainer).getByTestId("cache-writes")).toHaveTextContent("+50")
expect(within(cacheContainer).getByTestId("cache-reads")).toHaveTextContent("25")
})
it("handles export functionality", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over second task
const taskContainer = screen.getByTestId("virtuoso-item-2")
fireEvent.mouseEnter(taskContainer)
const exportButton = within(taskContainer).getByText("EXPORT")
fireEvent.click(exportButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "exportTaskWithId",
text: "2",
})
})
})

View File

@@ -7,7 +7,7 @@ const McpEnabledToggle = () => {
const { mcpEnabled, setMcpEnabled } = useExtensionState()
const handleChange = (e: Event | FormEvent<HTMLElement>) => {
const target = ('target' in e ? e.target : null) as HTMLInputElement | null
const target = ("target" in e ? e.target : null) as HTMLInputElement | null
if (!target) return
setMcpEnabled(target.checked)
vscode.postMessage({ type: "mcpEnabled", bool: target.checked })
@@ -15,20 +15,20 @@ const McpEnabledToggle = () => {
return (
<div style={{ marginBottom: "20px" }}>
<VSCodeCheckbox
checked={mcpEnabled}
onChange={handleChange}>
<VSCodeCheckbox checked={mcpEnabled} onChange={handleChange}>
<span style={{ fontWeight: "500" }}>Enable MCP Servers</span>
</VSCodeCheckbox>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will be able to interact with MCP servers for advanced functionality. If you're not using MCP, you can disable this to reduce Cline's token usage.
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will be able to interact with MCP servers for advanced functionality. If you're not
using MCP, you can disable this to reduce Cline's token usage.
</p>
</div>
)
}
export default McpEnabledToggle
export default McpEnabledToggle

View File

@@ -10,14 +10,14 @@ type McpToolRowProps = {
const McpToolRow = ({ tool, serverName, alwaysAllowMcp }: McpToolRowProps) => {
const handleAlwaysAllowChange = () => {
if (!serverName) return;
if (!serverName) return
vscode.postMessage({
type: "toggleToolAlwaysAllow",
serverName,
toolName: tool.name,
alwaysAllow: !tool.alwaysAllow
});
alwaysAllow: !tool.alwaysAllow,
})
}
return (
@@ -35,10 +35,7 @@ const McpToolRow = ({ tool, serverName, alwaysAllowMcp }: McpToolRowProps) => {
<span style={{ fontWeight: 500 }}>{tool.name}</span>
</div>
{serverName && alwaysAllowMcp && (
<VSCodeCheckbox
checked={tool.alwaysAllow}
onChange={handleAlwaysAllowChange}
data-tool={tool.name}>
<VSCodeCheckbox checked={tool.alwaysAllow} onChange={handleAlwaysAllowChange} data-tool={tool.name}>
Always allow
</VSCodeCheckbox>
)}

View File

@@ -159,7 +159,7 @@ const McpView = ({ onDone }: McpViewProps) => {
}
// Server Row Component
const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer, alwaysAllowMcp?: boolean }) => {
const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowMcp?: boolean }) => {
const [isExpanded, setIsExpanded] = useState(false)
const getStatusColor = () => {
@@ -216,9 +216,9 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer, alwaysAllowM
style={{
width: "20px",
height: "10px",
backgroundColor: server.disabled ?
"var(--vscode-titleBar-inactiveForeground)" :
"var(--vscode-button-background)",
backgroundColor: server.disabled
? "var(--vscode-titleBar-inactiveForeground)"
: "var(--vscode-button-background)",
borderRadius: "5px",
position: "relative",
cursor: "pointer",
@@ -229,30 +229,31 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer, alwaysAllowM
vscode.postMessage({
type: "toggleMcpServer",
serverName: server.name,
disabled: !server.disabled
});
disabled: !server.disabled,
})
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.preventDefault()
vscode.postMessage({
type: "toggleMcpServer",
serverName: server.name,
disabled: !server.disabled
});
disabled: !server.disabled,
})
}
}}
>
<div style={{
width: "6px",
height: "6px",
backgroundColor: "var(--vscode-titleBar-activeForeground)",
borderRadius: "50%",
position: "absolute",
top: "2px",
left: server.disabled ? "2px" : "12px",
transition: "left 0.2s",
}} />
}}>
<div
style={{
width: "6px",
height: "6px",
backgroundColor: "var(--vscode-titleBar-activeForeground)",
borderRadius: "50%",
position: "absolute",
top: "2px",
left: server.disabled ? "2px" : "12px",
transition: "left 0.2s",
}}
/>
</div>
</div>
<div

View File

@@ -1,132 +1,128 @@
import React from 'react'
import { render, fireEvent, screen } from '@testing-library/react'
import McpToolRow from '../McpToolRow'
import { vscode } from '../../../utils/vscode'
import React from "react"
import { render, fireEvent, screen } from "@testing-library/react"
import McpToolRow from "../McpToolRow"
import { vscode } from "../../../utils/vscode"
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn()
}
jest.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeCheckbox: function MockVSCodeCheckbox({
children,
checked,
onChange
}: {
children?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<label>
<input
type="checkbox"
checked={checked}
onChange={onChange}
/>
{children}
</label>
)
}
jest.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeCheckbox: function MockVSCodeCheckbox({
children,
checked,
onChange,
}: {
children?: React.ReactNode
checked?: boolean
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
}) {
return (
<label>
<input type="checkbox" checked={checked} onChange={onChange} />
{children}
</label>
)
},
}))
describe('McpToolRow', () => {
const mockTool = {
name: 'test-tool',
description: 'A test tool',
alwaysAllow: false
}
describe("McpToolRow", () => {
const mockTool = {
name: "test-tool",
description: "A test tool",
alwaysAllow: false,
}
beforeEach(() => {
jest.clearAllMocks()
})
beforeEach(() => {
jest.clearAllMocks()
})
it('renders tool name and description', () => {
render(<McpToolRow tool={mockTool} />)
expect(screen.getByText('test-tool')).toBeInTheDocument()
expect(screen.getByText('A test tool')).toBeInTheDocument()
})
it("renders tool name and description", () => {
render(<McpToolRow tool={mockTool} />)
it('does not show always allow checkbox when serverName is not provided', () => {
render(<McpToolRow tool={mockTool} />)
expect(screen.queryByText('Always allow')).not.toBeInTheDocument()
})
expect(screen.getByText("test-tool")).toBeInTheDocument()
expect(screen.getByText("A test tool")).toBeInTheDocument()
})
it('shows always allow checkbox when serverName and alwaysAllowMcp are provided', () => {
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
expect(screen.getByText('Always allow')).toBeInTheDocument()
})
it('sends message to toggle always allow when checkbox is clicked', () => {
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
const checkbox = screen.getByRole('checkbox')
fireEvent.click(checkbox)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'toggleToolAlwaysAllow',
serverName: 'test-server',
toolName: 'test-tool',
alwaysAllow: true
})
})
it('reflects always allow state in checkbox', () => {
const alwaysAllowedTool = {
...mockTool,
alwaysAllow: true
}
render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" alwaysAllowMcp={true} />)
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
expect(checkbox.checked).toBe(true)
})
it('prevents event propagation when clicking the checkbox', () => {
const mockOnClick = jest.fn()
render(
<div onClick={mockOnClick}>
<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />
</div>
)
const container = screen.getByTestId('tool-row-container')
fireEvent.click(container)
expect(mockOnClick).not.toHaveBeenCalled()
})
it("does not show always allow checkbox when serverName is not provided", () => {
render(<McpToolRow tool={mockTool} />)
it('displays input schema parameters when provided', () => {
const toolWithSchema = {
...mockTool,
inputSchema: {
type: 'object',
properties: {
param1: {
type: 'string',
description: 'First parameter'
},
param2: {
type: 'number',
description: 'Second parameter'
}
},
required: ['param1']
}
}
expect(screen.queryByText("Always allow")).not.toBeInTheDocument()
})
render(<McpToolRow tool={toolWithSchema} serverName="test-server" />)
expect(screen.getByText('Parameters')).toBeInTheDocument()
expect(screen.getByText('param1')).toBeInTheDocument()
expect(screen.getByText('param2')).toBeInTheDocument()
expect(screen.getByText('First parameter')).toBeInTheDocument()
expect(screen.getByText('Second parameter')).toBeInTheDocument()
})
})
it("shows always allow checkbox when serverName and alwaysAllowMcp are provided", () => {
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
expect(screen.getByText("Always allow")).toBeInTheDocument()
})
it("sends message to toggle always allow when checkbox is clicked", () => {
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
const checkbox = screen.getByRole("checkbox")
fireEvent.click(checkbox)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "toggleToolAlwaysAllow",
serverName: "test-server",
toolName: "test-tool",
alwaysAllow: true,
})
})
it("reflects always allow state in checkbox", () => {
const alwaysAllowedTool = {
...mockTool,
alwaysAllow: true,
}
render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" alwaysAllowMcp={true} />)
const checkbox = screen.getByRole("checkbox") as HTMLInputElement
expect(checkbox.checked).toBe(true)
})
it("prevents event propagation when clicking the checkbox", () => {
const mockOnClick = jest.fn()
render(
<div onClick={mockOnClick}>
<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />
</div>,
)
const container = screen.getByTestId("tool-row-container")
fireEvent.click(container)
expect(mockOnClick).not.toHaveBeenCalled()
})
it("displays input schema parameters when provided", () => {
const toolWithSchema = {
...mockTool,
inputSchema: {
type: "object",
properties: {
param1: {
type: "string",
description: "First parameter",
},
param2: {
type: "number",
description: "Second parameter",
},
},
required: ["param1"],
},
}
render(<McpToolRow tool={toolWithSchema} serverName="test-server" />)
expect(screen.getByText("Parameters")).toBeInTheDocument()
expect(screen.getByText("param1")).toBeInTheDocument()
expect(screen.getByText("param2")).toBeInTheDocument()
expect(screen.getByText("First parameter")).toBeInTheDocument()
expect(screen.getByText("Second parameter")).toBeInTheDocument()
})
})

View File

@@ -8,9 +8,9 @@ type PromptsViewProps = {
onDone: () => void
}
const AGENT_MODES = modes.map(mode => ({
const AGENT_MODES = modes.map((mode) => ({
id: mode.slug,
label: mode.name
label: mode.name,
}))
const PromptsView = ({ onDone }: PromptsViewProps) => {
@@ -21,24 +21,24 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
setEnhancementApiConfigId,
mode,
customInstructions,
setCustomInstructions
setCustomInstructions,
} = useExtensionState()
const [testPrompt, setTestPrompt] = useState('')
const [testPrompt, setTestPrompt] = useState("")
const [isEnhancing, setIsEnhancing] = useState(false)
const [activeTab, setActiveTab] = useState<Mode>(mode)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [selectedPromptContent, setSelectedPromptContent] = useState('')
const [selectedPromptTitle, setSelectedPromptTitle] = useState('')
const [selectedPromptContent, setSelectedPromptContent] = useState("")
const [selectedPromptTitle, setSelectedPromptTitle] = useState("")
useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data
if (message.type === 'enhancedPrompt') {
if (message.type === "enhancedPrompt") {
if (message.text) {
setTestPrompt(message.text)
}
setIsEnhancing(false)
} else if (message.type === 'systemPrompt') {
} else if (message.type === "systemPrompt") {
if (message.text) {
setSelectedPromptContent(message.text)
setSelectedPromptTitle(`System Prompt (${message.mode} mode)`)
@@ -47,17 +47,15 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
}
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
window.addEventListener("message", handler)
return () => window.removeEventListener("message", handler)
}, [])
type AgentMode = string;
type AgentMode = string
const updateAgentPrompt = (mode: Mode, promptData: PromptComponent) => {
const existingPrompt = customPrompts?.[mode]
const updatedPrompt = typeof existingPrompt === 'object'
? { ...existingPrompt, ...promptData }
: promptData
const updatedPrompt = typeof existingPrompt === "object" ? { ...existingPrompt, ...promptData } : promptData
// Only include properties that differ from defaults
if (updatedPrompt.roleDefinition === getRoleDefinition(mode)) {
@@ -67,14 +65,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
vscode.postMessage({
type: "updatePrompt",
promptMode: mode,
customPrompt: updatedPrompt
customPrompt: updatedPrompt,
})
}
const updateEnhancePrompt = (value: string | undefined) => {
vscode.postMessage({
type: "updateEnhancedPrompt",
text: value
text: value,
})
}
@@ -94,8 +92,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
const handleAgentReset = (mode: AgentMode) => {
const existingPrompt = customPrompts?.[mode]
updateAgentPrompt(mode, {
...(typeof existingPrompt === 'object' ? existingPrompt : {}),
roleDefinition: undefined
...(typeof existingPrompt === "object" ? existingPrompt : {}),
roleDefinition: undefined,
})
}
@@ -105,22 +103,22 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
const getAgentPromptValue = (mode: Mode): string => {
const prompt = customPrompts?.[mode]
return typeof prompt === 'object' ? prompt.roleDefinition ?? getRoleDefinition(mode) : getRoleDefinition(mode);
return typeof prompt === "object" ? (prompt.roleDefinition ?? getRoleDefinition(mode)) : getRoleDefinition(mode)
}
const getEnhancePromptValue = (): string => {
const enhance = customPrompts?.enhance
const defaultEnhance = typeof defaultPrompts.enhance === 'string' ? defaultPrompts.enhance : ''
return typeof enhance === 'string' ? enhance : defaultEnhance
const defaultEnhance = typeof defaultPrompts.enhance === "string" ? defaultPrompts.enhance : ""
return typeof enhance === "string" ? enhance : defaultEnhance
}
const handleTestEnhancement = () => {
if (!testPrompt.trim()) return
setIsEnhancing(true)
vscode.postMessage({
type: "enhancePrompt",
text: testPrompt
text: testPrompt,
})
}
@@ -147,19 +145,23 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0 20px" }}>
<div style={{ marginBottom: '20px' }}>
<div style={{ marginBottom: "20px" }}>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Custom Instructions for All Modes</div>
<div style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)", marginBottom: "8px" }}>
These instructions apply to all modes. They provide a base set of behaviors that can be enhanced by mode-specific instructions below.
<div
style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)", marginBottom: "8px" }}>
These instructions apply to all modes. They provide a base set of behaviors that can be enhanced
by mode-specific instructions below.
</div>
<VSCodeTextArea
value={customInstructions ?? ''}
value={customInstructions ?? ""}
onChange={(e) => {
const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value
const value =
(e as CustomEvent)?.detail?.target?.value ||
((e as any).target as HTMLTextAreaElement).value
setCustomInstructions(value || undefined)
vscode.postMessage({
type: "customInstructions",
text: value.trim() || undefined
text: value.trim() || undefined,
})
}}
rows={4}
@@ -168,32 +170,38 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
data-testid="global-custom-instructions-textarea"
/>
<div style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)", marginTop: "5px" }}>
Instructions can also be loaded from <span
Instructions can also be loaded from{" "}
<span
style={{
color: 'var(--vscode-textLink-foreground)',
cursor: 'pointer',
textDecoration: 'underline'
color: "var(--vscode-textLink-foreground)",
cursor: "pointer",
textDecoration: "underline",
}}
onClick={() => vscode.postMessage({
type: "openFile",
text: "./.clinerules",
values: {
create: true,
content: "",
}
})}
>.clinerules</span> in your workspace.
onClick={() =>
vscode.postMessage({
type: "openFile",
text: "./.clinerules",
values: {
create: true,
content: "",
},
})
}>
.clinerules
</span>{" "}
in your workspace.
</div>
</div>
<h3 style={{ color: "var(--vscode-foreground)", margin: "0 0 20px 0" }}>Mode-Specific Prompts</h3>
<div style={{
display: 'flex',
gap: '16px',
alignItems: 'center',
marginBottom: '12px'
}}>
<div
style={{
display: "flex",
gap: "16px",
alignItems: "center",
marginBottom: "12px",
}}>
{AGENT_MODES.map((tab) => (
<button
key={tab.id}
@@ -201,42 +209,50 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
data-active={activeTab === tab.id ? "true" : "false"}
onClick={() => setActiveTab(tab.id)}
style={{
padding: '4px 8px',
border: 'none',
background: activeTab === tab.id ? 'var(--vscode-button-background)' : 'none',
color: activeTab === tab.id ? 'var(--vscode-button-foreground)' : 'var(--vscode-foreground)',
cursor: 'pointer',
padding: "4px 8px",
border: "none",
background: activeTab === tab.id ? "var(--vscode-button-background)" : "none",
color:
activeTab === tab.id
? "var(--vscode-button-foreground)"
: "var(--vscode-foreground)",
cursor: "pointer",
opacity: activeTab === tab.id ? 1 : 0.8,
borderRadius: '3px',
fontWeight: 'bold'
}}
>
borderRadius: "3px",
fontWeight: "bold",
}}>
{tab.label}
</button>
))}
</div>
<div style={{ marginBottom: '20px' }}>
<div style={{ marginBottom: '8px' }}>
<div style={{ marginBottom: "20px" }}>
<div style={{ marginBottom: "8px" }}>
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: "4px"
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "4px",
}}>
<div style={{ fontWeight: "bold" }}>Role Definition</div>
<VSCodeButton
appearance="icon"
onClick={() => handleAgentReset(activeTab)}
data-testid="reset-prompt-button"
title="Revert to default"
>
title="Revert to default">
<span className="codicon codicon-discard"></span>
</VSCodeButton>
</div>
<div style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)", marginBottom: "8px" }}>
Define Cline's expertise and personality for this mode. This description shapes how Cline presents itself and approaches tasks.
<div
style={{
fontSize: "13px",
color: "var(--vscode-descriptionForeground)",
marginBottom: "8px",
}}>
Define Cline's expertise and personality for this mode. This description shapes how
Cline presents itself and approaches tasks.
</div>
</div>
<VSCodeTextArea
@@ -248,22 +264,30 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
data-testid={`${activeTab}-prompt-textarea`}
/>
</div>
<div style={{ marginBottom: '8px' }}>
<div style={{ marginBottom: "8px" }}>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Mode-specific Custom Instructions</div>
<div style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)", marginBottom: "8px" }}>
Add behavioral guidelines specific to {activeTab} mode. These instructions enhance the base behaviors defined above.
<div
style={{
fontSize: "13px",
color: "var(--vscode-descriptionForeground)",
marginBottom: "8px",
}}>
Add behavioral guidelines specific to {activeTab} mode. These instructions enhance the base
behaviors defined above.
</div>
<VSCodeTextArea
value={(() => {
const prompt = customPrompts?.[activeTab]
return typeof prompt === 'object' ? prompt.customInstructions ?? '' : ''
return typeof prompt === "object" ? (prompt.customInstructions ?? "") : ""
})()}
onChange={(e) => {
const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value
const value =
(e as CustomEvent)?.detail?.target?.value ||
((e as any).target as HTMLTextAreaElement).value
const existingPrompt = customPrompts?.[activeTab]
updateAgentPrompt(activeTab, {
...(typeof existingPrompt === 'object' ? existingPrompt : {}),
customInstructions: value.trim() || undefined
...(typeof existingPrompt === "object" ? existingPrompt : {}),
customInstructions: value.trim() || undefined,
})
}}
rows={4}
@@ -271,25 +295,34 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
style={{ width: "100%" }}
data-testid={`${activeTab}-custom-instructions-textarea`}
/>
<div style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)", marginTop: "5px" }}>
Custom instructions specific to {activeTab} mode can also be loaded from <span
<div
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
marginTop: "5px",
}}>
Custom instructions specific to {activeTab} mode can also be loaded from{" "}
<span
style={{
color: 'var(--vscode-textLink-foreground)',
cursor: 'pointer',
textDecoration: 'underline'
color: "var(--vscode-textLink-foreground)",
cursor: "pointer",
textDecoration: "underline",
}}
onClick={() => {
// First create/update the file with current custom instructions
const defaultContent = `# ${activeTab} Mode Rules\n\nAdd mode-specific rules and guidelines here.`
const existingPrompt = customPrompts?.[activeTab]
const existingInstructions = typeof existingPrompt === 'object' ? existingPrompt.customInstructions : undefined
const existingInstructions =
typeof existingPrompt === "object"
? existingPrompt.customInstructions
: undefined
vscode.postMessage({
type: "updatePrompt",
promptMode: activeTab,
customPrompt: {
...(typeof existingPrompt === 'object' ? existingPrompt : {}),
customInstructions: existingInstructions || defaultContent
}
...(typeof existingPrompt === "object" ? existingPrompt : {}),
customInstructions: existingInstructions || defaultContent,
},
})
// Then open the file
vscode.postMessage({
@@ -298,37 +331,40 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
values: {
create: true,
content: "",
}
},
})
}}
>.clinerules-{activeTab}</span> in your workspace.
}}>
.clinerules-{activeTab}
</span>{" "}
in your workspace.
</div>
</div>
</div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'flex-start' }}>
<div style={{ marginBottom: "20px", display: "flex", justifyContent: "flex-start" }}>
<VSCodeButton
appearance="primary"
onClick={() => {
vscode.postMessage({
type: "getSystemPrompt",
mode: activeTab
mode: activeTab,
})
}}
data-testid="preview-prompt-button"
>
data-testid="preview-prompt-button">
Preview System Prompt
</VSCodeButton>
</div>
<h3 style={{ color: "var(--vscode-foreground)", margin: "40px 0 20px 0" }}>Prompt Enhancement</h3>
<div style={{
color: "var(--vscode-foreground)",
fontSize: "13px",
marginBottom: "20px",
marginTop: "5px",
}}>
Use prompt enhancement to get tailored suggestions or improvements for your inputs. This ensures Cline understands your intent and provides the best possible responses.
<div
style={{
color: "var(--vscode-foreground)",
fontSize: "13px",
marginBottom: "20px",
marginTop: "5px",
}}>
Use prompt enhancement to get tailored suggestions or improvements for your inputs. This ensures
Cline understands your intent and provides the best possible responses.
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
@@ -337,22 +373,22 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
<div style={{ marginBottom: "8px" }}>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>API Configuration</div>
<div style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)" }}>
You can select an API configuration to always use for enhancing prompts, or just use whatever is currently selected
You can select an API configuration to always use for enhancing prompts, or just use
whatever is currently selected
</div>
</div>
<VSCodeDropdown
value={enhancementApiConfigId || ''}
value={enhancementApiConfigId || ""}
data-testid="api-config-dropdown"
onChange={(e: any) => {
const value = e.detail?.target?.value || e.target?.value
setEnhancementApiConfigId(value)
vscode.postMessage({
type: "enhancementApiConfigId",
text: value
text: value,
})
}}
style={{ width: "300px" }}
>
style={{ width: "300px" }}>
<VSCodeOption value="">Use currently selected API configuration</VSCodeOption>
{(listApiConfigMeta || []).map((config) => (
<VSCodeOption key={config.id} value={config.id}>
@@ -363,15 +399,29 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</div>
<div style={{ marginBottom: "8px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "4px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "4px",
}}>
<div style={{ fontWeight: "bold" }}>Enhancement Prompt</div>
<div style={{ display: "flex", gap: "8px" }}>
<VSCodeButton appearance="icon" onClick={handleEnhanceReset} title="Revert to default">
<VSCodeButton
appearance="icon"
onClick={handleEnhanceReset}
title="Revert to default">
<span className="codicon codicon-discard"></span>
</VSCodeButton>
</div>
</div>
<div style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)", marginBottom: "8px" }}>
<div
style={{
fontSize: "13px",
color: "var(--vscode-descriptionForeground)",
marginBottom: "8px",
}}>
This prompt will be used to refine your input when you hit the sparkle icon in chat.
</div>
</div>
@@ -382,7 +432,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
resize="vertical"
style={{ width: "100%" }}
/>
<div style={{ marginTop: "12px" }}>
<VSCodeTextArea
value={testPrompt}
@@ -393,18 +443,18 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
style={{ width: "100%" }}
data-testid="test-prompt-textarea"
/>
<div style={{
marginTop: "8px",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
gap: 8
}}>
<div
style={{
marginTop: "8px",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
gap: 8,
}}>
<VSCodeButton
onClick={handleTestEnhancement}
disabled={isEnhancing}
appearance="primary"
>
appearance="primary">
Preview Prompt Enhancement
</VSCodeButton>
</div>
@@ -417,66 +467,68 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</div>
{isDialogOpen && (
<div style={{
position: 'fixed',
inset: 0,
display: 'flex',
justifyContent: 'flex-end',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000
}}>
<div style={{
width: 'calc(100vw - 100px)',
height: '100%',
backgroundColor: 'var(--vscode-editor-background)',
boxShadow: '-2px 0 5px rgba(0, 0, 0, 0.2)',
display: 'flex',
flexDirection: 'column',
position: 'relative'
<div
style={{
position: "fixed",
inset: 0,
display: "flex",
justifyContent: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.5)",
zIndex: 1000,
}}>
<div style={{
flex: 1,
padding: '20px',
overflowY: 'auto',
minHeight: 0
<div
style={{
width: "calc(100vw - 100px)",
height: "100%",
backgroundColor: "var(--vscode-editor-background)",
boxShadow: "-2px 0 5px rgba(0, 0, 0, 0.2)",
display: "flex",
flexDirection: "column",
position: "relative",
}}>
<div
style={{
flex: 1,
padding: "20px",
overflowY: "auto",
minHeight: 0,
}}>
<VSCodeButton
appearance="icon"
onClick={() => setIsDialogOpen(false)}
style={{
position: 'absolute',
top: '20px',
right: '20px'
}}
>
position: "absolute",
top: "20px",
right: "20px",
}}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
<h2 style={{ margin: '0 0 16px' }}>{selectedPromptTitle}</h2>
<pre style={{
padding: '8px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--vscode-editor-font-family)',
fontSize: 'var(--vscode-editor-font-size)',
color: 'var(--vscode-editor-foreground)',
backgroundColor: 'var(--vscode-editor-background)',
border: '1px solid var(--vscode-editor-lineHighlightBorder)',
borderRadius: '4px',
overflowY: 'auto'
}}>
<h2 style={{ margin: "0 0 16px" }}>{selectedPromptTitle}</h2>
<pre
style={{
padding: "8px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--vscode-editor-font-family)",
fontSize: "var(--vscode-editor-font-size)",
color: "var(--vscode-editor-foreground)",
backgroundColor: "var(--vscode-editor-background)",
border: "1px solid var(--vscode-editor-lineHighlightBorder)",
borderRadius: "4px",
overflowY: "auto",
}}>
{selectedPromptContent}
</pre>
</div>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
padding: '12px 20px',
borderTop: '1px solid var(--vscode-editor-lineHighlightBorder)',
backgroundColor: 'var(--vscode-editor-background)'
}}>
<VSCodeButton onClick={() => setIsDialogOpen(false)}>
Close
</VSCodeButton>
<div
style={{
display: "flex",
justifyContent: "flex-end",
padding: "12px 20px",
borderTop: "1px solid var(--vscode-editor-lineHighlightBorder)",
backgroundColor: "var(--vscode-editor-background)",
}}>
<VSCodeButton onClick={() => setIsDialogOpen(false)}>Close</VSCodeButton>
</div>
</div>
</div>
@@ -485,4 +537,4 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
)
}
export default PromptsView
export default PromptsView

View File

@@ -1,160 +1,166 @@
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import PromptsView from '../PromptsView'
import { ExtensionStateContext } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import { render, screen, fireEvent } from "@testing-library/react"
import "@testing-library/jest-dom"
import PromptsView from "../PromptsView"
import { ExtensionStateContext } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn()
}
jest.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
const mockExtensionState = {
customPrompts: {},
listApiConfigMeta: [
{ id: 'config1', name: 'Config 1' },
{ id: 'config2', name: 'Config 2' }
],
enhancementApiConfigId: '',
setEnhancementApiConfigId: jest.fn(),
mode: 'code',
customInstructions: 'Initial instructions',
setCustomInstructions: jest.fn()
customPrompts: {},
listApiConfigMeta: [
{ id: "config1", name: "Config 1" },
{ id: "config2", name: "Config 2" },
],
enhancementApiConfigId: "",
setEnhancementApiConfigId: jest.fn(),
mode: "code",
customInstructions: "Initial instructions",
setCustomInstructions: jest.fn(),
}
const renderPromptsView = (props = {}) => {
const mockOnDone = jest.fn()
return render(
<ExtensionStateContext.Provider value={{ ...mockExtensionState, ...props } as any}>
<PromptsView onDone={mockOnDone} />
</ExtensionStateContext.Provider>
)
const mockOnDone = jest.fn()
return render(
<ExtensionStateContext.Provider value={{ ...mockExtensionState, ...props } as any}>
<PromptsView onDone={mockOnDone} />
</ExtensionStateContext.Provider>,
)
}
describe('PromptsView', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("PromptsView", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders all mode tabs', () => {
renderPromptsView()
expect(screen.getByTestId('code-tab')).toBeInTheDocument()
expect(screen.getByTestId('ask-tab')).toBeInTheDocument()
expect(screen.getByTestId('architect-tab')).toBeInTheDocument()
})
it("renders all mode tabs", () => {
renderPromptsView()
expect(screen.getByTestId("code-tab")).toBeInTheDocument()
expect(screen.getByTestId("ask-tab")).toBeInTheDocument()
expect(screen.getByTestId("architect-tab")).toBeInTheDocument()
})
it('defaults to current mode as active tab', () => {
renderPromptsView({ mode: 'ask' })
const codeTab = screen.getByTestId('code-tab')
const askTab = screen.getByTestId('ask-tab')
const architectTab = screen.getByTestId('architect-tab')
expect(askTab).toHaveAttribute('data-active', 'true')
expect(codeTab).toHaveAttribute('data-active', 'false')
expect(architectTab).toHaveAttribute('data-active', 'false')
})
it("defaults to current mode as active tab", () => {
renderPromptsView({ mode: "ask" })
it('switches between tabs correctly', () => {
renderPromptsView({ mode: 'code' })
const codeTab = screen.getByTestId('code-tab')
const askTab = screen.getByTestId('ask-tab')
const architectTab = screen.getByTestId('architect-tab')
// Initial state matches current mode (code)
expect(codeTab).toHaveAttribute('data-active', 'true')
expect(askTab).toHaveAttribute('data-active', 'false')
expect(architectTab).toHaveAttribute('data-active', 'false')
expect(architectTab).toHaveAttribute('data-active', 'false')
// Click Ask tab
fireEvent.click(askTab)
expect(askTab).toHaveAttribute('data-active', 'true')
expect(codeTab).toHaveAttribute('data-active', 'false')
expect(architectTab).toHaveAttribute('data-active', 'false')
// Click Architect tab
fireEvent.click(architectTab)
expect(architectTab).toHaveAttribute('data-active', 'true')
expect(askTab).toHaveAttribute('data-active', 'false')
expect(codeTab).toHaveAttribute('data-active', 'false')
})
const codeTab = screen.getByTestId("code-tab")
const askTab = screen.getByTestId("ask-tab")
const architectTab = screen.getByTestId("architect-tab")
it('handles prompt changes correctly', () => {
renderPromptsView()
const textarea = screen.getByTestId('code-prompt-textarea')
fireEvent(textarea, new CustomEvent('change', {
detail: {
target: {
value: 'New prompt value'
}
}
}))
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'updatePrompt',
promptMode: 'code',
customPrompt: { roleDefinition: 'New prompt value' }
})
})
expect(askTab).toHaveAttribute("data-active", "true")
expect(codeTab).toHaveAttribute("data-active", "false")
expect(architectTab).toHaveAttribute("data-active", "false")
})
it('resets prompt to default value', () => {
renderPromptsView()
const resetButton = screen.getByTestId('reset-prompt-button')
fireEvent.click(resetButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'updatePrompt',
promptMode: 'code',
customPrompt: { roleDefinition: undefined }
})
})
it("switches between tabs correctly", () => {
renderPromptsView({ mode: "code" })
it('handles API configuration selection', () => {
renderPromptsView()
const dropdown = screen.getByTestId('api-config-dropdown')
fireEvent(dropdown, new CustomEvent('change', {
detail: {
target: {
value: 'config1'
}
}
}))
expect(mockExtensionState.setEnhancementApiConfigId).toHaveBeenCalledWith('config1')
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'enhancementApiConfigId',
text: 'config1'
})
})
const codeTab = screen.getByTestId("code-tab")
const askTab = screen.getByTestId("ask-tab")
const architectTab = screen.getByTestId("architect-tab")
it('handles clearing custom instructions correctly', async () => {
const setCustomInstructions = jest.fn()
renderPromptsView({
...mockExtensionState,
customInstructions: 'Initial instructions',
setCustomInstructions
})
// Initial state matches current mode (code)
expect(codeTab).toHaveAttribute("data-active", "true")
expect(askTab).toHaveAttribute("data-active", "false")
expect(architectTab).toHaveAttribute("data-active", "false")
expect(architectTab).toHaveAttribute("data-active", "false")
const textarea = screen.getByTestId('global-custom-instructions-textarea')
const changeEvent = new CustomEvent('change', {
detail: { target: { value: '' } }
})
Object.defineProperty(changeEvent, 'target', {
value: { value: '' }
})
await fireEvent(textarea, changeEvent)
// Click Ask tab
fireEvent.click(askTab)
expect(askTab).toHaveAttribute("data-active", "true")
expect(codeTab).toHaveAttribute("data-active", "false")
expect(architectTab).toHaveAttribute("data-active", "false")
expect(setCustomInstructions).toHaveBeenCalledWith(undefined)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'customInstructions',
text: undefined
})
})
})
// Click Architect tab
fireEvent.click(architectTab)
expect(architectTab).toHaveAttribute("data-active", "true")
expect(askTab).toHaveAttribute("data-active", "false")
expect(codeTab).toHaveAttribute("data-active", "false")
})
it("handles prompt changes correctly", () => {
renderPromptsView()
const textarea = screen.getByTestId("code-prompt-textarea")
fireEvent(
textarea,
new CustomEvent("change", {
detail: {
target: {
value: "New prompt value",
},
},
}),
)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updatePrompt",
promptMode: "code",
customPrompt: { roleDefinition: "New prompt value" },
})
})
it("resets prompt to default value", () => {
renderPromptsView()
const resetButton = screen.getByTestId("reset-prompt-button")
fireEvent.click(resetButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updatePrompt",
promptMode: "code",
customPrompt: { roleDefinition: undefined },
})
})
it("handles API configuration selection", () => {
renderPromptsView()
const dropdown = screen.getByTestId("api-config-dropdown")
fireEvent(
dropdown,
new CustomEvent("change", {
detail: {
target: {
value: "config1",
},
},
}),
)
expect(mockExtensionState.setEnhancementApiConfigId).toHaveBeenCalledWith("config1")
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "enhancementApiConfigId",
text: "config1",
})
})
it("handles clearing custom instructions correctly", async () => {
const setCustomInstructions = jest.fn()
renderPromptsView({
...mockExtensionState,
customInstructions: "Initial instructions",
setCustomInstructions,
})
const textarea = screen.getByTestId("global-custom-instructions-textarea")
const changeEvent = new CustomEvent("change", {
detail: { target: { value: "" } },
})
Object.defineProperty(changeEvent, "target", {
value: { value: "" },
})
await fireEvent(textarea, changeEvent)
expect(setCustomInstructions).toHaveBeenCalledWith(undefined)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "customInstructions",
text: undefined,
})
})
})

View File

@@ -3,223 +3,216 @@ import { memo, useEffect, useRef, useState } from "react"
import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
interface ApiConfigManagerProps {
currentApiConfigName?: string
listApiConfigMeta?: ApiConfigMeta[]
onSelectConfig: (configName: string) => void
onDeleteConfig: (configName: string) => void
onRenameConfig: (oldName: string, newName: string) => void
onUpsertConfig: (configName: string) => void
currentApiConfigName?: string
listApiConfigMeta?: ApiConfigMeta[]
onSelectConfig: (configName: string) => void
onDeleteConfig: (configName: string) => void
onRenameConfig: (oldName: string, newName: string) => void
onUpsertConfig: (configName: string) => void
}
const ApiConfigManager = ({
currentApiConfigName = "",
listApiConfigMeta = [],
onSelectConfig,
onDeleteConfig,
onRenameConfig,
onUpsertConfig,
currentApiConfigName = "",
listApiConfigMeta = [],
onSelectConfig,
onDeleteConfig,
onRenameConfig,
onUpsertConfig,
}: ApiConfigManagerProps) => {
const [editState, setEditState] = useState<'new' | 'rename' | null>(null);
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>();
const [editState, setEditState] = useState<"new" | "rename" | null>(null)
const [inputValue, setInputValue] = useState("")
const inputRef = useRef<HTMLInputElement>()
// Focus input when entering edit mode
useEffect(() => {
if (editState) {
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [editState]);
// Focus input when entering edit mode
useEffect(() => {
if (editState) {
setTimeout(() => inputRef.current?.focus(), 0)
}
}, [editState])
// Reset edit state when current profile changes
useEffect(() => {
setEditState(null);
setInputValue("");
}, [currentApiConfigName]);
// Reset edit state when current profile changes
useEffect(() => {
setEditState(null)
setInputValue("")
}, [currentApiConfigName])
const handleAdd = () => {
const newConfigName = currentApiConfigName + " (copy)";
onUpsertConfig(newConfigName);
};
const handleAdd = () => {
const newConfigName = currentApiConfigName + " (copy)"
onUpsertConfig(newConfigName)
}
const handleStartRename = () => {
setEditState('rename');
setInputValue(currentApiConfigName || "");
};
const handleStartRename = () => {
setEditState("rename")
setInputValue(currentApiConfigName || "")
}
const handleCancel = () => {
setEditState(null);
setInputValue("");
};
const handleCancel = () => {
setEditState(null)
setInputValue("")
}
const handleSave = () => {
const trimmedValue = inputValue.trim();
if (!trimmedValue) return;
const handleSave = () => {
const trimmedValue = inputValue.trim()
if (!trimmedValue) return
if (editState === 'new') {
onUpsertConfig(trimmedValue);
} else if (editState === 'rename' && currentApiConfigName) {
onRenameConfig(currentApiConfigName, trimmedValue);
}
if (editState === "new") {
onUpsertConfig(trimmedValue)
} else if (editState === "rename" && currentApiConfigName) {
onRenameConfig(currentApiConfigName, trimmedValue)
}
setEditState(null);
setInputValue("");
};
setEditState(null)
setInputValue("")
}
const handleDelete = () => {
if (!currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return;
// Let the extension handle both deletion and selection
onDeleteConfig(currentApiConfigName);
};
const handleDelete = () => {
if (!currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return
const isOnlyProfile = listApiConfigMeta?.length === 1;
// Let the extension handle both deletion and selection
onDeleteConfig(currentApiConfigName)
}
return (
<div style={{ marginBottom: 5 }}>
<div style={{
display: "flex",
flexDirection: "column",
gap: "2px"
}}>
<label htmlFor="config-profile">
<span style={{ fontWeight: "500" }}>Configuration Profile</span>
</label>
const isOnlyProfile = listApiConfigMeta?.length === 1
{editState ? (
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<VSCodeTextField
ref={inputRef as any}
value={inputValue}
onInput={(e: any) => setInputValue(e.target.value)}
placeholder={editState === 'new' ? "Enter profile name" : "Enter new name"}
style={{ flexGrow: 1 }}
onKeyDown={(e: any) => {
if (e.key === 'Enter' && inputValue.trim()) {
handleSave();
} else if (e.key === 'Escape') {
handleCancel();
}
}}
/>
<VSCodeButton
appearance="icon"
disabled={!inputValue.trim()}
onClick={handleSave}
title="Save"
style={{
padding: 0,
margin: 0,
height: '28px',
width: '28px',
minWidth: '28px'
}}
>
<span className="codicon codicon-check" />
</VSCodeButton>
<VSCodeButton
appearance="icon"
onClick={handleCancel}
title="Cancel"
style={{
padding: 0,
margin: 0,
height: '28px',
width: '28px',
minWidth: '28px'
}}
>
<span className="codicon codicon-close" />
</VSCodeButton>
</div>
) : (
<>
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<select
id="config-profile"
value={currentApiConfigName}
onChange={(e) => onSelectConfig(e.target.value)}
style={{
flexGrow: 1,
padding: "4px 8px",
paddingRight: "24px",
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)",
border: "1px solid var(--vscode-dropdown-border)",
borderRadius: "2px",
height: "28px",
cursor: "pointer",
outline: "none"
}}
>
{listApiConfigMeta?.map((config) => (
<option
key={config.name}
value={config.name}
>
{config.name}
</option>
))}
</select>
<VSCodeButton
appearance="icon"
onClick={handleAdd}
title="Add profile"
style={{
padding: 0,
margin: 0,
height: '28px',
width: '28px',
minWidth: '28px'
}}
>
<span className="codicon codicon-add" />
</VSCodeButton>
{currentApiConfigName && (
<>
<VSCodeButton
appearance="icon"
onClick={handleStartRename}
title="Rename profile"
style={{
padding: 0,
margin: 0,
height: '28px',
width: '28px',
minWidth: '28px'
}}
>
<span className="codicon codicon-edit" />
</VSCodeButton>
<VSCodeButton
appearance="icon"
onClick={handleDelete}
title={isOnlyProfile ? "Cannot delete the only profile" : "Delete profile"}
disabled={isOnlyProfile}
style={{
padding: 0,
margin: 0,
height: '28px',
width: '28px',
minWidth: '28px'
}}
>
<span className="codicon codicon-trash" />
</VSCodeButton>
</>
)}
</div>
<p style={{
fontSize: "12px",
margin: "5px 0 12px",
color: "var(--vscode-descriptionForeground)"
}}>
Save different API configurations to quickly switch between providers and settings
</p>
</>
)}
</div>
</div>
)
return (
<div style={{ marginBottom: 5 }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "2px",
}}>
<label htmlFor="config-profile">
<span style={{ fontWeight: "500" }}>Configuration Profile</span>
</label>
{editState ? (
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<VSCodeTextField
ref={inputRef as any}
value={inputValue}
onInput={(e: any) => setInputValue(e.target.value)}
placeholder={editState === "new" ? "Enter profile name" : "Enter new name"}
style={{ flexGrow: 1 }}
onKeyDown={(e: any) => {
if (e.key === "Enter" && inputValue.trim()) {
handleSave()
} else if (e.key === "Escape") {
handleCancel()
}
}}
/>
<VSCodeButton
appearance="icon"
disabled={!inputValue.trim()}
onClick={handleSave}
title="Save"
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-check" />
</VSCodeButton>
<VSCodeButton
appearance="icon"
onClick={handleCancel}
title="Cancel"
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-close" />
</VSCodeButton>
</div>
) : (
<>
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<select
id="config-profile"
value={currentApiConfigName}
onChange={(e) => onSelectConfig(e.target.value)}
style={{
flexGrow: 1,
padding: "4px 8px",
paddingRight: "24px",
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)",
border: "1px solid var(--vscode-dropdown-border)",
borderRadius: "2px",
height: "28px",
cursor: "pointer",
outline: "none",
}}>
{listApiConfigMeta?.map((config) => (
<option key={config.name} value={config.name}>
{config.name}
</option>
))}
</select>
<VSCodeButton
appearance="icon"
onClick={handleAdd}
title="Add profile"
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-add" />
</VSCodeButton>
{currentApiConfigName && (
<>
<VSCodeButton
appearance="icon"
onClick={handleStartRename}
title="Rename profile"
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-edit" />
</VSCodeButton>
<VSCodeButton
appearance="icon"
onClick={handleDelete}
title={isOnlyProfile ? "Cannot delete the only profile" : "Delete profile"}
disabled={isOnlyProfile}
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-trash" />
</VSCodeButton>
</>
)}
</div>
<p
style={{
fontSize: "12px",
margin: "5px 0 12px",
color: "var(--vscode-descriptionForeground)",
}}>
Save different API configurations to quickly switch between providers and settings
</p>
</>
)}
</div>
</div>
)
}
export default memo(ApiConfigManager)
export default memo(ApiConfigManager)

View File

@@ -1,11 +1,6 @@
import { Checkbox, Dropdown } from "vscrui"
import type { DropdownOption } from "vscrui"
import {
VSCodeLink,
VSCodeRadio,
VSCodeRadioGroup,
VSCodeTextField
} from "@vscode/webview-ui-toolkit/react"
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react"
import { useEvent, useInterval } from "react-use"
import {
@@ -83,7 +78,12 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
requestLocalModels()
}
}, [selectedProvider, requestLocalModels])
useInterval(requestLocalModels, selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm" ? 2000 : null)
useInterval(
requestLocalModels,
selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm"
? 2000
: null,
)
const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
if (message.type === "ollamaModels" && message.ollamaModels) {
@@ -102,17 +102,19 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
...Object.keys(models).map((modelId) => ({
value: modelId,
label: modelId,
}))
})),
]
return (
<Dropdown
id="model-id"
value={selectedModelId}
onChange={(value: unknown) => {handleInputChange("apiModelId")({
target: {
value: (value as DropdownOption).value
}
})}}
onChange={(value: unknown) => {
handleInputChange("apiModelId")({
target: {
value: (value as DropdownOption).value,
},
})
}}
style={{ width: "100%" }}
options={options}
/>
@@ -131,8 +133,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
onChange={(value: unknown) => {
handleInputChange("apiProvider")({
target: {
value: (value as DropdownOption).value
}
value: (value as DropdownOption).value,
},
})
}}
style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}
@@ -149,7 +151,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{ value: "vscode-lm", label: "VS Code LM API" },
{ value: "mistral", label: "Mistral" },
{ value: "lmstudio", label: "LM Studio" },
{ value: "ollama", label: "Ollama" }
{ value: "ollama", label: "Ollama" },
]}
/>
</div>
@@ -331,7 +333,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
target: { value: checked },
})
}}>
Compress prompts and message chains to the context size (<a href="https://openrouter.ai/docs/transforms">OpenRouter Transforms</a>)
Compress prompts and message chains to the context size (
<a href="https://openrouter.ai/docs/transforms">OpenRouter Transforms</a>)
</Checkbox>
<br />
</div>
@@ -371,11 +374,13 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
id="aws-region-dropdown"
value={apiConfiguration?.awsRegion || ""}
style={{ width: "100%" }}
onChange={(value: unknown) => {handleInputChange("awsRegion")({
target: {
value: (value as DropdownOption).value
}
})}}
onChange={(value: unknown) => {
handleInputChange("awsRegion")({
target: {
value: (value as DropdownOption).value,
},
})
}}
options={[
{ value: "", label: "Select a region..." },
{ value: "us-east-1", label: "us-east-1" },
@@ -392,7 +397,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{ value: "eu-west-2", label: "eu-west-2" },
{ value: "eu-west-3", label: "eu-west-3" },
{ value: "sa-east-1", label: "sa-east-1" },
{ value: "us-gov-west-1", label: "us-gov-west-1" }
{ value: "us-gov-west-1", label: "us-gov-west-1" },
]}
/>
</div>
@@ -435,18 +440,20 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
id="vertex-region-dropdown"
value={apiConfiguration?.vertexRegion || ""}
style={{ width: "100%" }}
onChange={(value: unknown) => {handleInputChange("vertexRegion")({
target: {
value: (value as DropdownOption).value
}
})}}
onChange={(value: unknown) => {
handleInputChange("vertexRegion")({
target: {
value: (value as DropdownOption).value,
},
})
}}
options={[
{ value: "", label: "Select a region..." },
{ value: "us-east5", label: "us-east5" },
{ value: "us-central1", label: "us-central1" },
{ value: "europe-west1", label: "europe-west1" },
{ value: "europe-west4", label: "europe-west4" },
{ value: "asia-southeast1", label: "asia-southeast1" }
{ value: "asia-southeast1", label: "asia-southeast1" },
]}
/>
</div>
@@ -520,7 +527,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
<span style={{ fontWeight: 500 }}>API Key</span>
</VSCodeTextField>
<OpenAiModelPicker />
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: "flex", alignItems: "center" }}>
<Checkbox
checked={apiConfiguration?.openAiStreamingEnabled ?? true}
onChange={(checked: boolean) => {
@@ -669,19 +676,21 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{vsCodeLmModels.length > 0 ? (
<Dropdown
id="vscode-lm-model"
value={apiConfiguration?.vsCodeLmModelSelector ?
`${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}` :
""}
value={
apiConfiguration?.vsCodeLmModelSelector
? `${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}`
: ""
}
onChange={(value: unknown) => {
const valueStr = (value as DropdownOption)?.value;
const valueStr = (value as DropdownOption)?.value
if (!valueStr) {
return
}
const [vendor, family] = valueStr.split('/');
const [vendor, family] = valueStr.split("/")
handleInputChange("vsCodeLmModelSelector")({
target: {
value: { vendor, family }
}
value: { vendor, family },
},
})
}}
style={{ width: "100%" }}
@@ -689,18 +698,20 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{ value: "", label: "Select a model..." },
...vsCodeLmModels.map((model) => ({
value: `${model.vendor}/${model.family}`,
label: `${model.vendor} - ${model.family}`
}))
label: `${model.vendor} - ${model.family}`,
})),
]}
/>
) : (
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
The VS Code Language Model API allows you to run models provided by other VS Code extensions (including but not limited to GitHub Copilot).
The easiest way to get started is to install the Copilot and Copilot Chat extensions from the VS Code Marketplace.
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
The VS Code Language Model API allows you to run models provided by other VS Code
extensions (including but not limited to GitHub Copilot). The easiest way to get started
is to install the Copilot and Copilot Chat extensions from the VS Code Marketplace.
</p>
)}
@@ -711,7 +722,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
color: "var(--vscode-errorForeground)",
fontWeight: 500,
}}>
Note: This is a very experimental integration and may not work as expected. Please report any issues to the Roo-Cline GitHub repository.
Note: This is a very experimental integration and may not work as expected. Please report
any issues to the Roo-Cline GitHub repository.
</p>
</div>
</div>
@@ -1042,9 +1054,9 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
case "vscode-lm":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.vsCodeLmModelSelector ?
`${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}` :
"",
selectedModelId: apiConfiguration?.vsCodeLmModelSelector
? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}`
: "",
selectedModelInfo: {
...openAiModelInfoSaneDefaults,
supportsImages: false, // VSCode LM API currently doesn't support images

View File

@@ -38,7 +38,6 @@ const GlamaModelPicker: React.FC = () => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
useEffect(() => {
if (apiConfiguration?.glamaModelId && apiConfiguration?.glamaModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.glamaModelId)
@@ -47,18 +46,15 @@ const GlamaModelPicker: React.FC = () => {
const debouncedRefreshModels = useMemo(
() =>
debounce(
() => {
vscode.postMessage({ type: "refreshGlamaModels" })
},
50
),
[]
debounce(() => {
vscode.postMessage({ type: "refreshGlamaModels" })
}, 50),
[],
)
useMount(() => {
debouncedRefreshModels()
// Cleanup debounced function
return () => {
debouncedRefreshModels.clear()
@@ -91,7 +87,7 @@ const GlamaModelPicker: React.FC = () => {
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
selector: (item) => item.html,
})
}, [searchableItems])
@@ -99,9 +95,9 @@ const GlamaModelPicker: React.FC = () => {
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
return searchResults.map((result) => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
}))
}, [searchableItems, searchTerm, fzf])

View File

@@ -37,19 +37,16 @@ const OpenAiModelPicker: React.FC = () => {
const debouncedRefreshModels = useMemo(
() =>
debounce(
(baseUrl: string, apiKey: string) => {
vscode.postMessage({
type: "refreshOpenAiModels",
values: {
baseUrl,
apiKey
}
})
},
50
),
[]
debounce((baseUrl: string, apiKey: string) => {
vscode.postMessage({
type: "refreshOpenAiModels",
values: {
baseUrl,
apiKey,
},
})
}, 50),
[],
)
useEffect(() => {
@@ -57,10 +54,7 @@ const OpenAiModelPicker: React.FC = () => {
return
}
debouncedRefreshModels(
apiConfiguration.openAiBaseUrl,
apiConfiguration.openAiApiKey
)
debouncedRefreshModels(apiConfiguration.openAiBaseUrl, apiConfiguration.openAiApiKey)
// Cleanup debounced function
return () => {
@@ -94,7 +88,7 @@ const OpenAiModelPicker: React.FC = () => {
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
selector: (item) => item.html,
})
}, [searchableItems])
@@ -102,9 +96,9 @@ const OpenAiModelPicker: React.FC = () => {
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
return searchResults.map((result) => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
}))
}, [searchableItems, searchTerm, fzf])

View File

@@ -46,18 +46,15 @@ const OpenRouterModelPicker: React.FC = () => {
const debouncedRefreshModels = useMemo(
() =>
debounce(
() => {
vscode.postMessage({ type: "refreshOpenRouterModels" })
},
50
),
[]
debounce(() => {
vscode.postMessage({ type: "refreshOpenRouterModels" })
}, 50),
[],
)
useMount(() => {
debouncedRefreshModels()
// Cleanup debounced function
return () => {
debouncedRefreshModels.clear()
@@ -90,7 +87,7 @@ const OpenRouterModelPicker: React.FC = () => {
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
selector: (item) => item.html,
})
}, [searchableItems])
@@ -98,9 +95,9 @@ const OpenRouterModelPicker: React.FC = () => {
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
return searchResults.map((result) => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
}))
}, [searchableItems, searchTerm, fzf])

View File

@@ -1,4 +1,10 @@
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import {
VSCodeButton,
VSCodeCheckbox,
VSCodeLink,
VSCodeTextArea,
VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
@@ -61,7 +67,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
listApiConfigMeta,
mode,
setMode,
experimentalDiffStrategy,
experimentalDiffStrategy,
setExperimentalDiffStrategy,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
@@ -77,7 +83,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
if (!apiValidationResult && !modelIdValidationResult) {
vscode.postMessage({
type: "apiConfiguration",
apiConfiguration
apiConfiguration,
})
vscode.postMessage({ type: "customInstructions", text: customInstructions })
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
@@ -102,10 +108,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration
apiConfiguration,
})
vscode.postMessage({ type: "mode", text: mode })
vscode.postMessage({ type: "experimentalDiffStrategy", bool: experimentalDiffStrategy })
vscode.postMessage({ type: "experimentalDiffStrategy", bool: experimentalDiffStrategy })
onDone()
}
}
@@ -135,7 +141,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setCommandInput("")
vscode.postMessage({
type: "allowedCommands",
commands: newCommands
commands: newCommands,
})
}
}
@@ -161,53 +167,53 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
marginBottom: "17px",
paddingRight: 17,
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleSubmit}>Done</VSCodeButton>
</div>
<div
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
<div style={{ marginBottom: 5 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Provider Settings</h3>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>
Provider Settings
</h3>
<ApiConfigManager
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
onSelectConfig={(configName: string) => {
vscode.postMessage({
type: "loadApiConfiguration",
text: configName
text: configName,
})
}}
onDeleteConfig={(configName: string) => {
vscode.postMessage({
type: "deleteApiConfiguration",
text: configName
text: configName,
})
}}
onRenameConfig={(oldName: string, newName: string) => {
vscode.postMessage({
type: "renameApiConfiguration",
values: { oldName, newName },
apiConfiguration
apiConfiguration,
})
}}
onUpsertConfig={(configName: string) => {
vscode.postMessage({
type: "upsertApiConfiguration",
text: configName,
apiConfiguration
apiConfiguration,
})
}}
/>
<ApiOptions
apiErrorMessage={apiErrorMessage}
modelIdErrorMessage={modelIdErrorMessage}
/>
<ApiOptions apiErrorMessage={apiErrorMessage} modelIdErrorMessage={modelIdErrorMessage} />
</div>
<div style={{ marginBottom: 5 }}>
<div style={{ marginBottom: 15 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Agent Settings</h3>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>
Agent Settings
</h3>
<div style={{ marginBottom: 15 }}>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Agent Mode</label>
@@ -225,22 +231,27 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
color: "var(--vscode-input-foreground)",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
height: "28px"
height: "28px",
}}>
<option value="code">Code</option>
<option value="architect">Architect</option>
<option value="ask">Ask</option>
</select>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Select the mode that best fits your needs. Code mode focuses on implementation details, Architect mode on high-level design, and Ask mode on asking questions about the codebase.
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Select the mode that best fits your needs. Code mode focuses on implementation details,
Architect mode on high-level design, and Ask mode on asking questions about the
codebase.
</p>
</div>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Preferred Language</label>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
Preferred Language
</label>
<select
value={preferredLanguage}
onChange={(e) => setPreferredLanguage(e.target.value)}
@@ -251,7 +262,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
color: "var(--vscode-input-foreground)",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
height: "28px"
height: "28px",
}}>
<option value="English">English</option>
<option value="Arabic">Arabic - العربية</option>
@@ -272,11 +283,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<option value="Traditional Chinese">Traditional Chinese - </option>
<option value="Turkish">Turkish - Türkçe</option>
</select>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Select the language that Cline should use for communication.
</p>
</div>
@@ -298,7 +310,11 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
These instructions are added to the end of the system prompt sent with every request. Custom instructions set in .clinerules in the working directory are also included. For mode-specific instructions, use the <span className="codicon codicon-notebook" style={{ fontSize: "10px" }}></span> Prompts tab in the top menu.
These instructions are added to the end of the system prompt sent with every request. Custom
instructions set in .clinerules in the working directory are also included. For
mode-specific instructions, use the{" "}
<span className="codicon codicon-notebook" style={{ fontSize: "10px" }}></span> Prompts tab
in the top menu.
</p>
</div>
@@ -306,8 +322,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</div>
<div style={{ marginBottom: 5 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ fontWeight: "500", minWidth: '150px' }}>Terminal output limit</span>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ fontWeight: "500", minWidth: "150px" }}>Terminal output limit</span>
<input
type="range"
min="100"
@@ -317,27 +333,28 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
onChange={(e) => setTerminalOutputLineLimit(parseInt(e.target.value))}
style={{
flexGrow: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
accentColor: "var(--vscode-button-background)",
height: "2px",
}}
/>
<span style={{ minWidth: '45px', textAlign: 'left' }}>
{terminalOutputLineLimit ?? 500}
</span>
<span style={{ minWidth: "45px", textAlign: "left" }}>{terminalOutputLineLimit ?? 500}</span>
</div>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Maximum number of lines to include in terminal output when executing commands. When exceeded lines will be removed from the middle, saving tokens.
Maximum number of lines to include in terminal output when executing commands. When exceeded
lines will be removed from the middle, saving tokens.
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox checked={diffEnabled} onChange={(e: any) => {
setDiffEnabled(e.target.checked)
if (!e.target.checked) {
// Reset experimental strategy when diffs are disabled
setExperimentalDiffStrategy(false)
}
}}>
<VSCodeCheckbox
checked={diffEnabled}
onChange={(e: any) => {
setDiffEnabled(e.target.checked)
if (!e.target.checked) {
// Reset experimental strategy when diffs are disabled
setExperimentalDiffStrategy(false)
}
}}>
<span style={{ fontWeight: "500" }}>Enable editing through diffs</span>
</VSCodeCheckbox>
<p
@@ -346,12 +363,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will be able to edit files more quickly and will automatically reject truncated full-file writes. Works best with the latest Claude 3.5 Sonnet model.
When enabled, Cline will be able to edit files more quickly and will automatically reject
truncated full-file writes. Works best with the latest Claude 3.5 Sonnet model.
</p>
{diffEnabled && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ color: "var(--vscode-errorForeground)" }}></span>
<VSCodeCheckbox
checked={experimentalDiffStrategy}
@@ -359,13 +377,19 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<span style={{ fontWeight: "500" }}>Use experimental unified diff strategy</span>
</VSCodeCheckbox>
</div>
<p style={{ fontSize: "12px", marginBottom: 15, color: "var(--vscode-descriptionForeground)" }}>
Enable the experimental unified diff strategy. This strategy might reduce the number of retries caused by model errors but may cause unexpected behavior or incorrect edits.
<p
style={{
fontSize: "12px",
marginBottom: 15,
color: "var(--vscode-descriptionForeground)",
}}>
Enable the experimental unified diff strategy. This strategy might reduce the number of
retries caused by model errors but may cause unexpected behavior or incorrect edits.
Only enable if you understand the risks and are willing to carefully review all changes.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ fontWeight: "500", minWidth: '100px' }}>Match precision</span>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ fontWeight: "500", minWidth: "100px" }}>Match precision</span>
<input
type="range"
min="0.8"
@@ -373,20 +397,27 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
step="0.005"
value={fuzzyMatchThreshold ?? 1.0}
onChange={(e) => {
setFuzzyMatchThreshold(parseFloat(e.target.value));
setFuzzyMatchThreshold(parseFloat(e.target.value))
}}
style={{
flexGrow: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
accentColor: "var(--vscode-button-background)",
height: "2px",
}}
/>
<span style={{ minWidth: '35px', textAlign: 'left' }}>
<span style={{ minWidth: "35px", textAlign: "left" }}>
{Math.round((fuzzyMatchThreshold || 1) * 100)}%
</span>
</div>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
This slider controls how precisely code sections must match when applying diffs. Lower values allow more flexible matching but increase the risk of incorrect replacements. Use values below 100% with extreme caution.
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
This slider controls how precisely code sections must match when applying diffs. Lower
values allow more flexible matching but increase the risk of incorrect replacements. Use
values below 100% with extreme caution.
</p>
</div>
)}
@@ -409,11 +440,20 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
</div>
<div style={{ marginBottom: 15, border: "2px solid var(--vscode-errorForeground)", borderRadius: "4px", padding: "10px" }}>
<h4 style={{ fontWeight: 500, margin: "0 0 10px 0", color: "var(--vscode-errorForeground)" }}> High-Risk Auto-Approve Settings</h4>
<div
style={{
marginBottom: 15,
border: "2px solid var(--vscode-errorForeground)",
borderRadius: "4px",
padding: "10px",
}}>
<h4 style={{ fontWeight: 500, margin: "0 0 10px 0", color: "var(--vscode-errorForeground)" }}>
High-Risk Auto-Approve Settings
</h4>
<p style={{ fontSize: "12px", marginBottom: 15, color: "var(--vscode-descriptionForeground)" }}>
The following settings allow Cline to automatically perform potentially dangerous operations without requiring approval.
Enable these settings only if you fully trust the AI and understand the associated security risks.
The following settings allow Cline to automatically perform potentially dangerous operations
without requiring approval. Enable these settings only if you fully trust the AI and understand
the associated security risks.
</p>
<div style={{ marginBottom: 5 }}>
@@ -427,7 +467,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
{alwaysAllowWrite && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<input
type="range"
min="0"
@@ -437,15 +477,18 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
onChange={(e) => setWriteDelayMs(parseInt(e.target.value))}
style={{
flex: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
accentColor: "var(--vscode-button-background)",
height: "2px",
}}
/>
<span style={{ minWidth: '45px', textAlign: 'left' }}>
{writeDelayMs}ms
</span>
<span style={{ minWidth: "45px", textAlign: "left" }}>{writeDelayMs}ms</span>
</div>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Delay after writes to allow diagnostics to detect potential problems
</p>
</div>
@@ -459,7 +502,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Automatically perform browser actions without requiring approval<br />
Automatically perform browser actions without requiring approval
<br />
Note: Only applies when the model supports computer use
</p>
</div>
@@ -475,7 +519,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
{alwaysApproveResubmit && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<input
type="range"
min="0"
@@ -485,15 +529,18 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
onChange={(e) => setRequestDelaySeconds(parseInt(e.target.value))}
style={{
flex: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
accentColor: "var(--vscode-button-background)",
height: "2px",
}}
/>
<span style={{ minWidth: '45px', textAlign: 'left' }}>
{requestDelaySeconds}s
</span>
<span style={{ minWidth: "45px", textAlign: "left" }}>{requestDelaySeconds}s</span>
</div>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Delay before retrying the request
</p>
</div>
@@ -507,7 +554,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Enable auto-approval of individual MCP tools in the MCP Servers view (requires both this setting and the tool's individual "Always allow" checkbox)
Enable auto-approval of individual MCP tools in the MCP Servers view (requires both this
setting and the tool's individual "Always allow" checkbox)
</p>
</div>
@@ -524,20 +572,22 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
{alwaysAllowExecute && (
<div style={{ marginTop: 10 }}>
<span style={{ fontWeight: "500" }}>Allowed Auto-Execute Commands</span>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Command prefixes that can be auto-executed when "Always approve execute operations" is enabled.
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Command prefixes that can be auto-executed when "Always approve execute operations"
is enabled.
</p>
<div style={{ display: 'flex', gap: '5px', marginTop: '10px' }}>
<div style={{ display: "flex", gap: "5px", marginTop: "10px" }}>
<VSCodeTextField
value={commandInput}
onInput={(e: any) => setCommandInput(e.target.value)}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
e.preventDefault()
handleAddCommand()
}
@@ -545,51 +595,53 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
placeholder="Enter command prefix (e.g., 'git ')"
style={{ flexGrow: 1 }}
/>
<VSCodeButton onClick={handleAddCommand}>
Add
</VSCodeButton>
<VSCodeButton onClick={handleAddCommand}>Add</VSCodeButton>
</div>
<div style={{
marginTop: '10px',
display: 'flex',
flexWrap: 'wrap',
gap: '5px'
}}>
<div
style={{
marginTop: "10px",
display: "flex",
flexWrap: "wrap",
gap: "5px",
}}>
{(allowedCommands ?? []).map((cmd, index) => (
<div key={index} style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
backgroundColor: 'var(--vscode-button-secondaryBackground)',
padding: '2px 6px',
borderRadius: '4px',
border: '1px solid var(--vscode-button-secondaryBorder)',
height: '24px'
}}>
<div
key={index}
style={{
display: "flex",
alignItems: "center",
gap: "5px",
backgroundColor: "var(--vscode-button-secondaryBackground)",
padding: "2px 6px",
borderRadius: "4px",
border: "1px solid var(--vscode-button-secondaryBorder)",
height: "24px",
}}>
<span>{cmd}</span>
<VSCodeButton
appearance="icon"
style={{
padding: 0,
margin: 0,
height: '20px',
width: '20px',
minWidth: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--vscode-button-foreground)',
height: "20px",
width: "20px",
minWidth: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--vscode-button-foreground)",
}}
onClick={() => {
const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index)
const newCommands = (allowedCommands ?? []).filter(
(_, i) => i !== index,
)
setAllowedCommands(newCommands)
vscode.postMessage({
type: "allowedCommands",
commands: newCommands
commands: newCommands,
})
}}
>
}}>
<span className="codicon codicon-close" />
</VSCodeButton>
</div>
@@ -603,8 +655,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 5 }}>
<div style={{ marginBottom: 10 }}>
<div style={{ marginBottom: 15 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Browser Settings</h3>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Viewport size</label>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>
Browser Settings
</h3>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
Viewport size
</label>
<select
value={browserViewportSize}
onChange={(e) => setBrowserViewportSize(e.target.value)}
@@ -615,25 +671,27 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
color: "var(--vscode-input-foreground)",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
height: "28px"
height: "28px",
}}>
<option value="1280x800">Large Desktop (1280x800)</option>
<option value="900x600">Small Desktop (900x600)</option>
<option value="768x1024">Tablet (768x1024)</option>
<option value="360x640">Mobile (360x640)</option>
</select>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Select the viewport size for browser interactions. This affects how websites are displayed and interacted with.
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Select the viewport size for browser interactions. This affects how websites are
displayed and interacted with.
</p>
</div>
<div style={{ marginBottom: 15 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ fontWeight: "500", minWidth: '100px' }}>Screenshot quality</span>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ fontWeight: "500", minWidth: "100px" }}>Screenshot quality</span>
<input
type="range"
min="1"
@@ -643,28 +701,32 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
onChange={(e) => setScreenshotQuality(parseInt(e.target.value))}
style={{
flexGrow: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
accentColor: "var(--vscode-button-background)",
height: "2px",
}}
/>
<span style={{ minWidth: '35px', textAlign: 'left' }}>
{screenshotQuality ?? 75}%
</span>
<span style={{ minWidth: "35px", textAlign: "left" }}>{screenshotQuality ?? 75}%</span>
</div>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Adjust the WebP quality of browser screenshots. Higher values provide clearer screenshots but increase token usage.
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Adjust the WebP quality of browser screenshots. Higher values provide clearer
screenshots but increase token usage.
</p>
</div>
</div>
<div style={{ marginBottom: 5 }}>
<div style={{ marginBottom: 10 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Notification Settings</h3>
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>
Notification Settings
</h3>
<VSCodeCheckbox
checked={soundEnabled}
onChange={(e: any) => setSoundEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable sound effects</span>
</VSCodeCheckbox>
<p
@@ -678,8 +740,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</div>
{soundEnabled && (
<div style={{ marginLeft: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ fontWeight: "500", minWidth: '100px' }}>Volume</span>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ fontWeight: "500", minWidth: "100px" }}>Volume</span>
<input
type="range"
min="0"
@@ -689,12 +751,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
onChange={(e) => setSoundVolume(parseFloat(e.target.value))}
style={{
flexGrow: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
accentColor: "var(--vscode-button-background)",
height: "2px",
}}
aria-label="Volume"
/>
<span style={{ minWidth: '35px', textAlign: 'left' }}>
<span style={{ minWidth: "35px", textAlign: "left" }}>
{((soundVolume ?? 0.5) * 100).toFixed(0)}%
</span>
</div>
@@ -733,7 +795,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
If you have any questions or feedback, feel free to open an issue at{" "}
<VSCodeLink href="https://github.com/RooVetGit/Roo-Cline" style={{ display: "inline" }}>
github.com/RooVetGit/Roo-Cline
</VSCodeLink> or join {" "}
</VSCodeLink>{" "}
or join{" "}
<VSCodeLink href="https://www.reddit.com/r/roocline/" style={{ display: "inline" }}>
reddit.com/r/roocline
</VSCodeLink>

View File

@@ -1,154 +1,136 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import ApiConfigManager from '../ApiConfigManager';
import { render, screen, fireEvent } from "@testing-library/react"
import "@testing-library/jest-dom"
import ApiConfigManager from "../ApiConfigManager"
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick, title, disabled }: any) => (
<button onClick={onClick} title={title} disabled={disabled}>
{children}
</button>
),
VSCodeTextField: ({ value, onInput, placeholder }: any) => (
<input
value={value}
onChange={e => onInput(e)}
placeholder={placeholder}
ref={undefined} // Explicitly set ref to undefined to avoid warning
/>
),
}));
jest.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: ({ children, onClick, title, disabled }: any) => (
<button onClick={onClick} title={title} disabled={disabled}>
{children}
</button>
),
VSCodeTextField: ({ value, onInput, placeholder }: any) => (
<input
value={value}
onChange={(e) => onInput(e)}
placeholder={placeholder}
ref={undefined} // Explicitly set ref to undefined to avoid warning
/>
),
}))
describe('ApiConfigManager', () => {
const mockOnSelectConfig = jest.fn();
const mockOnDeleteConfig = jest.fn();
const mockOnRenameConfig = jest.fn();
const mockOnUpsertConfig = jest.fn();
describe("ApiConfigManager", () => {
const mockOnSelectConfig = jest.fn()
const mockOnDeleteConfig = jest.fn()
const mockOnRenameConfig = jest.fn()
const mockOnUpsertConfig = jest.fn()
const defaultProps = {
currentApiConfigName: 'Default Config',
listApiConfigMeta: [
{ name: 'Default Config' },
{ name: 'Another Config' }
],
onSelectConfig: mockOnSelectConfig,
onDeleteConfig: mockOnDeleteConfig,
onRenameConfig: mockOnRenameConfig,
onUpsertConfig: mockOnUpsertConfig,
};
const defaultProps = {
currentApiConfigName: "Default Config",
listApiConfigMeta: [{ name: "Default Config" }, { name: "Another Config" }],
onSelectConfig: mockOnSelectConfig,
onDeleteConfig: mockOnDeleteConfig,
onRenameConfig: mockOnRenameConfig,
onUpsertConfig: mockOnUpsertConfig,
}
beforeEach(() => {
jest.clearAllMocks();
});
beforeEach(() => {
jest.clearAllMocks()
})
it('immediately creates a copy when clicking add button', () => {
render(<ApiConfigManager {...defaultProps} />);
it("immediately creates a copy when clicking add button", () => {
render(<ApiConfigManager {...defaultProps} />)
// Find and click the add button
const addButton = screen.getByTitle('Add profile');
fireEvent.click(addButton);
// Find and click the add button
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
// Verify that onUpsertConfig was called with the correct name
expect(mockOnUpsertConfig).toHaveBeenCalledTimes(1);
expect(mockOnUpsertConfig).toHaveBeenCalledWith('Default Config (copy)');
});
// Verify that onUpsertConfig was called with the correct name
expect(mockOnUpsertConfig).toHaveBeenCalledTimes(1)
expect(mockOnUpsertConfig).toHaveBeenCalledWith("Default Config (copy)")
})
it('creates copy with correct name when current config has spaces', () => {
render(
<ApiConfigManager
{...defaultProps}
currentApiConfigName="My Test Config"
/>
);
it("creates copy with correct name when current config has spaces", () => {
render(<ApiConfigManager {...defaultProps} currentApiConfigName="My Test Config" />)
const addButton = screen.getByTitle('Add profile');
fireEvent.click(addButton);
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
expect(mockOnUpsertConfig).toHaveBeenCalledWith('My Test Config (copy)');
});
expect(mockOnUpsertConfig).toHaveBeenCalledWith("My Test Config (copy)")
})
it('handles empty current config name gracefully', () => {
render(
<ApiConfigManager
{...defaultProps}
currentApiConfigName=""
/>
);
it("handles empty current config name gracefully", () => {
render(<ApiConfigManager {...defaultProps} currentApiConfigName="" />)
const addButton = screen.getByTitle('Add profile');
fireEvent.click(addButton);
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
expect(mockOnUpsertConfig).toHaveBeenCalledWith(' (copy)');
});
expect(mockOnUpsertConfig).toHaveBeenCalledWith(" (copy)")
})
it('allows renaming the current config', () => {
render(<ApiConfigManager {...defaultProps} />);
// Start rename
const renameButton = screen.getByTitle('Rename profile');
fireEvent.click(renameButton);
it("allows renaming the current config", () => {
render(<ApiConfigManager {...defaultProps} />)
// Find input and enter new name
const input = screen.getByDisplayValue('Default Config');
fireEvent.input(input, { target: { value: 'New Name' } });
// Start rename
const renameButton = screen.getByTitle("Rename profile")
fireEvent.click(renameButton)
// Save
const saveButton = screen.getByTitle('Save');
fireEvent.click(saveButton);
// Find input and enter new name
const input = screen.getByDisplayValue("Default Config")
fireEvent.input(input, { target: { value: "New Name" } })
expect(mockOnRenameConfig).toHaveBeenCalledWith('Default Config', 'New Name');
});
// Save
const saveButton = screen.getByTitle("Save")
fireEvent.click(saveButton)
it('allows selecting a different config', () => {
render(<ApiConfigManager {...defaultProps} />);
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: 'Another Config' } });
expect(mockOnRenameConfig).toHaveBeenCalledWith("Default Config", "New Name")
})
expect(mockOnSelectConfig).toHaveBeenCalledWith('Another Config');
});
it("allows selecting a different config", () => {
render(<ApiConfigManager {...defaultProps} />)
it('allows deleting the current config when not the only one', () => {
render(<ApiConfigManager {...defaultProps} />);
const deleteButton = screen.getByTitle('Delete profile');
expect(deleteButton).not.toBeDisabled();
fireEvent.click(deleteButton);
expect(mockOnDeleteConfig).toHaveBeenCalledWith('Default Config');
});
const select = screen.getByRole("combobox")
fireEvent.change(select, { target: { value: "Another Config" } })
it('disables delete button when only one config exists', () => {
render(
<ApiConfigManager
{...defaultProps}
listApiConfigMeta={[{ name: 'Default Config' }]}
/>
);
const deleteButton = screen.getByTitle('Cannot delete the only profile');
expect(deleteButton).toHaveAttribute('disabled');
});
expect(mockOnSelectConfig).toHaveBeenCalledWith("Another Config")
})
it('cancels rename operation when clicking cancel', () => {
render(<ApiConfigManager {...defaultProps} />);
// Start rename
const renameButton = screen.getByTitle('Rename profile');
fireEvent.click(renameButton);
it("allows deleting the current config when not the only one", () => {
render(<ApiConfigManager {...defaultProps} />)
// Find input and enter new name
const input = screen.getByDisplayValue('Default Config');
fireEvent.input(input, { target: { value: 'New Name' } });
const deleteButton = screen.getByTitle("Delete profile")
expect(deleteButton).not.toBeDisabled()
// Cancel
const cancelButton = screen.getByTitle('Cancel');
fireEvent.click(cancelButton);
fireEvent.click(deleteButton)
expect(mockOnDeleteConfig).toHaveBeenCalledWith("Default Config")
})
// Verify rename was not called
expect(mockOnRenameConfig).not.toHaveBeenCalled();
// Verify we're back to normal view
expect(screen.queryByDisplayValue('New Name')).not.toBeInTheDocument();
});
});
it("disables delete button when only one config exists", () => {
render(<ApiConfigManager {...defaultProps} listApiConfigMeta={[{ name: "Default Config" }]} />)
const deleteButton = screen.getByTitle("Cannot delete the only profile")
expect(deleteButton).toHaveAttribute("disabled")
})
it("cancels rename operation when clicking cancel", () => {
render(<ApiConfigManager {...defaultProps} />)
// Start rename
const renameButton = screen.getByTitle("Rename profile")
fireEvent.click(renameButton)
// Find input and enter new name
const input = screen.getByDisplayValue("Default Config")
fireEvent.input(input, { target: { value: "New Name" } })
// Cancel
const cancelButton = screen.getByTitle("Cancel")
fireEvent.click(cancelButton)
// Verify rename was not called
expect(mockOnRenameConfig).not.toHaveBeenCalled()
// Verify we're back to normal view
expect(screen.queryByDisplayValue("New Name")).not.toBeInTheDocument()
})
})

View File

@@ -1,336 +1,340 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import SettingsView from '../SettingsView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import SettingsView from "../SettingsView"
import { ExtensionStateContextProvider } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn(),
},
jest.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
// Mock ApiConfigManager component
jest.mock('../ApiConfigManager', () => ({
__esModule: true,
default: ({ currentApiConfigName, listApiConfigMeta, onSelectConfig, onDeleteConfig, onRenameConfig, onUpsertConfig }: any) => (
<div data-testid="api-config-management">
<span>Current config: {currentApiConfigName}</span>
</div>
)
jest.mock("../ApiConfigManager", () => ({
__esModule: true,
default: ({
currentApiConfigName,
listApiConfigMeta,
onSelectConfig,
onDeleteConfig,
onRenameConfig,
onUpsertConfig,
}: any) => (
<div data-testid="api-config-management">
<span>Current config: {currentApiConfigName}</span>
</div>
),
}))
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick, appearance }: any) => (
appearance === 'icon' ?
<button onClick={onClick} className="codicon codicon-close" aria-label="Remove command">
<span className="codicon codicon-close" />
</button> :
<button onClick={onClick} data-appearance={appearance}>{children}</button>
),
VSCodeCheckbox: ({ children, onChange, checked }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
aria-label={typeof children === 'string' ? children : undefined}
/>
{children}
</label>
),
VSCodeTextField: ({ value, onInput, placeholder }: any) => (
<input
type="text"
value={value}
onChange={(e) => onInput({ target: { value: e.target.value } })}
placeholder={placeholder}
/>
),
VSCodeTextArea: () => <textarea />,
VSCodeLink: ({ children, href }: any) => <a href={href || '#'}>{children}</a>,
VSCodeDropdown: ({ children, value, onChange }: any) => (
<select value={value} onChange={onChange}>
{children}
</select>
),
VSCodeOption: ({ children, value }: any) => (
<option value={value}>{children}</option>
),
VSCodeRadio: ({ children, value, checked, onChange }: any) => (
<input
type="radio"
value={value}
checked={checked}
onChange={onChange}
/>
),
VSCodeRadioGroup: ({ children, value, onChange }: any) => (
<div onChange={onChange}>
{children}
</div>
),
VSCodeSlider: ({ value, onChange }: any) => (
<input
type="range"
value={value}
onChange={(e) => onChange({ target: { value: Number(e.target.value) } })}
min={0}
max={1}
step={0.01}
style={{ flexGrow: 1, height: '2px' }}
/>
)
jest.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: ({ children, onClick, appearance }: any) =>
appearance === "icon" ? (
<button onClick={onClick} className="codicon codicon-close" aria-label="Remove command">
<span className="codicon codicon-close" />
</button>
) : (
<button onClick={onClick} data-appearance={appearance}>
{children}
</button>
),
VSCodeCheckbox: ({ children, onChange, checked }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
aria-label={typeof children === "string" ? children : undefined}
/>
{children}
</label>
),
VSCodeTextField: ({ value, onInput, placeholder }: any) => (
<input
type="text"
value={value}
onChange={(e) => onInput({ target: { value: e.target.value } })}
placeholder={placeholder}
/>
),
VSCodeTextArea: () => <textarea />,
VSCodeLink: ({ children, href }: any) => <a href={href || "#"}>{children}</a>,
VSCodeDropdown: ({ children, value, onChange }: any) => (
<select value={value} onChange={onChange}>
{children}
</select>
),
VSCodeOption: ({ children, value }: any) => <option value={value}>{children}</option>,
VSCodeRadio: ({ children, value, checked, onChange }: any) => (
<input type="radio" value={value} checked={checked} onChange={onChange} />
),
VSCodeRadioGroup: ({ children, value, onChange }: any) => <div onChange={onChange}>{children}</div>,
VSCodeSlider: ({ value, onChange }: any) => (
<input
type="range"
value={value}
onChange={(e) => onChange({ target: { value: Number(e.target.value) } })}
min={0}
max={1}
step={0.01}
style={{ flexGrow: 1, height: "2px" }}
/>
),
}))
// 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,
soundEnabled: false,
soundVolume: 0.5,
...state
}
}, '*')
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
soundEnabled: false,
soundVolume: 0.5,
...state,
},
},
"*",
)
}
const renderSettingsView = () => {
const onDone = jest.fn()
render(
<ExtensionStateContextProvider>
<SettingsView onDone={onDone} />
</ExtensionStateContextProvider>
)
// Hydrate initial state
mockPostMessage({})
return { onDone }
const onDone = jest.fn()
render(
<ExtensionStateContextProvider>
<SettingsView onDone={onDone} />
</ExtensionStateContextProvider>,
)
// Hydrate initial state
mockPostMessage({})
return { onDone }
}
describe('SettingsView - Sound Settings', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("SettingsView - Sound Settings", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('initializes with sound disabled by default', () => {
renderSettingsView()
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
expect(soundCheckbox).not.toBeChecked()
// Volume slider should not be visible when sound is disabled
expect(screen.queryByRole('slider', { name: /volume/i })).not.toBeInTheDocument()
})
it("initializes with sound disabled by default", () => {
renderSettingsView()
it('toggles sound setting and sends message to VSCode', () => {
renderSettingsView()
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
// Enable sound
fireEvent.click(soundCheckbox)
expect(soundCheckbox).toBeChecked()
// Click Done to save settings
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'soundEnabled',
bool: true
})
)
})
const soundCheckbox = screen.getByRole("checkbox", {
name: /Enable sound effects/i,
})
expect(soundCheckbox).not.toBeChecked()
it('shows volume slider when sound is enabled', () => {
renderSettingsView()
// Enable sound
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
fireEvent.click(soundCheckbox)
// Volume slider should not be visible when sound is disabled
expect(screen.queryByRole("slider", { name: /volume/i })).not.toBeInTheDocument()
})
// Volume slider should be visible
const volumeSlider = screen.getByRole('slider', { name: /volume/i })
expect(volumeSlider).toBeInTheDocument()
expect(volumeSlider).toHaveValue('0.5')
})
it("toggles sound setting and sends message to VSCode", () => {
renderSettingsView()
it('updates volume and sends message to VSCode when slider changes', () => {
renderSettingsView()
// Enable sound
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
fireEvent.click(soundCheckbox)
const soundCheckbox = screen.getByRole("checkbox", {
name: /Enable sound effects/i,
})
// Change volume
const volumeSlider = screen.getByRole('slider', { name: /volume/i })
fireEvent.change(volumeSlider, { target: { value: '0.75' } })
// Enable sound
fireEvent.click(soundCheckbox)
expect(soundCheckbox).toBeChecked()
// Click Done to save settings
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
// Click Done to save settings
const doneButton = screen.getByText("Done")
fireEvent.click(doneButton)
// Verify message sent to VSCode
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'soundVolume',
value: 0.75
})
})
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "soundEnabled",
bool: true,
}),
)
})
it("shows volume slider when sound is enabled", () => {
renderSettingsView()
// Enable sound
const soundCheckbox = screen.getByRole("checkbox", {
name: /Enable sound effects/i,
})
fireEvent.click(soundCheckbox)
// Volume slider should be visible
const volumeSlider = screen.getByRole("slider", { name: /volume/i })
expect(volumeSlider).toBeInTheDocument()
expect(volumeSlider).toHaveValue("0.5")
})
it("updates volume and sends message to VSCode when slider changes", () => {
renderSettingsView()
// Enable sound
const soundCheckbox = screen.getByRole("checkbox", {
name: /Enable sound effects/i,
})
fireEvent.click(soundCheckbox)
// Change volume
const volumeSlider = screen.getByRole("slider", { name: /volume/i })
fireEvent.change(volumeSlider, { target: { value: "0.75" } })
// Click Done to save settings
const doneButton = screen.getByText("Done")
fireEvent.click(doneButton)
// Verify message sent to VSCode
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "soundVolume",
value: 0.75,
})
})
})
describe('SettingsView - API Configuration', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("SettingsView - API Configuration", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders ApiConfigManagement with correct props', () => {
renderSettingsView()
expect(screen.getByTestId('api-config-management')).toBeInTheDocument()
})
it("renders ApiConfigManagement with correct props", () => {
renderSettingsView()
expect(screen.getByTestId("api-config-management")).toBeInTheDocument()
})
})
describe('SettingsView - Allowed Commands', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("SettingsView - Allowed Commands", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('shows allowed commands section when alwaysAllowExecute is enabled', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
it("shows allowed commands section when alwaysAllowExecute is enabled", () => {
renderSettingsView()
// Verify allowed commands section appears
expect(screen.getByText(/Allowed Auto-Execute Commands/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/Enter command prefix/i)).toBeInTheDocument()
})
// Enable always allow execute
const executeCheckbox = screen.getByRole("checkbox", {
name: /Always approve allowed execute operations/i,
})
fireEvent.click(executeCheckbox)
it('adds new command to the list', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Verify allowed commands section appears
expect(screen.getByText(/Allowed Auto-Execute Commands/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/Enter command prefix/i)).toBeInTheDocument()
})
// Add a new command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
it("adds new command to the list", () => {
renderSettingsView()
// Verify command was added
expect(screen.getByText('npm test')).toBeInTheDocument()
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'allowedCommands',
commands: ['npm test']
})
})
// Enable always allow execute
const executeCheckbox = screen.getByRole("checkbox", {
name: /Always approve allowed execute operations/i,
})
fireEvent.click(executeCheckbox)
it('removes command from the list', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Add a new command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: "npm test" } })
// Add a command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
const addButton = screen.getByText("Add")
fireEvent.click(addButton)
// Remove the command
const removeButton = screen.getByRole('button', { name: 'Remove command' })
fireEvent.click(removeButton)
// Verify command was added
expect(screen.getByText("npm test")).toBeInTheDocument()
// Verify command was removed
expect(screen.queryByText('npm test')).not.toBeInTheDocument()
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: 'allowedCommands',
commands: []
})
})
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "allowedCommands",
commands: ["npm test"],
})
})
it('prevents duplicate commands', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
it("removes command from the list", () => {
renderSettingsView()
// Add a command twice
const input = screen.getByPlaceholderText(/Enter command prefix/i)
const addButton = screen.getByText('Add')
// Enable always allow execute
const executeCheckbox = screen.getByRole("checkbox", {
name: /Always approve allowed execute operations/i,
})
fireEvent.click(executeCheckbox)
// First addition
fireEvent.change(input, { target: { value: 'npm test' } })
fireEvent.click(addButton)
// Add a command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: "npm test" } })
const addButton = screen.getByText("Add")
fireEvent.click(addButton)
// Second addition attempt
fireEvent.change(input, { target: { value: 'npm test' } })
fireEvent.click(addButton)
// Remove the command
const removeButton = screen.getByRole("button", { name: "Remove command" })
fireEvent.click(removeButton)
// Verify command appears only once
const commands = screen.getAllByText('npm test')
expect(commands).toHaveLength(1)
})
// Verify command was removed
expect(screen.queryByText("npm test")).not.toBeInTheDocument()
it('saves allowed commands when clicking Done', () => {
const { onDone } = renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: "allowedCommands",
commands: [],
})
})
// Add a command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
it("prevents duplicate commands", () => {
renderSettingsView()
// Click Done
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
// Enable always allow execute
const executeCheckbox = screen.getByRole("checkbox", {
name: /Always approve allowed execute operations/i,
})
fireEvent.click(executeCheckbox)
// Verify VSCode messages were sent
expect(vscode.postMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'allowedCommands',
commands: ['npm test']
}))
expect(onDone).toHaveBeenCalled()
})
// Add a command twice
const input = screen.getByPlaceholderText(/Enter command prefix/i)
const addButton = screen.getByText("Add")
// First addition
fireEvent.change(input, { target: { value: "npm test" } })
fireEvent.click(addButton)
// Second addition attempt
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)
})
it("saves allowed commands when clicking Done", () => {
const { onDone } = renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole("checkbox", {
name: /Always approve allowed execute operations/i,
})
fireEvent.click(executeCheckbox)
// Add a command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: "npm test" } })
const addButton = screen.getByText("Add")
fireEvent.click(addButton)
// Click Done
const doneButton = screen.getByText("Done")
fireEvent.click(doneButton)
// Verify VSCode messages were sent
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "allowedCommands",
commands: ["npm test"],
}),
)
expect(onDone).toHaveBeenCalled()
})
})

View File

@@ -24,10 +24,10 @@ const WelcomeView = () => {
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, padding: "0 20px" }}>
<h2>Hi, I'm Cline</h2>
<p>
I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities
and access to tools that let me create & edit files, explore complex projects, use the browser, and
execute terminal commands (with your permission, of course). I can even use MCP to create new tools and
extend my own capabilities.
I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities and access
to tools that let me create & edit files, explore complex projects, use the browser, and execute
terminal commands (with your permission, of course). I can even use MCP to create new tools and extend
my own capabilities.
</p>
<b>To get started, this extension needs an API provider.</b>

View File

@@ -13,9 +13,7 @@ import { vscode } from "../utils/vscode"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { findLastIndex } from "../../../src/shared/array"
import { McpServer } from "../../../src/shared/mcp"
import {
checkExistKey
} from "../../../src/shared/checkExistApiConfig"
import { checkExistKey } from "../../../src/shared/checkExistApiConfig"
import { Mode } from "../../../src/core/prompts/types"
import { CustomPrompts, defaultModeSlug, defaultPrompts } from "../../../src/shared/modes"
@@ -25,7 +23,7 @@ export interface ExtensionStateContextType extends ExtensionState {
theme: any
glamaModels: Record<string, ModelInfo>
openRouterModels: Record<string, ModelInfo>
openAiModels: string[],
openAiModels: string[]
mcpServers: McpServer[]
filePaths: string[]
setApiConfiguration: (config: ApiConfiguration) => void
@@ -82,7 +80,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
soundVolume: 0.5,
diffEnabled: false,
fuzzyMatchThreshold: 1.0,
preferredLanguage: 'English',
preferredLanguage: "English",
writeDelayMs: 1000,
browserViewportSize: "900x600",
screenshotQuality: 75,
@@ -90,11 +88,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
mcpEnabled: true,
alwaysApproveResubmit: false,
requestDelaySeconds: 5,
currentApiConfigName: 'default',
currentApiConfigName: "default",
listApiConfigMeta: [],
mode: defaultModeSlug,
customPrompts: defaultPrompts,
enhancementApiConfigId: '',
enhancementApiConfigId: "",
experimentalDiffStrategy: false,
autoApprovalEnabled: false,
})
@@ -112,87 +110,95 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const setListApiConfigMeta = useCallback(
(value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
[setState],
)
const setListApiConfigMeta = useCallback((value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [setState])
const onUpdateApiConfig = useCallback(
(apiConfig: ApiConfiguration) => {
vscode.postMessage({
type: "upsertApiConfiguration",
text: state.currentApiConfigName,
apiConfiguration: apiConfig,
})
},
[state],
)
const onUpdateApiConfig = useCallback((apiConfig: ApiConfiguration) => {
vscode.postMessage({
type: "upsertApiConfiguration",
text: state.currentApiConfigName,
apiConfiguration: apiConfig,
})
}, [state])
const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
switch (message.type) {
case "state": {
const newState = message.state!
setState(prevState => ({
...prevState,
...newState
}))
const config = newState.apiConfiguration
const hasKey = checkExistKey(config)
setShowWelcome(!hasKey)
setDidHydrateState(true)
break
}
case "theme": {
if (message.text) {
setTheme(convertTextMateToHljs(JSON.parse(message.text)))
const handleMessage = useCallback(
(event: MessageEvent) => {
const message: ExtensionMessage = event.data
switch (message.type) {
case "state": {
const newState = message.state!
setState((prevState) => ({
...prevState,
...newState,
}))
const config = newState.apiConfiguration
const hasKey = checkExistKey(config)
setShowWelcome(!hasKey)
setDidHydrateState(true)
break
}
break
}
case "workspaceUpdated": {
setFilePaths(message.filePaths ?? [])
break
}
case "partialMessage": {
const partialMessage = message.partialMessage!
setState((prevState) => {
// worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock
const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === partialMessage.ts)
if (lastIndex !== -1) {
const newClineMessages = [...prevState.clineMessages]
newClineMessages[lastIndex] = partialMessage
return { ...prevState, clineMessages: newClineMessages }
case "theme": {
if (message.text) {
setTheme(convertTextMateToHljs(JSON.parse(message.text)))
}
return prevState
})
break
break
}
case "workspaceUpdated": {
setFilePaths(message.filePaths ?? [])
break
}
case "partialMessage": {
const partialMessage = message.partialMessage!
setState((prevState) => {
// worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock
const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === partialMessage.ts)
if (lastIndex !== -1) {
const newClineMessages = [...prevState.clineMessages]
newClineMessages[lastIndex] = partialMessage
return { ...prevState, clineMessages: newClineMessages }
}
return prevState
})
break
}
case "glamaModels": {
const updatedModels = message.glamaModels ?? {}
setGlamaModels({
[glamaDefaultModelId]: glamaDefaultModelInfo, // in case the extension sent a model list without the default model
...updatedModels,
})
break
}
case "openRouterModels": {
const updatedModels = message.openRouterModels ?? {}
setOpenRouterModels({
[openRouterDefaultModelId]: openRouterDefaultModelInfo, // in case the extension sent a model list without the default model
...updatedModels,
})
break
}
case "openAiModels": {
const updatedModels = message.openAiModels ?? []
setOpenAiModels(updatedModels)
break
}
case "mcpServers": {
setMcpServers(message.mcpServers ?? [])
break
}
case "listApiConfig": {
setListApiConfigMeta(message.listApiConfig ?? [])
break
}
}
case "glamaModels": {
const updatedModels = message.glamaModels ?? {}
setGlamaModels({
[glamaDefaultModelId]: glamaDefaultModelInfo, // in case the extension sent a model list without the default model
...updatedModels,
})
break
}
case "openRouterModels": {
const updatedModels = message.openRouterModels ?? {}
setOpenRouterModels({
[openRouterDefaultModelId]: openRouterDefaultModelInfo, // in case the extension sent a model list without the default model
...updatedModels,
})
break
}
case "openAiModels": {
const updatedModels = message.openAiModels ?? []
setOpenAiModels(updatedModels)
break
}
case "mcpServers": {
setMcpServers(message.mcpServers ?? [])
break
}
case "listApiConfig": {
setListApiConfigMeta(message.listApiConfig ?? [])
break
}
}
}, [setListApiConfigMeta])
},
[setListApiConfigMeta],
)
useEvent("message", handleMessage)
@@ -215,10 +221,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
writeDelayMs: state.writeDelayMs,
screenshotQuality: state.screenshotQuality,
experimentalDiffStrategy: state.experimentalDiffStrategy ?? false,
setApiConfiguration: (value) => setState((prevState) => ({
...prevState,
apiConfiguration: value
})),
setApiConfiguration: (value) =>
setState((prevState) => ({
...prevState,
apiConfiguration: value,
})),
setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })),
setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })),
setAlwaysAllowWrite: (value) => setState((prevState) => ({ ...prevState, alwaysAllowWrite: value })),
@@ -230,12 +237,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
setBrowserViewportSize: (value: string) => setState((prevState) => ({ ...prevState, browserViewportSize: value })),
setBrowserViewportSize: (value: string) =>
setState((prevState) => ({ ...prevState, browserViewportSize: value })),
setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),
setPreferredLanguage: (value) => setState((prevState) => ({ ...prevState, preferredLanguage: value })),
setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })),
setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })),
setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
setTerminalOutputLineLimit: (value) =>
setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })),
setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })),
setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })),
@@ -244,8 +253,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
onUpdateApiConfig,
setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })),
setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
setExperimentalDiffStrategy: (value) => setState((prevState) => ({ ...prevState, experimentalDiffStrategy: value })),
setEnhancementApiConfigId: (value) =>
setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
setExperimentalDiffStrategy: (value) =>
setState((prevState) => ({ ...prevState, experimentalDiffStrategy: value })),
setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
}

View File

@@ -1,71 +1,65 @@
import React from 'react'
import { render, screen, act } from '@testing-library/react'
import { ExtensionStateContextProvider, useExtensionState } from '../ExtensionStateContext'
import React from "react"
import { render, screen, act } from "@testing-library/react"
import { ExtensionStateContextProvider, useExtensionState } from "../ExtensionStateContext"
// Test component that consumes the context
const TestComponent = () => {
const { allowedCommands, setAllowedCommands, soundEnabled } = useExtensionState()
return (
<div>
<div data-testid="allowed-commands">{JSON.stringify(allowedCommands)}</div>
<div data-testid="sound-enabled">{JSON.stringify(soundEnabled)}</div>
<button
data-testid="update-button"
onClick={() => setAllowedCommands(['npm install', 'git status'])}
>
Update Commands
</button>
</div>
)
const { allowedCommands, setAllowedCommands, soundEnabled } = useExtensionState()
return (
<div>
<div data-testid="allowed-commands">{JSON.stringify(allowedCommands)}</div>
<div data-testid="sound-enabled">{JSON.stringify(soundEnabled)}</div>
<button data-testid="update-button" onClick={() => setAllowedCommands(["npm install", "git status"])}>
Update Commands
</button>
</div>
)
}
describe('ExtensionStateContext', () => {
it('initializes with empty allowedCommands array', () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>
)
describe("ExtensionStateContext", () => {
it("initializes with empty allowedCommands array", () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>,
)
expect(JSON.parse(screen.getByTestId('allowed-commands').textContent!)).toEqual([])
})
expect(JSON.parse(screen.getByTestId("allowed-commands").textContent!)).toEqual([])
})
it('initializes with soundEnabled set to false', () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>
)
it("initializes with soundEnabled set to false", () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>,
)
expect(JSON.parse(screen.getByTestId('sound-enabled').textContent!)).toBe(false)
})
expect(JSON.parse(screen.getByTestId("sound-enabled").textContent!)).toBe(false)
})
it('updates allowedCommands through setAllowedCommands', () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>
)
it("updates allowedCommands through setAllowedCommands", () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>,
)
act(() => {
screen.getByTestId('update-button').click()
})
act(() => {
screen.getByTestId("update-button").click()
})
expect(JSON.parse(screen.getByTestId('allowed-commands').textContent!)).toEqual([
'npm install',
'git status'
])
})
expect(JSON.parse(screen.getByTestId("allowed-commands").textContent!)).toEqual(["npm install", "git status"])
})
it('throws error when used outside provider', () => {
// Suppress console.error for this test since we expect an error
const consoleSpy = jest.spyOn(console, 'error')
consoleSpy.mockImplementation(() => {})
it("throws error when used outside provider", () => {
// Suppress console.error for this test since we expect an error
const consoleSpy = jest.spyOn(console, "error")
consoleSpy.mockImplementation(() => {})
expect(() => {
render(<TestComponent />)
}).toThrow('useExtensionState must be used within an ExtensionStateContextProvider')
expect(() => {
render(<TestComponent />)
}).toThrow("useExtensionState must be used within an ExtensionStateContextProvider")
consoleSpy.mockRestore()
})
consoleSpy.mockRestore()
})
})

View File

@@ -10,26 +10,26 @@ export interface GitCommit {
class GitService {
private commits: GitCommit[] | null = null
private lastQuery: string = ''
private lastQuery: string = ""
async searchCommits(query: string = ''): Promise<GitCommit[]> {
async searchCommits(query: string = ""): Promise<GitCommit[]> {
if (query === this.lastQuery && this.commits) {
return this.commits
}
// Request search from extension
vscode.postMessage({ type: 'searchCommits', query })
vscode.postMessage({ type: "searchCommits", query })
// Wait for response
const response = await new Promise<GitCommit[]>((resolve) => {
const handler = (event: MessageEvent) => {
const message = event.data
if (message.type === 'commitSearchResults') {
window.removeEventListener('message', handler)
if (message.type === "commitSearchResults") {
window.removeEventListener("message", handler)
resolve(message.commits)
}
}
window.addEventListener('message', handler)
window.addEventListener("message", handler)
})
this.commits = response
@@ -39,8 +39,8 @@ class GitService {
clearCache() {
this.commits = null
this.lastQuery = ''
this.lastQuery = ""
}
}
export const gitService = new GitService()
export const gitService = new GitService()

View File

@@ -1,28 +1,28 @@
import '@testing-library/jest-dom';
import "@testing-library/jest-dom"
// Mock crypto.getRandomValues
Object.defineProperty(window, 'crypto', {
value: {
getRandomValues: function(buffer: Uint8Array) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] = Math.floor(Math.random() * 256);
}
return buffer;
}
}
});
Object.defineProperty(window, "crypto", {
value: {
getRandomValues: function (buffer: Uint8Array) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] = Math.floor(Math.random() * 256)
}
return buffer
},
},
})
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})

View File

@@ -1,101 +1,110 @@
import { parseCommand, isAllowedSingleCommand, validateCommand } from '../command-validation'
import { parseCommand, isAllowedSingleCommand, validateCommand } from "../command-validation"
describe('Command Validation', () => {
describe('parseCommand', () => {
it('splits commands by chain operators', () => {
expect(parseCommand('npm test && npm run build')).toEqual(['npm test', 'npm run build'])
expect(parseCommand('npm test || npm run build')).toEqual(['npm test', 'npm run build'])
expect(parseCommand('npm test; npm run build')).toEqual(['npm test', 'npm run build'])
expect(parseCommand('npm test | npm run build')).toEqual(['npm test', 'npm run build'])
describe("Command Validation", () => {
describe("parseCommand", () => {
it("splits commands by chain operators", () => {
expect(parseCommand("npm test && npm run build")).toEqual(["npm test", "npm run build"])
expect(parseCommand("npm test || npm run build")).toEqual(["npm test", "npm run build"])
expect(parseCommand("npm test; npm run build")).toEqual(["npm test", "npm run build"])
expect(parseCommand("npm test | npm run build")).toEqual(["npm test", "npm run build"])
})
it('preserves quoted content', () => {
it("preserves quoted content", () => {
expect(parseCommand('npm test "param with | inside"')).toEqual(['npm test "param with | inside"'])
expect(parseCommand('echo "hello | world"')).toEqual(['echo "hello | world"'])
expect(parseCommand('npm test "param with && inside"')).toEqual(['npm test "param with && inside"'])
})
it('handles subshell patterns', () => {
expect(parseCommand('npm test $(echo test)')).toEqual(['npm test', 'echo test'])
expect(parseCommand('npm test `echo test`')).toEqual(['npm test', 'echo test'])
it("handles subshell patterns", () => {
expect(parseCommand("npm test $(echo test)")).toEqual(["npm test", "echo test"])
expect(parseCommand("npm test `echo test`")).toEqual(["npm test", "echo test"])
})
it('handles empty and whitespace input', () => {
expect(parseCommand('')).toEqual([])
expect(parseCommand(' ')).toEqual([])
expect(parseCommand('\t')).toEqual([])
it("handles empty and whitespace input", () => {
expect(parseCommand("")).toEqual([])
expect(parseCommand(" ")).toEqual([])
expect(parseCommand("\t")).toEqual([])
})
it('handles PowerShell specific patterns', () => {
expect(parseCommand('npm test 2>&1 | Select-String "Error"')).toEqual(['npm test 2>&1', 'Select-String "Error"'])
expect(parseCommand('npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"'))
.toEqual(['npm test', 'Select-String -NotMatch "node_modules"', 'Select-String "FAIL|Error"'])
it("handles PowerShell specific patterns", () => {
expect(parseCommand('npm test 2>&1 | Select-String "Error"')).toEqual([
"npm test 2>&1",
'Select-String "Error"',
])
expect(
parseCommand('npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"'),
).toEqual(["npm test", 'Select-String -NotMatch "node_modules"', 'Select-String "FAIL|Error"'])
})
})
describe('isAllowedSingleCommand', () => {
const allowedCommands = ['npm test', 'npm run', 'echo']
describe("isAllowedSingleCommand", () => {
const allowedCommands = ["npm test", "npm run", "echo"]
it('matches commands case-insensitively', () => {
expect(isAllowedSingleCommand('NPM TEST', allowedCommands)).toBe(true)
expect(isAllowedSingleCommand('npm TEST --coverage', allowedCommands)).toBe(true)
expect(isAllowedSingleCommand('ECHO hello', allowedCommands)).toBe(true)
it("matches commands case-insensitively", () => {
expect(isAllowedSingleCommand("NPM TEST", allowedCommands)).toBe(true)
expect(isAllowedSingleCommand("npm TEST --coverage", allowedCommands)).toBe(true)
expect(isAllowedSingleCommand("ECHO hello", allowedCommands)).toBe(true)
})
it('matches command prefixes', () => {
expect(isAllowedSingleCommand('npm test --coverage', allowedCommands)).toBe(true)
expect(isAllowedSingleCommand('npm run build', allowedCommands)).toBe(true)
it("matches command prefixes", () => {
expect(isAllowedSingleCommand("npm test --coverage", allowedCommands)).toBe(true)
expect(isAllowedSingleCommand("npm run build", allowedCommands)).toBe(true)
expect(isAllowedSingleCommand('echo "hello world"', allowedCommands)).toBe(true)
})
it('rejects non-matching commands', () => {
expect(isAllowedSingleCommand('npmtest', allowedCommands)).toBe(false)
expect(isAllowedSingleCommand('dangerous', allowedCommands)).toBe(false)
expect(isAllowedSingleCommand('rm -rf /', allowedCommands)).toBe(false)
it("rejects non-matching commands", () => {
expect(isAllowedSingleCommand("npmtest", allowedCommands)).toBe(false)
expect(isAllowedSingleCommand("dangerous", allowedCommands)).toBe(false)
expect(isAllowedSingleCommand("rm -rf /", allowedCommands)).toBe(false)
})
it('handles undefined/empty allowed commands', () => {
expect(isAllowedSingleCommand('npm test', undefined as any)).toBe(false)
expect(isAllowedSingleCommand('npm test', [])).toBe(false)
it("handles undefined/empty allowed commands", () => {
expect(isAllowedSingleCommand("npm test", undefined as any)).toBe(false)
expect(isAllowedSingleCommand("npm test", [])).toBe(false)
})
})
describe('validateCommand', () => {
const allowedCommands = ['npm test', 'npm run', 'echo', 'Select-String']
describe("validateCommand", () => {
const allowedCommands = ["npm test", "npm run", "echo", "Select-String"]
it('validates simple commands', () => {
expect(validateCommand('npm test', allowedCommands)).toBe(true)
expect(validateCommand('npm run build', allowedCommands)).toBe(true)
expect(validateCommand('dangerous', allowedCommands)).toBe(false)
it("validates simple commands", () => {
expect(validateCommand("npm test", allowedCommands)).toBe(true)
expect(validateCommand("npm run build", allowedCommands)).toBe(true)
expect(validateCommand("dangerous", allowedCommands)).toBe(false)
})
it('validates chained commands', () => {
expect(validateCommand('npm test && npm run build', allowedCommands)).toBe(true)
expect(validateCommand('npm test && dangerous', allowedCommands)).toBe(false)
it("validates chained commands", () => {
expect(validateCommand("npm test && npm run build", allowedCommands)).toBe(true)
expect(validateCommand("npm test && dangerous", allowedCommands)).toBe(false)
expect(validateCommand('npm test | Select-String "Error"', allowedCommands)).toBe(true)
expect(validateCommand('npm test | rm -rf /', allowedCommands)).toBe(false)
expect(validateCommand("npm test | rm -rf /", allowedCommands)).toBe(false)
})
it('handles quoted content correctly', () => {
it("handles quoted content correctly", () => {
expect(validateCommand('npm test "param with | inside"', allowedCommands)).toBe(true)
expect(validateCommand('echo "hello | world"', allowedCommands)).toBe(true)
expect(validateCommand('npm test "param with && inside"', allowedCommands)).toBe(true)
})
it('handles subshell execution attempts', () => {
expect(validateCommand('npm test $(echo dangerous)', allowedCommands)).toBe(false)
expect(validateCommand('npm test `rm -rf /`', allowedCommands)).toBe(false)
it("handles subshell execution attempts", () => {
expect(validateCommand("npm test $(echo dangerous)", allowedCommands)).toBe(false)
expect(validateCommand("npm test `rm -rf /`", allowedCommands)).toBe(false)
})
it('handles PowerShell patterns', () => {
it("handles PowerShell patterns", () => {
expect(validateCommand('npm test 2>&1 | Select-String "Error"', allowedCommands)).toBe(true)
expect(validateCommand('npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"', allowedCommands)).toBe(true)
expect(validateCommand('npm test | Select-String | dangerous', allowedCommands)).toBe(false)
expect(
validateCommand(
'npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"',
allowedCommands,
),
).toBe(true)
expect(validateCommand("npm test | Select-String | dangerous", allowedCommands)).toBe(false)
})
it('handles empty input', () => {
expect(validateCommand('', allowedCommands)).toBe(true)
expect(validateCommand(' ', allowedCommands)).toBe(true)
it("handles empty input", () => {
expect(validateCommand("", allowedCommands)).toBe(true)
expect(validateCommand(" ", allowedCommands)).toBe(true)
})
})
})
})

View File

@@ -1,130 +1,137 @@
import { insertMention, removeMention, getContextMenuOptions, shouldShowContextMenu, ContextMenuOptionType, ContextMenuQueryItem } from '../context-mentions'
import {
insertMention,
removeMention,
getContextMenuOptions,
shouldShowContextMenu,
ContextMenuOptionType,
ContextMenuQueryItem,
} from "../context-mentions"
describe('insertMention', () => {
it('should insert mention at cursor position when no @ symbol exists', () => {
const result = insertMention('Hello world', 5, 'test')
expect(result.newValue).toBe('Hello@test world')
describe("insertMention", () => {
it("should insert mention at cursor position when no @ symbol exists", () => {
const result = insertMention("Hello world", 5, "test")
expect(result.newValue).toBe("Hello@test world")
expect(result.mentionIndex).toBe(5)
})
it('should replace text after last @ symbol', () => {
const result = insertMention('Hello @wor world', 8, 'test')
expect(result.newValue).toBe('Hello @test world')
it("should replace text after last @ symbol", () => {
const result = insertMention("Hello @wor world", 8, "test")
expect(result.newValue).toBe("Hello @test world")
expect(result.mentionIndex).toBe(6)
})
it('should handle empty text', () => {
const result = insertMention('', 0, 'test')
expect(result.newValue).toBe('@test ')
it("should handle empty text", () => {
const result = insertMention("", 0, "test")
expect(result.newValue).toBe("@test ")
expect(result.mentionIndex).toBe(0)
})
})
describe('removeMention', () => {
it('should remove mention when cursor is at end of mention', () => {
describe("removeMention", () => {
it("should remove mention when cursor is at end of mention", () => {
// Test with the problems keyword that matches the regex
const result = removeMention('Hello @problems ', 15)
expect(result.newText).toBe('Hello ')
const result = removeMention("Hello @problems ", 15)
expect(result.newText).toBe("Hello ")
expect(result.newPosition).toBe(6)
})
it('should not remove text when not at end of mention', () => {
const result = removeMention('Hello @test world', 8)
expect(result.newText).toBe('Hello @test world')
it("should not remove text when not at end of mention", () => {
const result = removeMention("Hello @test world", 8)
expect(result.newText).toBe("Hello @test world")
expect(result.newPosition).toBe(8)
})
it('should handle text without mentions', () => {
const result = removeMention('Hello world', 5)
expect(result.newText).toBe('Hello world')
it("should handle text without mentions", () => {
const result = removeMention("Hello world", 5)
expect(result.newText).toBe("Hello world")
expect(result.newPosition).toBe(5)
})
})
describe('getContextMenuOptions', () => {
describe("getContextMenuOptions", () => {
const mockQueryItems: ContextMenuQueryItem[] = [
{
type: ContextMenuOptionType.File,
value: 'src/test.ts',
label: 'test.ts',
description: 'Source file'
value: "src/test.ts",
label: "test.ts",
description: "Source file",
},
{
type: ContextMenuOptionType.Git,
value: 'abc1234',
label: 'Initial commit',
description: 'First commit',
icon: '$(git-commit)'
value: "abc1234",
label: "Initial commit",
description: "First commit",
icon: "$(git-commit)",
},
{
type: ContextMenuOptionType.Folder,
value: 'src',
label: 'src',
description: 'Source folder'
}
value: "src",
label: "src",
description: "Source folder",
},
]
it('should return all option types for empty query', () => {
const result = getContextMenuOptions('', null, [])
it("should return all option types for empty query", () => {
const result = getContextMenuOptions("", null, [])
expect(result).toHaveLength(5)
expect(result.map(item => item.type)).toEqual([
expect(result.map((item) => item.type)).toEqual([
ContextMenuOptionType.Problems,
ContextMenuOptionType.URL,
ContextMenuOptionType.Folder,
ContextMenuOptionType.File,
ContextMenuOptionType.Git
ContextMenuOptionType.Git,
])
})
it('should filter by selected type when query is empty', () => {
const result = getContextMenuOptions('', ContextMenuOptionType.File, mockQueryItems)
it("should filter by selected type when query is empty", () => {
const result = getContextMenuOptions("", ContextMenuOptionType.File, mockQueryItems)
expect(result).toHaveLength(1)
expect(result[0].type).toBe(ContextMenuOptionType.File)
expect(result[0].value).toBe('src/test.ts')
expect(result[0].value).toBe("src/test.ts")
})
it('should match git commands', () => {
const result = getContextMenuOptions('git', null, mockQueryItems)
it("should match git commands", () => {
const result = getContextMenuOptions("git", null, mockQueryItems)
expect(result[0].type).toBe(ContextMenuOptionType.Git)
expect(result[0].label).toBe('Git Commits')
expect(result[0].label).toBe("Git Commits")
})
it('should match git commit hashes', () => {
const result = getContextMenuOptions('abc1234', null, mockQueryItems)
it("should match git commit hashes", () => {
const result = getContextMenuOptions("abc1234", null, mockQueryItems)
expect(result[0].type).toBe(ContextMenuOptionType.Git)
expect(result[0].value).toBe('abc1234')
expect(result[0].value).toBe("abc1234")
})
it('should return NoResults when no matches found', () => {
const result = getContextMenuOptions('nonexistent', null, mockQueryItems)
it("should return NoResults when no matches found", () => {
const result = getContextMenuOptions("nonexistent", null, mockQueryItems)
expect(result).toHaveLength(1)
expect(result[0].type).toBe(ContextMenuOptionType.NoResults)
})
})
describe('shouldShowContextMenu', () => {
it('should return true for @ symbol', () => {
expect(shouldShowContextMenu('@', 1)).toBe(true)
describe("shouldShowContextMenu", () => {
it("should return true for @ symbol", () => {
expect(shouldShowContextMenu("@", 1)).toBe(true)
})
it('should return true for @ followed by text', () => {
expect(shouldShowContextMenu('Hello @test', 10)).toBe(true)
it("should return true for @ followed by text", () => {
expect(shouldShowContextMenu("Hello @test", 10)).toBe(true)
})
it('should return false when no @ symbol exists', () => {
expect(shouldShowContextMenu('Hello world', 5)).toBe(false)
it("should return false when no @ symbol exists", () => {
expect(shouldShowContextMenu("Hello world", 5)).toBe(false)
})
it('should return false for @ followed by whitespace', () => {
expect(shouldShowContextMenu('Hello @ world', 6)).toBe(false)
it("should return false for @ followed by whitespace", () => {
expect(shouldShowContextMenu("Hello @ world", 6)).toBe(false)
})
it('should return false for @ in URL', () => {
expect(shouldShowContextMenu('Hello @http://test.com', 17)).toBe(false)
it("should return false for @ in URL", () => {
expect(shouldShowContextMenu("Hello @http://test.com", 17)).toBe(false)
})
it('should return false for @problems', () => {
it("should return false for @problems", () => {
// Position cursor at the end to test the full word
expect(shouldShowContextMenu('@problems', 9)).toBe(false)
expect(shouldShowContextMenu("@problems", 9)).toBe(false)
})
})
})

View File

@@ -1,4 +1,4 @@
import { parse } from 'shell-quote'
import { parse } from "shell-quote"
type ShellToken = string | { op: string } | { command: string }
@@ -46,39 +46,39 @@ export function parseCommand(command: string): string[] {
let currentCommand: string[] = []
for (const token of tokens) {
if (typeof token === 'object' && 'op' in token) {
if (typeof token === "object" && "op" in token) {
// Chain operator - split command
if (['&&', '||', ';', '|'].includes(token.op)) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(' '))
currentCommand = []
}
if (["&&", "||", ";", "|"].includes(token.op)) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
} else {
// Other operators (>, &) are part of the command
currentCommand.push(token.op)
// Other operators (>, &) are part of the command
currentCommand.push(token.op)
}
} else if (typeof token === 'string') {
} else if (typeof token === "string") {
// Check if it's a subshell placeholder
const subshellMatch = token.match(/__SUBSH_(\d+)__/)
if (subshellMatch) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(' '))
currentCommand = []
}
commands.push(subshells[parseInt(subshellMatch[1])])
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
commands.push(subshells[parseInt(subshellMatch[1])])
} else {
currentCommand.push(token)
currentCommand.push(token)
}
}
}
// Add any remaining command
if (currentCommand.length > 0) {
commands.push(currentCommand.join(' '))
commands.push(currentCommand.join(" "))
}
// Restore quotes and redirections
return commands.map(cmd => {
return commands.map((cmd) => {
let result = cmd
// Restore quotes
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
@@ -91,15 +91,10 @@ export function parseCommand(command: string): string[] {
/**
* Check if a single command is allowed based on prefix matching.
*/
export function isAllowedSingleCommand(
command: string,
allowedCommands: string[]
): boolean {
export function isAllowedSingleCommand(command: string, allowedCommands: string[]): boolean {
if (!command || !allowedCommands?.length) return false
const trimmedCommand = command.trim().toLowerCase()
return allowedCommands.some(prefix =>
trimmedCommand.startsWith(prefix.toLowerCase())
)
return allowedCommands.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase()))
}
/**
@@ -110,7 +105,7 @@ export function validateCommand(command: string, allowedCommands: string[]): boo
if (!command?.trim()) return true
// Block subshell execution attempts
if (command.includes('$(') || command.includes('`')) {
if (command.includes("$(") || command.includes("`")) {
return false
}
@@ -118,9 +113,9 @@ export function validateCommand(command: string, allowedCommands: string[]): boo
const subCommands = parseCommand(command)
// Then ensure every sub-command starts with an allowed prefix
return subCommands.every(cmd => {
return subCommands.every((cmd) => {
// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, '').trim()
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
return isAllowedSingleCommand(cmdWithoutRedirection, allowedCommands)
})
}
}

View File

@@ -74,7 +74,7 @@ export function getContextMenuOptions(
value: "git-changes",
label: "Working changes",
description: "Current uncommitted changes",
icon: "$(git-commit)"
icon: "$(git-commit)",
}
if (query === "") {
@@ -93,8 +93,7 @@ export function getContextMenuOptions(
}
if (selectedType === ContextMenuOptionType.Git) {
const commits = queryItems
.filter((item) => item.type === ContextMenuOptionType.Git)
const commits = queryItems.filter((item) => item.type === ContextMenuOptionType.Git)
return commits.length > 0 ? [workingChanges, ...commits] : [workingChanges]
}
@@ -112,11 +111,11 @@ export function getContextMenuOptions(
// Check for top-level option matches
if ("git".startsWith(lowerQuery)) {
suggestions.push({
suggestions.push({
type: ContextMenuOptionType.Git,
label: "Git Commits",
description: "Search repository history",
icon: "$(git-commit)"
icon: "$(git-commit)",
})
} else if ("git-changes".startsWith(lowerQuery)) {
suggestions.push(workingChanges)
@@ -130,9 +129,8 @@ export function getContextMenuOptions(
// Add exact SHA matches to suggestions
if (/^[a-f0-9]{7,40}$/i.test(lowerQuery)) {
const exactMatches = queryItems.filter((item) =>
item.type === ContextMenuOptionType.Git &&
item.value?.toLowerCase() === lowerQuery
const exactMatches = queryItems.filter(
(item) => item.type === ContextMenuOptionType.Git && item.value?.toLowerCase() === lowerQuery,
)
if (exactMatches.length > 0) {
suggestions.push(...exactMatches)
@@ -143,52 +141,50 @@ export function getContextMenuOptions(
value: lowerQuery,
label: `Commit ${lowerQuery}`,
description: "Git commit hash",
icon: "$(git-commit)"
icon: "$(git-commit)",
})
}
}
// Create searchable strings array for fzf
const searchableItems = queryItems.map(item => ({
const searchableItems = queryItems.map((item) => ({
original: item,
searchStr: [item.value, item.label, item.description].filter(Boolean).join(' ')
searchStr: [item.value, item.label, item.description].filter(Boolean).join(" "),
}))
// Initialize fzf instance for fuzzy search
const fzf = new Fzf(searchableItems, {
selector: item => item.searchStr
selector: (item) => item.searchStr,
})
// Get fuzzy matching items
const matchingItems = query ? fzf.find(query).map(result => result.item.original) : []
const matchingItems = query ? fzf.find(query).map((result) => result.item.original) : []
// Separate matches by type
const fileMatches = matchingItems.filter(item =>
item.type === ContextMenuOptionType.File ||
item.type === ContextMenuOptionType.Folder
const fileMatches = matchingItems.filter(
(item) => item.type === ContextMenuOptionType.File || item.type === ContextMenuOptionType.Folder,
)
const gitMatches = matchingItems.filter(item =>
item.type === ContextMenuOptionType.Git
)
const otherMatches = matchingItems.filter(item =>
item.type !== ContextMenuOptionType.File &&
item.type !== ContextMenuOptionType.Folder &&
item.type !== ContextMenuOptionType.Git
const gitMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.Git)
const otherMatches = matchingItems.filter(
(item) =>
item.type !== ContextMenuOptionType.File &&
item.type !== ContextMenuOptionType.Folder &&
item.type !== ContextMenuOptionType.Git,
)
// Combine suggestions with matching items in the desired order
if (suggestions.length > 0 || matchingItems.length > 0) {
const allItems = [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
// Remove duplicates based on type and value
const seen = new Set()
const deduped = allItems.filter(item => {
const deduped = allItems.filter((item) => {
const key = `${item.type}-${item.value}`
if (seen.has(key)) return false
seen.add(key)
return true
})
return deduped
}

View File

@@ -1,4 +1,8 @@
export function highlightFzfMatch(text: string, positions: number[], highlightClassName: string = "history-item-highlight") {
export function highlightFzfMatch(
text: string,
positions: number[],
highlightClassName: string = "history-item-highlight",
) {
if (!positions.length) return text
const parts: { text: string; highlight: boolean }[] = []
@@ -12,14 +16,14 @@ export function highlightFzfMatch(text: string, positions: number[], highlightCl
if (pos > lastIndex) {
parts.push({
text: text.substring(lastIndex, pos),
highlight: false
highlight: false,
})
}
// Add highlighted character
parts.push({
text: text[pos],
highlight: true
highlight: true,
})
lastIndex = pos + 1
@@ -29,16 +33,12 @@ export function highlightFzfMatch(text: string, positions: number[], highlightCl
if (lastIndex < text.length) {
parts.push({
text: text.substring(lastIndex),
highlight: false
highlight: false,
})
}
// Build final string
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('')
}
.map((part) => (part.highlight ? `<span class="${highlightClassName}">${part.text}</span>` : part.text))
.join("")
}