Merge branch 'main' into new_unified

This commit is contained in:
Daniel
2025-01-15 11:53:27 -05:00
committed by GitHub
116 changed files with 16285 additions and 2361 deletions

View File

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

View File

@@ -18,7 +18,7 @@
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-remark": "^2.1.0",
@@ -37,7 +37,10 @@
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/shell-quote": "^1.7.5",
"@types/vscode-webview": "^1.57.5",
"eslint": "^8.57.0"
"customize-cra": "^1.0.0",
"eslint": "^8.57.0",
"jest-simple-dot-reporter": "^1.0.5",
"react-app-rewired": "^2.2.1"
}
},
"node_modules/@adobe/css-tools": {
@@ -5624,6 +5627,15 @@
"version": "3.1.3",
"license": "MIT"
},
"node_modules/customize-cra": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/customize-cra/-/customize-cra-1.0.0.tgz",
"integrity": "sha512-DbtaLuy59224U+xCiukkxSq8clq++MOtJ1Et7LED1fLszWe88EoblEYFBJ895sB1mC6B4uu3xPT/IjClELhMbA==",
"dev": true,
"dependencies": {
"lodash.flow": "^3.5.0"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"license": "BSD-2-Clause"
@@ -7468,12 +7480,10 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.0.0",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
"node_modules/fzf": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
@@ -9257,6 +9267,12 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/jest-simple-dot-reporter": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/jest-simple-dot-reporter/-/jest-simple-dot-reporter-1.0.5.tgz",
"integrity": "sha512-cZLFG/C7k0+WYoIGGuGXKm0vmJiXlWG/m3uCZ4RaMPYxt8lxjdXMLHYkxXaQ7gVWaSPe7uAPCEUcRxthC5xskg==",
"dev": true
},
"node_modules/jest-snapshot": {
"version": "27.5.1",
"license": "MIT",
@@ -9896,6 +9912,12 @@
"version": "4.0.8",
"license": "MIT"
},
"node_modules/lodash.flow": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz",
"integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==",
"dev": true
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"license": "MIT"
@@ -12269,6 +12291,30 @@
"version": "0.13.11",
"license": "MIT"
},
"node_modules/react-app-rewired": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.2.1.tgz",
"integrity": "sha512-uFQWTErXeLDrMzOJHKp0h8P1z0LV9HzPGsJ6adOtGlA/B9WfT6Shh4j2tLTTGlXOfiVx6w6iWpp7SOC5pvk+gA==",
"dev": true,
"dependencies": {
"semver": "^5.6.0"
},
"bin": {
"react-app-rewired": "bin/index.js"
},
"peerDependencies": {
"react-scripts": ">=2.1.3"
}
},
"node_modules/react-app-rewired/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/react-dev-utils": {
"version": "12.0.1",
"license": "MIT",

View File

@@ -13,7 +13,7 @@
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-remark": "^2.1.0",
@@ -29,9 +29,9 @@
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"start": "react-app-rewired start",
"build": "node ./scripts/build-react-no-split.js",
"test": "react-scripts test --watchAll=false",
"test": "react-app-rewired test --watchAll=false",
"eject": "react-scripts eject",
"lint": "eslint src --ext ts,tsx"
},
@@ -57,14 +57,9 @@
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/shell-quote": "^1.7.5",
"@types/vscode-webview": "^1.57.5",
"eslint": "^8.57.0"
},
"jest": {
"transformIgnorePatterns": [
"/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|unist-util-find-after|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text|@vscode/webview-ui-toolkit|@microsoft/fast-react-wrapper|@microsoft/fast-element|@microsoft/fast-foundation|@microsoft/fast-web-utilities|exenv-es6)/)"
],
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
"customize-cra": "^1.0.0",
"eslint": "^8.57.0",
"jest-simple-dot-reporter": "^1.0.5",
"react-app-rewired": "^2.2.1"
}
}

View File

@@ -8,12 +8,14 @@ import WelcomeView from "./components/welcome/WelcomeView"
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
import { vscode } from "./utils/vscode"
import McpView from "./components/mcp/McpView"
import PromptsView from "./components/prompts/PromptsView"
const AppContent = () => {
const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState()
const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showMcp, setShowMcp] = useState(false)
const [showPrompts, setShowPrompts] = useState(false)
const [showAnnouncement, setShowAnnouncement] = useState(false)
const handleMessage = useCallback((e: MessageEvent) => {
@@ -25,21 +27,31 @@ const AppContent = () => {
setShowSettings(true)
setShowHistory(false)
setShowMcp(false)
setShowPrompts(false)
break
case "historyButtonClicked":
setShowSettings(false)
setShowHistory(true)
setShowMcp(false)
setShowPrompts(false)
break
case "mcpButtonClicked":
setShowSettings(false)
setShowHistory(false)
setShowMcp(true)
setShowPrompts(false)
break
case "promptsButtonClicked":
setShowSettings(false)
setShowHistory(false)
setShowMcp(false)
setShowPrompts(true)
break
case "chatButtonClicked":
setShowSettings(false)
setShowHistory(false)
setShowMcp(false)
setShowPrompts(false)
break
}
break
@@ -68,14 +80,16 @@ const AppContent = () => {
{showSettings && <SettingsView onDone={() => setShowSettings(false)} />}
{showHistory && <HistoryView onDone={() => setShowHistory(false)} />}
{showMcp && <McpView onDone={() => setShowMcp(false)} />}
{showPrompts && <PromptsView onDone={() => setShowPrompts(false)} />}
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
<ChatView
showHistoryView={() => {
setShowSettings(false)
setShowMcp(false)
setShowPrompts(false)
setShowHistory(true)
}}
isHidden={showSettings || showHistory || showMcp}
isHidden={showSettings || showHistory || showMcp || showPrompts}
showAnnouncement={showAnnouncement}
hideAnnouncement={() => {
setShowAnnouncement(false)

View File

@@ -29,100 +29,39 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
style={{ position: "absolute", top: "8px", right: "8px" }}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
<h2 style={{ margin: "0 0 8px" }}>
🎉{" "}Introducing Roo Cline v{minorVersion}
</h2>
<h3 style={{ margin: "0 0 8px" }}>
🎉{" "}New in Cline v{minorVersion}
Agent Modes Customization
</h3>
<p style={{ margin: "5px 0px", fontWeight: "bold" }}>Add custom tools to Cline using MCP!</p>
<p style={{ margin: "5px 0px" }}>
The Model Context Protocol allows agents like Cline to plug and play custom tools,{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
e.g. a web-search tool or GitHub tool.
</VSCodeLink>
</p>
<p style={{ margin: "5px 0px" }}>
You can add and configure MCP servers by clicking the new{" "}
<span className="codicon codicon-server" style={{ fontSize: "10px" }}></span> icon in the menu bar.
</p>
<p style={{ margin: "5px 0px" }}>
To take things a step further, Cline also has the ability to create custom tools for himself. Just say
"add a tool that..." and watch as he builds and installs new capabilities specific to{" "}
<i>your workflow</i>. For example:
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>"...fetches Jira tickets": Get ticket ACs and put Cline to work</li>
<li>"...manages AWS EC2s": Check server metrics and scale up or down</li>
<li>"...pulls PagerDuty incidents": Pulls details to help Cline fix bugs</li>
<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>
Cline handles everything from creating the MCP server to installing it in the extension, ready to use in
future tasks. The servers are saved to <code>~/Documents/Cline/MCP</code> so you can easily share them
with others too.{" "}
</p>
<h3 style={{ margin: "0 0 8px" }}>
Prompt Enhancement Configuration
</h3>
<p style={{ margin: "5px 0px" }}>
Try it yourself by asking Cline to "add a tool that gets the latest npm docs", or
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}>
see a demo of MCP in action here.
</VSCodeLink>
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>Test your changes instantly with the Preview Prompt Enhancement tool.</li>
</ul>
</p>
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
so I recommend trying them out.
<br />
{!apiConfiguration?.openRouterApiKey && (
<VSCodeButtonLink
href={getOpenRouterAuthUrl(vscodeUriScheme)}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
margin: "4px -30px 2px 0",
}}>
Get OpenRouter API Key
</VSCodeButtonLink>
)}
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
<VSCodeButton
onClick={() => {
vscode.postMessage({
type: "apiConfiguration",
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
})
}}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
margin: "4px -30px 2px 0",
}}>
Switch to OpenRouter
</VSCodeButton>
)}
</li>
<li>
<b>Edit Cline's changes before accepting!</b> When he creates or edits a file, you can modify his
changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in
the center to undo "<code>{"// rest of code here"}</code>" shenanigans)
</li>
<li>
New <code>search_files</code> tool that lets Cline perform regex searches in your project, letting
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
</li>
<li>
When Cline runs commands, you can now type directly in the terminal (+ support for Python
environments)
</li>
</ul>*/}
<div
style={{
height: "1px",
background: "var(--vscode-foreground)",
opacity: 0.1,
margin: "8px 0",
}}
/>
<p style={{ margin: "0" }}>
Join
<VSCodeLink style={{ display: "inline" }} href="https://discord.gg/cline">
discord.gg/cline
<p style={{ margin: "5px 0px" }}>
We're very excited to see what you build with this new feature! Join us at
<VSCodeLink href="https://www.reddit.com/r/roocline" style={{ display: "inline" }}>
reddit.com/r/roocline
</VSCodeLink>
for more updates!
to discuss and share feedback.
</p>
</div>
)

