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,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()