mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 12:21:13 -05:00
Prettier backfill
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })),
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user