View File

@@ -1,6 +1,6 @@
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import deepEqual from "fast-deep-equal"
import React, { memo, useEffect, useMemo, useRef } from "react"
import React, { memo, useEffect, useMemo, useRef, useState } from "react"
import { useSize } from "react-use"
import {
ClineApiReqInfo,
@@ -154,6 +154,8 @@ export const ChatRowContent = ({
style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: successColor, fontWeight: "bold" }}>Task Completed</span>,
]
case "api_req_retry_delayed":
return []
case "api_req_started":
const getIconSpan = (iconName: string, color: string) => (
<div
@@ -211,15 +213,7 @@ export const ChatRowContent = ({
default:
return [null, null]
}
}, [
type,
cost,
apiRequestFailedMessage,
isCommandExecuting,
apiReqCancelReason,
isMcpServerResponding,
message.text,
])
}, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage])
const headerStyle: React.CSSProperties = {
display: "flex",
@@ -557,7 +551,7 @@ export const ChatRowContent = ({
case "text":
return (
<div>
<Markdown markdown={message.text} />
<Markdown markdown={message.text} partial={message.partial} />
</div>
)
case "user_feedback":
@@ -709,7 +703,7 @@ export const ChatRowContent = ({
</div>
)}
<div style={{ paddingTop: 10 }}>
<Markdown markdown={message.text} />
<Markdown markdown={message.text} partial={message.partial} />
</div>
</>
)
@@ -882,7 +876,7 @@ export const ChatRowContent = ({
{title}
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} />
<Markdown markdown={message.text} partial={message.partial} />
</div>
</div>
)
@@ -924,10 +918,63 @@ export const ProgressIndicator = () => (
</div>
)
const Markdown = memo(({ markdown }: { markdown?: string }) => {
const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => {
const [isHovering, setIsHovering] = useState(false);
return (
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere", marginBottom: -15, marginTop: -15 }}>
<MarkdownBlock markdown={markdown} />
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{ position: "relative" }}
>
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere", marginBottom: -15, marginTop: -15 }}>
<MarkdownBlock markdown={markdown} />
</div>
{markdown && !partial && isHovering && (
<div
style={{
position: "absolute",
bottom: "-4px",
right: "8px",
opacity: 0,
animation: "fadeIn 0.2s ease-in-out forwards",
borderRadius: "4px"
}}
>
<style>
{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1.0; }
}
`}
</style>
<VSCodeButton
className="copy-button"
appearance="icon"
style={{
height: "24px",
border: "none",
background: "var(--vscode-editor-background)",
transition: "background 0.2s ease-in-out"
}}
onClick={() => {
navigator.clipboard.writeText(markdown);
// Flash the button background briefly to indicate success
const button = document.activeElement as HTMLElement;
if (button) {
button.style.background = "var(--vscode-button-background)";
setTimeout(() => {
button.style.background = "";
}, 200);
}
}}
title="Copy as markdown"
>
<span className="codicon codicon-copy"></span>
</VSCodeButton>
</div>
)}
</div>
)
})

View File

@@ -14,6 +14,8 @@ import ContextMenu from "./ContextMenu"
import Thumbnails from "../common/Thumbnails"
import { vscode } from "../../utils/vscode"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
import { Mode } from "../../../../src/core/prompts/types"
import { CaretIcon } from "../common/CaretIcon"
interface ChatTextAreaProps {
inputValue: string
@@ -26,6 +28,8 @@ interface ChatTextAreaProps {
onSelectImages: () => void
shouldDisableImages: boolean
onHeightChange?: (height: number) => void
mode: Mode
setMode: (value: Mode) => void
}
const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
@@ -41,19 +45,34 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onSelectImages,
shouldDisableImages,
onHeightChange,
mode,
setMode,
},
ref,
) => {
const { filePaths, apiConfiguration } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const { filePaths, currentApiConfigName, listApiConfigMeta } = useExtensionState()
const [gitCommits, setGitCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (showDropdown) {
setShowDropdown(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showDropdown])
// Handle enhanced prompt response
useEffect(() => {
const messageHandler = (event: MessageEvent) => {
const message = event.data
if (message.type === 'enhancedPrompt' && message.text) {
setInputValue(message.text)
if (message.type === 'enhancedPrompt') {
if (message.text) {
setInputValue(message.text)
}
setIsEnhancingPrompt(false)
} else if (message.type === 'commitSearchResults') {
const commits = message.commits.map((commit: any) => ({
@@ -357,7 +376,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (!isMouseDownOnMenu) {
setShowContextMenu(false)
}
setIsTextAreaFocused(false)
}, [isMouseDownOnMenu])
const handlePaste = useCallback(
@@ -475,65 +493,97 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[updateCursorPosition],
)
const selectStyle = {
fontSize: "11px",
cursor: textAreaDisabled ? "not-allowed" : "pointer",
backgroundColor: "transparent",
border: "none",
color: "var(--vscode-foreground)",
opacity: textAreaDisabled ? 0.5 : 0.8,
outline: "none",
paddingLeft: "20px",
paddingRight: "6px",
WebkitAppearance: "none" as const,
MozAppearance: "none" as const,
appearance: "none" as const
}
const caretContainerStyle = {
position: "absolute" as const,
left: 6,
top: "50%",
transform: "translateY(-45%)",
pointerEvents: "none" as const,
opacity: textAreaDisabled ? 0.5 : 0.8
}
return (
<div style={{
padding: "10px 15px",
opacity: textAreaDisabled ? 0.5 : 1,
position: "relative",
display: "flex",
}}
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)
setInputValue(newValue)
const newCursorPosition = cursorPosition + text.length
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
return
}
const acceptedTypes = ["png", "jpeg", "webp"]
const imageFiles = files.filter((file) => {
const [type, subtype] = file.type.split("/")
return type === "image" && acceptedTypes.includes(subtype)
})
if (!shouldDisableImages && imageFiles.length > 0) {
const imagePromises = imageFiles.map((file) => {
return new Promise<string | null>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
if (reader.error) {
console.error("Error reading file:", reader.error)
resolve(null)
} else {
const result = reader.result
resolve(typeof result === "string" ? result : null)
}
}
reader.readAsDataURL(file)
})
})
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') {
vscode.postMessage({
type: 'draggedImages',
dataUrls: dataUrls
})
}
} else {
console.warn("No valid images were processed")
<div
className="chat-text-area"
style={{
opacity: textAreaDisabled ? 0.5 : 1,
position: "relative",
display: "flex",
flexDirection: "column",
gap: "8px",
backgroundColor: "var(--vscode-input-background)",
minHeight: "100px",
margin: "10px 15px",
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)
setInputValue(newValue)
const newCursorPosition = cursorPosition + text.length
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
return
}
}
}}
onDragOver={(e) => {
e.preventDefault()
}}>
const acceptedTypes = ["png", "jpeg", "webp"]
const imageFiles = files.filter((file) => {
const [type, subtype] = file.type.split("/")
return type === "image" && acceptedTypes.includes(subtype)
})
if (!shouldDisableImages && imageFiles.length > 0) {
const imagePromises = imageFiles.map((file) => {
return new Promise<string | null>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
if (reader.error) {
console.error("Error reading file:", reader.error)
resolve(null)
} else {
const result = reader.result
resolve(typeof result === "string" ? result : null)
}
}
reader.readAsDataURL(file)
})
})
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') {
vscode.postMessage({
type: 'draggedImages',
dataUrls: dataUrls
})
}
} else {
console.warn("No valid images were processed")
}
}
}}
onDragOver={(e) => {
e.preventDefault()
}}
>
{showContextMenu && (
<div ref={contextMenuContainerRef}>
<ContextMenu
@@ -547,100 +597,87 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
</div>
)}
{!isTextAreaFocused && (
<div style={{
position: "relative",
flex: "1 1 auto",
display: "flex",
flexDirection: "column-reverse",
minHeight: 0,
overflow: "hidden"
}}>
<div
ref={highlightLayerRef}
style={{
position: "absolute",
inset: "10px 15px",
border: "1px solid var(--vscode-input-border)",
borderRadius: 2,
inset: 0,
pointerEvents: "none",
zIndex: 5,
whiteSpace: "pre-wrap",
wordWrap: "break-word",
color: "transparent",
overflow: "hidden",
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
padding: "8px",
marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
zIndex: 1
}}
/>
)}
<div
ref={highlightLayerRef}
style={{
position: "absolute",
top: 10,
left: 15,
right: 15,
bottom: 10,
pointerEvents: "none",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
color: "transparent",
overflow: "hidden",
backgroundColor: "var(--vscode-input-background)",
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
borderRadius: 2,
borderLeft: 0,
borderRight: 0,
borderTop: 0,
borderColor: "transparent",
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
padding: "9px 9px 25px 9px",
}}
/>
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue}
disabled={textAreaDisabled}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onFocus={() => setIsTextAreaFocused(true)}
onBlur={handleBlur}
onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height)
}
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={2}
maxRows={20}
autoFocus={true}
style={{
width: "100%",
boxSizing: "border-box",
backgroundColor: "transparent",
color: "var(--vscode-input-foreground)",
borderRadius: 2,
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
resize: "none",
overflowX: "hidden",
overflowY: "scroll",
borderLeft: 0,
borderRight: 0,
borderTop: 0,
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
borderColor: "transparent",
padding: "9px 9px 25px 9px",
cursor: textAreaDisabled ? "not-allowed" : undefined,
flex: 1,
zIndex: 1,
}}
onScroll={() => updateHighlights()}
/>
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue}
disabled={textAreaDisabled}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height)
}
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={4}
maxRows={20}
autoFocus={true}
style={{
width: "100%",
boxSizing: "border-box",
backgroundColor: "transparent",
color: "var(--vscode-input-foreground)",
borderRadius: 2,
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
resize: "none",
overflowX: "hidden",
overflowY: "auto",
border: "none",
padding: "8px",
marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
cursor: textAreaDisabled ? "not-allowed" : undefined,
flex: "0 1 auto",
zIndex: 2
}}
onScroll={() => updateHighlights()}
/>
</div>
{selectedImages.length > 0 && (
<Thumbnails
images={selectedImages}
@@ -648,32 +685,136 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onHeightChange={handleThumbnailsHeightChange}
style={{
position: "absolute",
paddingTop: 4,
bottom: 14,
left: 22,
right: 67,
bottom: "36px",
left: "16px",
zIndex: 2,
marginBottom: "8px"
}}
/>
)}
<div className="button-row" style={{ position: "absolute", right: 20, display: "flex", alignItems: "center", height: 31, bottom: 8, zIndex: 2, justifyContent: "flex-end" }}>
<span style={{ display: "flex", alignItems: "center", gap: 12 }}>
{apiConfiguration?.apiProvider === "openrouter" && (
<div style={{ display: "flex", alignItems: "center" }}>
{isEnhancingPrompt && <span style={{ marginRight: 10, color: "var(--vscode-input-foreground)", opacity: 0.5 }}>Enhancing prompt...</span>}
<span
role="button"
aria-label="enhance prompt"
data-testid="enhance-prompt-button"
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-sparkle`}
onClick={() => !textAreaDisabled && handleEnhancePrompt()}
style={{ fontSize: 16.5 }}
<div style={{
display: "flex",
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}
disabled={textAreaDisabled}
onChange={(e) => {
const newMode = e.target.value as Mode
setMode(newMode)
vscode.postMessage({
type: "mode",
text: newMode
})
}}
style={{
...selectStyle,
minWidth: "70px",
flex: "0 0 auto"
}}
>
<option value="code" style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}>Code</option>
<option value="architect" style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}>Architect</option>
<option value="ask" style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}>Ask</option>
</select>
<div style={caretContainerStyle}>
<CaretIcon />
</div>
</div>
<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
})}
style={{
...selectStyle,
width: "100%",
textOverflow: "ellipsis"
}}
>
{(listApiConfigMeta || [])?.map((config) => (
<option
key={config.name}
value={config.name}
style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}
>
{config.name}
</option>
))}
</select>
<div style={caretContainerStyle}>
<CaretIcon />
</div>
</div>
</div>
<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
role="button"
aria-label="enhance prompt"
data-testid="enhance-prompt-button"
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-sparkle`}
onClick={() => !textAreaDisabled && handleEnhancePrompt()}
style={{ fontSize: 16.5 }}
/>
)}
</div>
<span
className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`}
onClick={() => !shouldDisableImages && onSelectImages()}
style={{ fontSize: 16.5 }}
/>
</div>
)}
<span className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`} onClick={() => !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} />
<span className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`} onClick={() => !textAreaDisabled && onSend()} style={{ fontSize: 15 }} />
</span>
<span
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
onClick={() => !textAreaDisabled && onSend()}
style={{ fontSize: 15 }}
/>
</div>
</div>
</div>
)

