merge: resolve conflicts after upstream merge

This commit is contained in:
RaySinner
2025-01-08 23:47:26 +03:00
34 changed files with 2418 additions and 175 deletions

View File

@@ -44,9 +44,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
const { filePaths, apiConfiguration } = useExtensionState()
const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
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(() => {
@@ -649,13 +661,62 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
style={{
position: "absolute",
paddingTop: 4,
bottom: 14,
bottom: 32,
left: 22,
right: 67,
zIndex: 2,
}}
/>
)}
{(listApiConfigMeta || []).length > 1 && (
<div
style={{
position: "absolute",
left: 25,
bottom: 14,
zIndex: 2
}}
>
<select
value={currentApiConfigName}
disabled={textAreaDisabled}
onChange={(e) => vscode.postMessage({
type: "loadApiConfiguration",
text: e.target.value
})}
style={{
fontSize: "11px",
cursor: textAreaDisabled ? "not-allowed" : "pointer",
backgroundColor: "transparent",
border: "none",
color: "var(--vscode-input-foreground)",
opacity: textAreaDisabled ? 0.5 : 0.6,
outline: "none",
paddingLeft: 14,
WebkitAppearance: "none",
MozAppearance: "none",
appearance: "none",
backgroundImage: "url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\")",
backgroundRepeat: "no-repeat",
backgroundPosition: "left 0px center",
backgroundSize: "10px"
}}
>
{(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>
)}
<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" && (

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"
@@ -868,12 +868,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

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

@@ -44,13 +44,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 [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
@@ -59,7 +58,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(() => {
@@ -743,16 +744,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

@@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView"
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" })
})

View File

@@ -8,7 +8,7 @@ import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
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

View File

@@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView"
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" })
})

View File

@@ -5,6 +5,7 @@ import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import ApiOptions from "./ApiOptions"
import McpEnabledToggle from "../mcp/McpEnabledToggle"
import ApiConfigManager from "./ApiConfigManager"
const IS_DEV = false // FIXME: use flags when packaging
@@ -51,10 +52,17 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
terminalOutputLineLimit,
setTerminalOutputLineLimit,
mcpEnabled,
alwaysApproveResubmit,
setAlwaysApproveResubmit,
requestDelaySeconds,
setRequestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
} = 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)
@@ -83,6 +91,15 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 })
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration
})
onDone()
}
}
@@ -146,8 +163,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}
/>
@@ -355,18 +401,51 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Automatically perform browser actions without requiring approval<br/>
Automatically perform browser actions without requiring approval<br />
Note: Only applies when the model supports computer use
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysApproveResubmit}
onChange={(e: any) => setAlwaysApproveResubmit(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always retry failed API requests</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Automatically retry failed API requests when server returns an error response
</p>
{alwaysApproveResubmit && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="range"
min="0"
max="100"
step="1"
value={requestDelaySeconds}
onChange={(e) => setRequestDelaySeconds(parseInt(e.target.value))}
style={{
flex: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
}}
/>
<span style={{ minWidth: '45px', textAlign: 'left' }}>
{requestDelaySeconds}s
</span>
</div>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Delay before retrying the request
</p>
</div>
)}
</div>
<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)" }}>
@@ -525,7 +604,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 5 }}>
<div style={{ marginBottom: 10 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Notification Settings</h3>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Notification Settings</h3>
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable sound effects</span>
</VSCodeCheckbox>

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,9 @@ 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"
export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@@ -46,6 +49,13 @@ export interface ExtensionStateContextType extends ExtensionState {
setTerminalOutputLineLimit: (value: number) => void
mcpEnabled: boolean
setMcpEnabled: (value: boolean) => void
alwaysApproveResubmit?: boolean
setAlwaysApproveResubmit: (value: boolean) => void
requestDelaySeconds: number
setRequestDelaySeconds: (value: number) => void
setCurrentApiConfigName: (value: string) => void
setListApiConfigMeta: (value: ApiConfigMeta[]) => void
onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -67,6 +77,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
screenshotQuality: 75,
terminalOutputLineLimit: 500,
mcpEnabled: true,
alwaysApproveResubmit: false,
requestDelaySeconds: 5,
currentApiConfigName: 'default',
listApiConfigMeta: [],
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@@ -82,27 +96,24 @@ 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!)
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
@@ -156,8 +167,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setMcpServers(message.mcpServers ?? [])
break
}
case "listApiConfig": {
setListApiConfigMeta(message.listApiConfig ?? [])
break
}
}
}, [])
}, [setListApiConfigMeta])
useEvent("message", handleMessage)
@@ -201,6 +216,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })),
setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })),
setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })),
setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })),
setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })),
setListApiConfigMeta,
onUpdateApiConfig
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>