View File

@@ -1,4 +1,4 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
@@ -38,7 +38,7 @@ 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 } = useExtensionState()
const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands, writeDelayMs, mode, setMode } = 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)
@@ -192,6 +192,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
case "say":
// don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
switch (lastMessage.say) {
case "api_req_retry_delayed":
setTextAreaDisabled(true)
break
case "api_req_started":
if (secondLastMessage?.ask === "command_output") {
// if the last ask is a command_output, and we receive an api_req_started, then that means the command has finished and we don't need input from the user anymore (in every other case, the user has to interact with input field or buttons to continue, which does the following automatically)
@@ -294,11 +297,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// there is no other case that a textfield should be enabled
}
}
// Only reset message-specific state, preserving mode
setInputValue("")
setTextAreaDisabled(true)
setSelectedImages([])
setClineAsk(undefined)
setEnableButtons(false)
// Do not reset mode here as it should persist
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
disableAutoScrollRef.current = false
@@ -335,8 +340,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setTextAreaDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
disableAutoScrollRef.current = false
}, [clineAsk, startNewTask])
@@ -364,8 +367,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setTextAreaDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
disableAutoScrollRef.current = false
}, [clineAsk, startNewTask, isStreaming])
@@ -466,6 +467,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
return false
case "api_req_retry_delayed":
// Only show the retry message if it's the last message
return message === modifiedMessages.at(-1)
case "text":
// Sometimes cline returns an empty text message, we don't want to render these. (We also use a say text for user messages, so in case they just sent images we still render that)
if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
@@ -773,9 +777,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
const placeholderText = useMemo(() => {
const text = task ? "Type a message...\n(@ to add context, hold shift to drag in images)" : "Type your task here...\n(@ to add context, hold shift to drag in images)"
return text
}, [task])
const baseText = task ? "Type a message..." : "Type your task here..."
const contextText = "(@ to add context"
const imageText = shouldDisableImages ? "" : ", hold shift to drag in images"
const helpText = imageText ? `\n${contextText}${imageText})` : `\n${contextText})`
return baseText + helpText
}, [task, shouldDisableImages])
const itemContent = useCallback(
(index: number, messageOrGroup: ClineMessage | ClineMessage[]) => {
@@ -868,12 +875,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
<div style={{ padding: "0 20px", flexShrink: 0 }}>
<h2>What can I do for you?</h2>
<p>
Thanks to{" "}
<VSCodeLink
href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
style={{ display: "inline" }}>
Claude 3.5 Sonnet's agentic coding capabilities,
</VSCodeLink>{" "}
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
@@ -982,6 +984,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
scrollToBottomAuto()
}
}}
mode={mode}
setMode={setMode}
/>
</div>
)

View File

@@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
import ChatTextArea from '../ChatTextArea';
import { useExtensionState } from '../../../context/ExtensionStateContext';
import { vscode } from '../../../utils/vscode';
import { codeMode } from '../../../../../src/shared/modes';
// Mock modules
jest.mock('../../../utils/vscode', () => ({
@@ -32,6 +33,8 @@ describe('ChatTextArea', () => {
selectedImages: [],
setSelectedImages: jest.fn(),
onHeightChange: jest.fn(),
mode: codeMode,
setMode: jest.fn(),
};
beforeEach(() => {
@@ -46,37 +49,9 @@ describe('ChatTextArea', () => {
});
describe('enhance prompt button', () => {
it('should show enhance prompt button only when apiProvider is openrouter', () => {
// Test with non-openrouter provider
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'anthropic',
},
});
const { rerender } = render(<ChatTextArea {...defaultProps} />);
expect(screen.queryByTestId('enhance-prompt-button')).not.toBeInTheDocument();
// Test with openrouter provider
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'openrouter',
},
});
rerender(<ChatTextArea {...defaultProps} />);
const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i });
expect(enhanceButton).toBeInTheDocument();
});
it('should be disabled when textAreaDisabled is true', () => {
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'openrouter',
},
});
render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />);
@@ -137,7 +112,8 @@ describe('ChatTextArea', () => {
const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i });
fireEvent.click(enhanceButton);
expect(screen.getByText('Enhancing prompt...')).toBeInTheDocument();
const loadingSpinner = screen.getByText('', { selector: '.codicon-loading' });
expect(loadingSpinner).toBeInTheDocument();
});
});

View File

@@ -263,6 +263,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
writeDelayMs: 0,
clineMessages: [
{
type: 'say',
@@ -276,6 +277,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the write tool ask message
mockPostMessage({
alwaysAllowWrite: true,
writeDelayMs: 0,
clineMessages: [
{
type: 'say',

View File

@@ -0,0 +1,16 @@
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>
)

View File

@@ -3,8 +3,9 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { Virtuoso } from "react-virtuoso"
import React, { memo, useMemo, useState, useEffect } from "react"
import Fuse, { FuseResult } from "fuse.js"
import { Fzf } from "fzf"
import { formatLargeNumber } from "../../utils/format"
import { highlightFzfMatch } from "../../utils/highlight"
type HistoryViewProps = {
onDone: () => void
@@ -67,20 +68,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
return taskHistory.filter((item) => item.ts && item.task)
}, [taskHistory])
const fuse = useMemo(() => {
return new Fuse(presentableTasks, {
keys: ["task"],
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
const fzf = useMemo(() => {
return new Fzf(presentableTasks, {
selector: item => item.task
})
}, [presentableTasks])
const taskHistorySearchResults = useMemo(() => {
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
let results = presentableTasks
if (searchQuery) {
const searchResults = fzf.find(searchQuery)
results = searchResults.map(result => ({
...result.item,
task: highlightFzfMatch(result.item.task, Array.from(result.positions))
}))
}
// First apply search if needed
const searchResults = searchQuery ? results : presentableTasks;
@@ -104,7 +106,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
return (b.ts || 0) - (a.ts || 0);
}
});
}, [presentableTasks, searchQuery, fuse, sortOption])
}, [presentableTasks, searchQuery, fzf, sortOption])
return (
<>
@@ -463,112 +465,4 @@ const ExportButton = ({ itemId }: { itemId: string }) => (
</VSCodeButton>
)
// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
export const highlight = (
fuseSearchResult: FuseResult<any>[],
highlightClassName: string = "history-item-highlight",
) => {
const set = (obj: Record<string, any>, path: string, value: any) => {
const pathValue = path.split(".")
let i: number
for (i = 0; i < pathValue.length - 1; i++) {
if (pathValue[i] === "__proto__" || pathValue[i] === "constructor") return
obj = obj[pathValue[i]] as Record<string, any>
}
if (pathValue[i] !== "__proto__" && pathValue[i] !== "constructor") {
obj[pathValue[i]] = value
}
}
// Function to merge overlapping regions
const mergeRegions = (regions: [number, number][]): [number, number][] => {
if (regions.length === 0) return regions
// Sort regions by start index
regions.sort((a, b) => a[0] - b[0])
const merged: [number, number][] = [regions[0]]
for (let i = 1; i < regions.length; i++) {
const last = merged[merged.length - 1]
const current = regions[i]
if (current[0] <= last[1] + 1) {
// Overlapping or adjacent regions
last[1] = Math.max(last[1], current[1])
} else {
merged.push(current)
}
}
return merged
}
const generateHighlightedText = (inputText: string, regions: [number, number][] = []) => {
if (regions.length === 0) {
return inputText
}
// Sort and merge overlapping regions
const mergedRegions = mergeRegions(regions)
// Convert regions to a list of parts with their highlight status
const parts: { text: string; highlight: boolean }[] = []
let lastIndex = 0
mergedRegions.forEach(([start, end]) => {
// Add non-highlighted text before this region
if (start > lastIndex) {
parts.push({
text: inputText.substring(lastIndex, start),
highlight: false
})
}
// Add highlighted text
parts.push({
text: inputText.substring(start, end + 1),
highlight: true
})
lastIndex = end + 1
})
// Add any remaining text
if (lastIndex < inputText.length) {
parts.push({
text: inputText.substring(lastIndex),
highlight: false
})
}
// Build final string
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('')
}
return fuseSearchResult
.filter(({ matches }) => matches && matches.length)
.map(({ item, matches }) => {
const highlightedItem = { ...item }
matches?.forEach((match) => {
if (match.key && typeof match.value === "string" && match.indices) {
// Merge overlapping regions before generating highlighted text
const mergedIndices = mergeRegions([...match.indices])
set(highlightedItem, match.key, generateHighlightedText(match.value, mergedIndices))
}
})
return highlightedItem
})
}
export default memo(HistoryView)

View File

@@ -0,0 +1,479 @@
import { VSCodeButton, VSCodeTextArea, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { defaultPrompts, askMode, codeMode, architectMode, Mode, PromptComponent } from "../../../../src/shared/modes"
import { vscode } from "../../utils/vscode"
import React, { useState, useEffect } from "react"
type PromptsViewProps = {
onDone: () => void
}
const AGENT_MODES = [
{ id: codeMode, label: 'Code' },
{ id: architectMode, label: 'Architect' },
{ id: askMode, label: 'Ask' },
] as const
const PromptsView = ({ onDone }: PromptsViewProps) => {
const {
customPrompts,
listApiConfigMeta,
enhancementApiConfigId,
setEnhancementApiConfigId,
mode,
customInstructions,
setCustomInstructions
} = useExtensionState()
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('')
useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data
if (message.type === 'enhancedPrompt') {
if (message.text) {
setTestPrompt(message.text)
}
setIsEnhancing(false)
} else if (message.type === 'systemPrompt') {
if (message.text) {
setSelectedPromptContent(message.text)
setSelectedPromptTitle(`System Prompt (${message.mode} mode)`)
setIsDialogOpen(true)
}
}
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [])
type AgentMode = typeof codeMode | typeof architectMode | typeof askMode
const updateAgentPrompt = (mode: AgentMode, promptData: PromptComponent) => {
const updatedPrompt = {
...customPrompts?.[mode],
...promptData
}
// Only include properties that differ from defaults
if (updatedPrompt.roleDefinition === defaultPrompts[mode].roleDefinition) {
delete updatedPrompt.roleDefinition
}
vscode.postMessage({
type: "updatePrompt",
promptMode: mode,
customPrompt: updatedPrompt
})
}
const updateEnhancePrompt = (value: string | undefined) => {
vscode.postMessage({
type: "updateEnhancedPrompt",
text: value
})
}
const handleAgentPromptChange = (mode: AgentMode, e: Event | React.FormEvent<HTMLElement>) => {
const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value
updateAgentPrompt(mode, { roleDefinition: value.trim() || undefined })
}
const handleEnhancePromptChange = (e: Event | React.FormEvent<HTMLElement>) => {
const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value
const trimmedValue = value.trim()
if (trimmedValue !== defaultPrompts.enhance) {
updateEnhancePrompt(trimmedValue || undefined)
}
}
const handleAgentReset = (mode: AgentMode) => {
updateAgentPrompt(mode, {
...customPrompts?.[mode],
roleDefinition: undefined
})
}
const handleEnhanceReset = () => {
updateEnhancePrompt(undefined)
}
const getAgentPromptValue = (mode: AgentMode): string => {
return customPrompts?.[mode]?.roleDefinition ?? defaultPrompts[mode].roleDefinition
}
const getEnhancePromptValue = (): string => {
return customPrompts?.enhance ?? defaultPrompts.enhance
}
const handleTestEnhancement = () => {
if (!testPrompt.trim()) return
setIsEnhancing(true)
vscode.postMessage({
type: "enhancePrompt",
text: testPrompt
})
}
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
flexDirection: "column",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 17px 10px 20px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Prompts</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0 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>
<VSCodeTextArea
value={customInstructions ?? ''}
onChange={(e) => {
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
})
}}
rows={4}
resize="vertical"
style={{ width: "100%" }}
data-testid="global-custom-instructions-textarea"
/>
<div style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)", marginTop: "5px" }}>
Instructions can also be loaded from <span
style={{
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.
</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'
}}>
{AGENT_MODES.map((tab) => (
<button
key={tab.id}
data-testid={`${tab.id}-tab`}
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',
opacity: activeTab === tab.id ? 1 : 0.8,
borderRadius: '3px',
fontWeight: 'bold'
}}
>
{tab.label}
</button>
))}
</div>
<div style={{ marginBottom: '20px' }}>
<div style={{ marginBottom: '8px' }}>
<div>
<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"
>
<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>
</div>
<VSCodeTextArea
value={getAgentPromptValue(activeTab)}
onChange={(e) => handleAgentPromptChange(activeTab, e)}
rows={4}
resize="vertical"
style={{ width: "100%" }}
data-testid={`${activeTab}-prompt-textarea`}
/>
</div>
<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>
<VSCodeTextArea
value={customPrompts?.[activeTab]?.customInstructions ?? ''}
onChange={(e) => {
const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value
updateAgentPrompt(activeTab, {
...customPrompts?.[activeTab],
customInstructions: value.trim() || undefined
})
}}
rows={4}
resize="vertical"
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
style={{
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.`
vscode.postMessage({
type: "updatePrompt",
promptMode: activeTab,
customPrompt: {
...customPrompts?.[activeTab],
customInstructions: customPrompts?.[activeTab]?.customInstructions || defaultContent
}
})
// Then open the file
vscode.postMessage({
type: "openFile",
text: `./.clinerules-${activeTab}`,
values: {
create: true,
content: "",
}
})
}}
>.clinerules-{activeTab}</span> in your workspace.
</div>
</div>
</div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'flex-start' }}>
<VSCodeButton
appearance="primary"
onClick={() => {
vscode.postMessage({
type: "getSystemPrompt",
mode: activeTab
})
}}
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>
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div>
<div style={{ marginBottom: "12px" }}>
<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
</div>
</div>
<VSCodeDropdown
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
})
}}
style={{ width: "300px" }}
>
<VSCodeOption value="">Use currently selected API configuration</VSCodeOption>
{(listApiConfigMeta || []).map((config) => (
<VSCodeOption key={config.id} value={config.id}>
{config.name}
</VSCodeOption>
))}
</VSCodeDropdown>
</div>
<div style={{ marginBottom: "8px" }}>
<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">
<span className="codicon codicon-discard"></span>
</VSCodeButton>
</div>
</div>
<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>
<VSCodeTextArea
value={getEnhancePromptValue()}
onChange={handleEnhancePromptChange}
rows={4}
resize="vertical"
style={{ width: "100%" }}
/>
<div style={{ marginTop: "12px" }}>
<VSCodeTextArea
value={testPrompt}
onChange={(e) => setTestPrompt((e.target as HTMLTextAreaElement).value)}
placeholder="Enter a prompt to test the enhancement"
rows={3}
resize="vertical"
style={{ width: "100%" }}
data-testid="test-prompt-textarea"
/>
<div style={{
marginTop: "8px",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
gap: 8
}}>
<VSCodeButton
onClick={handleTestEnhancement}
disabled={isEnhancing}
appearance="primary"
>
Preview Prompt Enhancement
</VSCodeButton>
</div>
</div>
</div>
</div>
{/* Bottom padding */}
<div style={{ height: "20px" }} />
</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={{
flex: 1,
padding: '20px',
overflowY: 'auto',
minHeight: 0
}}>
<VSCodeButton
appearance="icon"
onClick={() => setIsDialogOpen(false)}
style={{
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'
}}>
{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>
</div>
</div>
)}
</div>
)
}
export default PromptsView

View File

@@ -0,0 +1,160 @@
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()
}
}))
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()
}
const renderPromptsView = (props = {}) => {
const mockOnDone = jest.fn()
return render(
<ExtensionStateContext.Provider value={{ ...mockExtensionState, ...props } as any}>
<PromptsView onDone={mockOnDone} />
</ExtensionStateContext.Provider>
)
}
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('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('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')
})
it('handles prompt changes correctly', () => {
renderPromptsView()
const textarea = screen.getByTestId('code-prompt-textarea')
fireEvent(textarea, new CustomEvent('change', {
detail: {
target: {
value: 'New prompt value'
}
}
}))
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'updatePrompt',
promptMode: 'code',
customPrompt: { roleDefinition: 'New prompt value' }
})
})
it('resets prompt to default value', () => {
renderPromptsView()
const resetButton = screen.getByTestId('reset-prompt-button')
fireEvent.click(resetButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'updatePrompt',
promptMode: 'code',
customPrompt: { roleDefinition: undefined }
})
})
it('handles API configuration selection', () => {
renderPromptsView()
const dropdown = screen.getByTestId('api-config-dropdown')
fireEvent(dropdown, new CustomEvent('change', {
detail: {
target: {
value: 'config1'
}
}
}))
expect(mockExtensionState.setEnhancementApiConfigId).toHaveBeenCalledWith('config1')
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'enhancementApiConfigId',
text: 'config1'
})
})
it('handles clearing custom instructions correctly', async () => {
const setCustomInstructions = jest.fn()
renderPromptsView({
...mockExtensionState,
customInstructions: 'Initial instructions',
setCustomInstructions
})
const textarea = screen.getByTestId('global-custom-instructions-textarea')
const changeEvent = new CustomEvent('change', {
detail: { target: { value: '' } }
})
Object.defineProperty(changeEvent, 'target', {
value: { value: '' }
})
await fireEvent(textarea, changeEvent)
expect(setCustomInstructions).toHaveBeenCalledWith(undefined)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'customInstructions',
text: undefined
})
})
})

View File

@@ -0,0 +1,225 @@
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
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
}
const ApiConfigManager = ({
currentApiConfigName = "",
listApiConfigMeta = [],
onSelectConfig,
onDeleteConfig,
onRenameConfig,
onUpsertConfig,
}: ApiConfigManagerProps) => {
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]);
// Reset edit state when current profile changes
useEffect(() => {
setEditState(null);
setInputValue("");
}, [currentApiConfigName]);
const handleAdd = () => {
const newConfigName = currentApiConfigName + " (copy)";
onUpsertConfig(newConfigName);
};
const handleStartRename = () => {
setEditState('rename');
setInputValue(currentApiConfigName || "");
};
const handleCancel = () => {
setEditState(null);
setInputValue("");
};
const handleSave = () => {
const trimmedValue = inputValue.trim();
if (!trimmedValue) return;
if (editState === 'new') {
onUpsertConfig(trimmedValue);
} else if (editState === 'rename' && currentApiConfigName) {
onRenameConfig(currentApiConfigName, trimmedValue);
}
setEditState(null);
setInputValue("");
};
const handleDelete = () => {
if (!currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return;
// Let the extension handle both deletion and selection
onDeleteConfig(currentApiConfigName);
};
const isOnlyProfile = listApiConfigMeta?.length === 1;
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)

View File

@@ -43,13 +43,12 @@ import OpenAiModelPicker from "./OpenAiModelPicker"
import GlamaModelPicker from "./GlamaModelPicker"
interface ApiOptionsProps {
showModelOptions: boolean
apiErrorMessage?: string
modelIdErrorMessage?: string
}
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState()
const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => {
const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState()
const [ollamaModels, setOllamaModels] = useState<string[]>([])
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
@@ -57,7 +56,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
setApiConfiguration({ ...apiConfiguration, [field]: event.target.value })
const apiConfig = { ...apiConfiguration, [field]: event.target.value }
onUpdateApiConfig(apiConfig)
setApiConfiguration(apiConfig)
}
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
@@ -693,16 +694,15 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
</p>
)}
{selectedProvider === "glama" && showModelOptions && <GlamaModelPicker />}
{selectedProvider === "glama" && <GlamaModelPicker />}
{selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker />}
{selectedProvider === "openrouter" && <OpenRouterModelPicker />}
{selectedProvider !== "glama" &&
selectedProvider !== "openrouter" &&
selectedProvider !== "openai" &&
selectedProvider !== "ollama" &&
selectedProvider !== "lmstudio" &&
showModelOptions && (
selectedProvider !== "lmstudio" && (
<>
<div className="dropdown-container">
<label htmlFor="model-id">

View File

@@ -1,5 +1,5 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import { Fzf } from "fzf"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
import { useMount } from "react-use"
@@ -7,11 +7,11 @@ import styled from "styled-components"
import { glamaDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { highlightFzfMatch } from "../../utils/highlight"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
const GlamaModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, glamaModels } = useExtensionState()
const { apiConfiguration, setApiConfiguration, glamaModels, onUpdateApiConfig } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -22,11 +22,14 @@ const GlamaModelPicker: React.FC = () => {
const handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it
setApiConfiguration({
const apiConfig = {
...apiConfiguration,
glamaModelId: newModelId,
glamaModelInfo: glamaModels[newModelId],
})
}
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setSearchTerm(newModelId)
}
@@ -34,6 +37,13 @@ const GlamaModelPicker: React.FC = () => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
useEffect(() => {
if (apiConfiguration?.glamaModelId && apiConfiguration?.glamaModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.glamaModelId)
}
}, [apiConfiguration, searchTerm])
useMount(() => {
vscode.postMessage({ type: "refreshGlamaModels" })
})
@@ -62,25 +72,21 @@ const GlamaModelPicker: React.FC = () => {
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
return results
}, [searchableItems, searchTerm, fuse])
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
}))
}, [searchableItems, searchTerm, fzf])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) return

View File

@@ -1,14 +1,14 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import { Fzf } from "fzf"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
import styled from "styled-components"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { highlightFzfMatch } from "../../utils/highlight"
const OpenAiModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, openAiModels } = useExtensionState()
const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "")
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -18,13 +18,22 @@ const OpenAiModelPicker: React.FC = () => {
const handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it
setApiConfiguration({
const apiConfig = {
...apiConfiguration,
openAiModelId: newModelId,
})
}
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setSearchTerm(newModelId)
}
useEffect(() => {
if (apiConfiguration?.openAiModelId && apiConfiguration?.openAiModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.openAiModelId)
}
}, [apiConfiguration, searchTerm])
useEffect(() => {
if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) {
return
@@ -62,25 +71,21 @@ const OpenAiModelPicker: React.FC = () => {
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
return results
}, [searchableItems, searchTerm, fuse])
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
}))
}, [searchableItems, searchTerm, fzf])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) return

View File

@@ -1,5 +1,5 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import { Fzf } from "fzf"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
import { useMount } from "react-use"
@@ -7,11 +7,11 @@ import styled from "styled-components"
import { openRouterDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { highlightFzfMatch } from "../../utils/highlight"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
const OpenRouterModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState()
const { apiConfiguration, setApiConfiguration, openRouterModels, onUpdateApiConfig } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -22,11 +22,14 @@ const OpenRouterModelPicker: React.FC = () => {
const handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it
setApiConfiguration({
const apiConfig = {
...apiConfiguration,
openRouterModelId: newModelId,
openRouterModelInfo: openRouterModels[newModelId],
})
}
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setSearchTerm(newModelId)
}
@@ -34,6 +37,12 @@ const OpenRouterModelPicker: React.FC = () => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
useEffect(() => {
if (apiConfiguration?.openRouterModelId && apiConfiguration?.openRouterModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.openRouterModelId)
}
}, [apiConfiguration, searchTerm])
useMount(() => {
vscode.postMessage({ type: "refreshOpenRouterModels" })
})
@@ -62,25 +71,21 @@ const OpenRouterModelPicker: React.FC = () => {
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
const fzf = useMemo(() => {
return new Fzf(searchableItems, {
selector: item => item.html
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
return results
}, [searchableItems, searchTerm, fuse])
if (!searchTerm) return searchableItems
const searchResults = fzf.find(searchTerm)
return searchResults.map(result => ({
...result.item,
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
}))
}, [searchableItems, searchTerm, fzf])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) return

View File

@@ -5,6 +5,8 @@ import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import ApiOptions from "./ApiOptions"
import McpEnabledToggle from "../mcp/McpEnabledToggle"
import ApiConfigManager from "./ApiConfigManager"
import { Mode } from "../../../../src/shared/modes"
const IS_DEV = false // FIXME: use flags when packaging
@@ -55,12 +57,17 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysApproveResubmit,
requestDelaySeconds,
setRequestDelaySeconds,
experimentalDiffStrategy,
currentApiConfigName,
listApiConfigMeta,
mode,
setMode,
experimentalDiffStrategy,
setExperimentalDiffStrategy,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("")
const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
@@ -91,7 +98,14 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
vscode.postMessage({ type: "experimentalDiffStrategy", bool: experimentalDiffStrategy })
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration
})
vscode.postMessage({ type: "mode", text: mode })
vscode.postMessage({ type: "experimentalDiffStrategy", bool: experimentalDiffStrategy })
onDone()
}
}
@@ -155,8 +169,37 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
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>
<ApiConfigManager
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
onSelectConfig={(configName: string) => {
vscode.postMessage({
type: "loadApiConfiguration",
text: configName
})
}}
onDeleteConfig={(configName: string) => {
vscode.postMessage({
type: "deleteApiConfiguration",
text: configName
})
}}
onRenameConfig={(oldName: string, newName: string) => {
vscode.postMessage({
type: "renameApiConfiguration",
values: { oldName, newName },
apiConfiguration
})
}}
onUpsertConfig={(configName: string) => {
vscode.postMessage({
type: "upsertApiConfiguration",
text: configName,
apiConfiguration
})
}}
/>
<ApiOptions
showModelOptions={true}
apiErrorMessage={apiErrorMessage}
modelIdErrorMessage={modelIdErrorMessage}
/>
@@ -166,6 +209,37 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 15 }}>
<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>
<select
value={mode}
onChange={(e) => {
const value = e.target.value as Mode
setMode(value)
vscode.postMessage({ type: "mode", text: value })
}}
style={{
width: "100%",
padding: "4px 8px",
backgroundColor: "var(--vscode-input-background)",
color: "var(--vscode-input-foreground)",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
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>
</div>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Preferred Language</label>
<select
value={preferredLanguage}
@@ -207,24 +281,26 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
</div>
<VSCodeTextArea
value={customInstructions ?? ""}
style={{ width: "100%" }}
rows={4}
placeholder={
'e.g. "Run unit tests at the end", "Use TypeScript with async/await", "Speak in Spanish"'
}
onInput={(e: any) => setCustomInstructions(e.target?.value ?? "")}>
<div style={{ marginBottom: 15 }}>
<span style={{ fontWeight: "500" }}>Custom Instructions</span>
</VSCodeTextArea>
<p
style={{
fontSize: "12px",
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 and .cursorrules in the working directory are also included.
</p>
<VSCodeTextArea
value={customInstructions ?? ""}
style={{ width: "100%" }}
rows={4}
placeholder={
'e.g. "Run unit tests at the end", "Use TypeScript with async/await", "Speak in Spanish"'
}
onInput={(e: any) => setCustomInstructions(e.target?.value ?? "")}
/>
<p
style={{
fontSize: "12px",
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.
</p>
</div>
<McpEnabledToggle />
</div>
@@ -427,10 +503,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowMcp}
onChange={(e: any) => {
setAlwaysAllowMcp(e.target.checked)
vscode.postMessage({ type: "alwaysAllowMcp", bool: e.target.checked })
}}>
onChange={(e: any) => setAlwaysAllowMcp(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
@@ -505,7 +578,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
minWidth: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
color: 'var(--vscode-button-foreground)',
}}
onClick={() => {
const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index)
@@ -658,7 +732,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<p style={{ wordWrap: "break-word", margin: 0, padding: 0 }}>
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" }}>
https://github.com/RooVetGit/Roo-Cline
github.com/RooVetGit/Roo-Cline
</VSCodeLink> or join {" "}
<VSCodeLink href="https://www.reddit.com/r/roocline/" style={{ display: "inline" }}>
reddit.com/r/roocline
</VSCodeLink>
</p>
<p style={{ fontStyle: "italic", margin: "10px 0 0 0", padding: 0 }}>v{version}</p>

View File

@@ -0,0 +1,154 @@
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
/>
),
}));
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,
};
beforeEach(() => {
jest.clearAllMocks();
});
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);
// 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"
/>
);
const addButton = screen.getByTitle('Add profile');
fireEvent.click(addButton);
expect(mockOnUpsertConfig).toHaveBeenCalledWith('My Test Config (copy)');
});
it('handles empty current config name gracefully', () => {
render(
<ApiConfigManager
{...defaultProps}
currentApiConfigName=""
/>
);
const addButton = screen.getByTitle('Add profile');
fireEvent.click(addButton);
expect(mockOnUpsertConfig).toHaveBeenCalledWith(' (copy)');
});
it('allows renaming the current config', () => {
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' } });
// Save
const saveButton = screen.getByTitle('Save');
fireEvent.click(saveButton);
expect(mockOnRenameConfig).toHaveBeenCalledWith('Default Config', 'New Name');
});
it('allows selecting a different config', () => {
render(<ApiConfigManager {...defaultProps} />);
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: 'Another Config' } });
expect(mockOnSelectConfig).toHaveBeenCalledWith('Another Config');
});
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');
});
it('disables delete button when only one config exists', () => {
render(
<ApiConfigManager
{...defaultProps}
listApiConfigMeta={[{ name: 'Default Config' }]}
/>
);
const deleteButton = screen.getByTitle('Cannot delete the only profile');
expect(deleteButton).toHaveAttribute('disabled');
});
it('cancels rename operation when clicking cancel', () => {
render(<ApiConfigManager {...defaultProps} />);
// Start rename
const renameButton = screen.getByTitle('Rename profile');
fireEvent.click(renameButton);
// Find input and enter new name
const input = screen.getByDisplayValue('Default Config');
fireEvent.input(input, { target: { value: 'New Name' } });
// Cancel
const cancelButton = screen.getByTitle('Cancel');
fireEvent.click(cancelButton);
// Verify rename was not called
expect(mockOnRenameConfig).not.toHaveBeenCalled();
// Verify we're back to normal view
expect(screen.queryByDisplayValue('New Name')).not.toBeInTheDocument();
});
});

View File

@@ -11,6 +11,16 @@ jest.mock('../../../utils/vscode', () => ({
},
}))
// 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>
)
}))
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick, appearance }: any) => (
@@ -185,6 +195,18 @@ describe('SettingsView - Sound Settings', () => {
})
})
describe('SettingsView - API Configuration', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders ApiConfigManagement with correct props', () => {
renderSettingsView()
expect(screen.getByTestId('api-config-management')).toBeInTheDocument()
})
})
describe('SettingsView - Allowed Commands', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@@ -1,4 +1,4 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useEffect, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration } from "../../utils/validate"
@@ -24,21 +24,16 @@ 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{" "}
<VSCodeLink
href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
style={{ display: "inline" }}>
Claude 3.5 Sonnet's agentic coding capabilities
</VSCodeLink>{" "}
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 for Claude 3.5 Sonnet.</b>
<b>To get started, this extension needs an API provider.</b>
<div style={{ marginTop: "10px" }}>
<ApiOptions showModelOptions={false} />
<ApiOptions />
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}>
Let's go!
</VSCodeButton>

View File

@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
import { useEvent } from "react-use"
import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
import {
ApiConfiguration,
ModelInfo,
@@ -13,6 +13,11 @@ 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 { Mode } from "../../../src/core/prompts/types"
import { codeMode, CustomPrompts, defaultPrompts } from "../../../src/shared/modes"
export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@@ -50,7 +55,15 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysApproveResubmit: (value: boolean) => void
requestDelaySeconds: number
setRequestDelaySeconds: (value: number) => void
experimentalDiffStrategy: boolean
setCurrentApiConfigName: (value: string) => void
setListApiConfigMeta: (value: ApiConfigMeta[]) => void
onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
mode: Mode
setMode: (value: Mode) => void
setCustomPrompts: (value: CustomPrompts) => void
enhancementApiConfigId?: string
setEnhancementApiConfigId: (value: string) => void
experimentalDiffStrategy: boolean
setExperimentalDiffStrategy: (value: boolean) => void
}
@@ -74,8 +87,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
terminalOutputLineLimit: 500,
mcpEnabled: true,
alwaysApproveResubmit: false,
requestDelaySeconds: 0,
experimentalDiffStrategy: false,
requestDelaySeconds: 5,
currentApiConfigName: 'default',
listApiConfigMeta: [],
mode: codeMode,
customPrompts: defaultPrompts,
enhancementApiConfigId: '',
experimentalDiffStrategy: false,
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@@ -91,27 +109,27 @@ 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 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": {
setState(message.state!)
setState(prevState => ({
...prevState,
...message.state!
}))
const config = message.state?.apiConfiguration
const hasKey = config
? [
config.apiKey,
config.glamaApiKey,
config.openRouterApiKey,
config.awsRegion,
config.vertexProjectId,
config.openAiApiKey,
config.ollamaModelId,
config.lmStudioModelId,
config.geminiApiKey,
config.openAiNativeApiKey,
config.deepSeekApiKey,
].some((key) => key !== undefined)
: false
const hasKey = checkExistKey(config)
setShowWelcome(!hasKey)
setDidHydrateState(true)
break
@@ -165,8 +183,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setMcpServers(message.mcpServers ?? [])
break
}
case "listApiConfig": {
setListApiConfigMeta(message.listApiConfig ?? [])
break
}
}
}, [])
}, [setListApiConfigMeta])
useEvent("message", handleMessage)
@@ -212,7 +234,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })),
setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })),
setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })),
experimentalDiffStrategy: state.experimentalDiffStrategy ?? false,
setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })),
setListApiConfigMeta,
onUpdateApiConfig,
setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })),
setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
experimentalDiffStrategy: state.experimentalDiffStrategy ?? false,
setExperimentalDiffStrategy: (value) => setState((prevState) => ({ ...prevState, experimentalDiffStrategy: value }))
}

View File

@@ -1,4 +1,5 @@
import { mentionRegex } from "../../../src/shared/context-mentions"
import { Fzf } from "fzf"
export function insertMention(
text: string,
@@ -147,13 +148,21 @@ export function getContextMenuOptions(
}
}
// Get matching items, separating by type
const matchingItems = queryItems.filter((item) =>
item.value?.toLowerCase().includes(lowerQuery) ||
item.label?.toLowerCase().includes(lowerQuery) ||
item.description?.toLowerCase().includes(lowerQuery)
)
// Create searchable strings array for fzf
const searchableItems = queryItems.map(item => ({
original: item,
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
})
// Get fuzzy matching items
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
@@ -169,7 +178,18 @@ export function getContextMenuOptions(
// Combine suggestions with matching items in the desired order
if (suggestions.length > 0 || matchingItems.length > 0) {
return [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
const allItems = [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
// Remove duplicates based on type and value
const seen = new Set()
const deduped = allItems.filter(item => {
const key = `${item.type}-${item.value}`
if (seen.has(key)) return false
seen.add(key)
return true
})
return deduped
}
return [{ type: ContextMenuOptionType.NoResults }]

View File

@@ -0,0 +1,44 @@
export function highlightFzfMatch(text: string, positions: number[], highlightClassName: string = "history-item-highlight") {
if (!positions.length) return text
const parts: { text: string; highlight: boolean }[] = []
let lastIndex = 0
// Sort positions to ensure we process them in order
positions.sort((a, b) => a - b)
positions.forEach((pos) => {
// Add non-highlighted text before this position
if (pos > lastIndex) {
parts.push({
text: text.substring(lastIndex, pos),
highlight: false
})
}
// Add highlighted character
parts.push({
text: text[pos],
highlight: true
})
lastIndex = pos + 1
})
// Add any remaining text
if (lastIndex < text.length) {
parts.push({
text: text.substring(lastIndex),
highlight: false
})
}
// Build final string
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('')
}