Merge pull request #297 from RooVetGit/api_config

Save different API configurations to quickly switch between providers and settings
This commit is contained in:
Matt Rubens
2025-01-08 08:50:23 -05:00
committed by GitHub
22 changed files with 1585 additions and 139 deletions

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Save different API configurations to quickly switch between providers and settings (thanks @samhvw8!)

View File

@@ -7,6 +7,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
- Drag and drop images into chats - Drag and drop images into chats
- Delete messages from chats - Delete messages from chats
- @-mention Git commits to include their context in the chat - @-mention Git commits to include their context in the chat
- Save different API configurations to quickly switch between providers and settings
- "Enhance prompt" button (OpenRouter models only for now) - "Enhance prompt" button (OpenRouter models only for now)
- Sound effects for feedback - Sound effects for feedback
- Option to use browsers of different sizes and adjust screenshot quality - Option to use browsers of different sizes and adjust screenshot quality

View File

@@ -799,8 +799,30 @@ export class Cline {
} }
} }
// Convert to Anthropic.MessageParam by spreading only the API-required properties // Clean conversation history by:
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => ({ role, content })) // 1. Converting to Anthropic.MessageParam by spreading only the API-required properties
// 2. Converting image blocks to text descriptions if model doesn't support images
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => {
// Handle array content (could contain image blocks)
if (Array.isArray(content)) {
if (!this.api.getModel().info.supportsImages) {
// Convert image blocks to text descriptions
content = content.map(block => {
if (block.type === 'image') {
// Convert image blocks to text descriptions
// Note: We can't access the actual image content/url due to API limitations,
// but we can indicate that an image was present in the conversation
return {
type: 'text',
text: '[Referenced image in conversation]'
};
}
return block;
});
}
}
return { role, content }
})
const stream = this.api.createMessage(systemPrompt, cleanConversationHistory) const stream = this.api.createMessage(systemPrompt, cleanConversationHistory)
const iterator = stream[Symbol.asyncIterator]() const iterator = stream[Symbol.asyncIterator]()

View File

@@ -1,7 +1,8 @@
import { Cline } from '../Cline'; import { Cline } from '../Cline';
import { ClineProvider } from '../webview/ClineProvider'; import { ClineProvider } from '../webview/ClineProvider';
import { ApiConfiguration } from '../../shared/api'; import { ApiConfiguration, ModelInfo } from '../../shared/api';
import { ApiStreamChunk } from '../../api/transform/stream'; import { ApiStreamChunk } from '../../api/transform/stream';
import { Anthropic } from '@anthropic-ai/sdk';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
// Mock all MCP-related modules // Mock all MCP-related modules
@@ -498,6 +499,133 @@ describe('Cline', () => {
expect(passedMessage).not.toHaveProperty('ts'); expect(passedMessage).not.toHaveProperty('ts');
expect(passedMessage).not.toHaveProperty('extraProp'); expect(passedMessage).not.toHaveProperty('extraProp');
}); });
it('should handle image blocks based on model capabilities', async () => {
// Create two configurations - one with image support, one without
const configWithImages = {
...mockApiConfig,
apiModelId: 'claude-3-sonnet'
};
const configWithoutImages = {
...mockApiConfig,
apiModelId: 'gpt-3.5-turbo'
};
// Create test conversation history with mixed content
const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [
{
role: 'user' as const,
content: [
{
type: 'text' as const,
text: 'Here is an image'
} satisfies Anthropic.TextBlockParam,
{
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: 'image/jpeg',
data: 'base64data'
}
} satisfies Anthropic.ImageBlockParam
]
},
{
role: 'assistant' as const,
content: [{
type: 'text' as const,
text: 'I see the image'
} satisfies Anthropic.TextBlockParam]
}
];
// Test with model that supports images
const clineWithImages = new Cline(
mockProvider,
configWithImages,
undefined,
false,
undefined,
'test task'
);
// Mock the model info to indicate image support
jest.spyOn(clineWithImages.api, 'getModel').mockReturnValue({
id: 'claude-3-sonnet',
info: {
supportsImages: true,
supportsPromptCache: true,
supportsComputerUse: true,
contextWindow: 200000,
maxTokens: 4096,
inputPrice: 0.25,
outputPrice: 0.75
} as ModelInfo
});
clineWithImages.apiConversationHistory = conversationHistory;
// Test with model that doesn't support images
const clineWithoutImages = new Cline(
mockProvider,
configWithoutImages,
undefined,
false,
undefined,
'test task'
);
// Mock the model info to indicate no image support
jest.spyOn(clineWithoutImages.api, 'getModel').mockReturnValue({
id: 'gpt-3.5-turbo',
info: {
supportsImages: false,
supportsPromptCache: false,
supportsComputerUse: false,
contextWindow: 16000,
maxTokens: 2048,
inputPrice: 0.1,
outputPrice: 0.2
} as ModelInfo
});
clineWithoutImages.apiConversationHistory = conversationHistory;
// Create message spy for both instances
const createMessageSpyWithImages = jest.fn();
const createMessageSpyWithoutImages = jest.fn();
const mockStream = {
async *[Symbol.asyncIterator]() {
yield { type: 'text', text: '' };
}
} as AsyncGenerator<ApiStreamChunk>;
jest.spyOn(clineWithImages.api, 'createMessage').mockImplementation((...args) => {
createMessageSpyWithImages(...args);
return mockStream;
});
jest.spyOn(clineWithoutImages.api, 'createMessage').mockImplementation((...args) => {
createMessageSpyWithoutImages(...args);
return mockStream;
});
// Trigger API requests for both instances
await clineWithImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]);
await clineWithoutImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]);
// Verify model with image support preserves image blocks
const callsWithImages = createMessageSpyWithImages.mock.calls;
const historyWithImages = callsWithImages[0][1][0];
expect(historyWithImages.content).toHaveLength(2);
expect(historyWithImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' });
expect(historyWithImages.content[1]).toHaveProperty('type', 'image');
// Verify model without image support converts image blocks to text
const callsWithoutImages = createMessageSpyWithoutImages.mock.calls;
const historyWithoutImages = callsWithoutImages[0][1][0];
expect(historyWithoutImages.content).toHaveLength(2);
expect(historyWithoutImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' });
expect(historyWithoutImages.content[1]).toEqual({
type: 'text',
text: '[Referenced image in conversation]'
});
});
}); });
}); });
}); });

View File

@@ -0,0 +1,164 @@
import { ExtensionContext } from 'vscode'
import { ApiConfiguration } from '../../shared/api'
import { ApiConfigMeta } from '../../shared/ExtensionMessage'
export interface ApiConfigData {
currentApiConfigName: string
apiConfigs: {
[key: string]: ApiConfiguration
}
}
export class ConfigManager {
private readonly defaultConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {}
}
}
private readonly SCOPE_PREFIX = "roo_cline_config_"
private readonly context: ExtensionContext
constructor(context: ExtensionContext) {
this.context = context
}
/**
* Initialize config if it doesn't exist
*/
async initConfig(): Promise<void> {
try {
const config = await this.readConfig()
if (!config) {
await this.writeConfig(this.defaultConfig)
}
} catch (error) {
throw new Error(`Failed to initialize config: ${error}`)
}
}
/**
* List all available configs with metadata
*/
async ListConfig(): Promise<ApiConfigMeta[]> {
try {
const config = await this.readConfig()
return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
name,
apiProvider: apiConfig.apiProvider,
}))
} catch (error) {
throw new Error(`Failed to list configs: ${error}`)
}
}
/**
* Save a config with the given name
*/
async SaveConfig(name: string, config: ApiConfiguration): Promise<void> {
try {
const currentConfig = await this.readConfig()
currentConfig.apiConfigs[name] = config
await this.writeConfig(currentConfig)
} catch (error) {
throw new Error(`Failed to save config: ${error}`)
}
}
/**
* Load a config by name
*/
async LoadConfig(name: string): Promise<ApiConfiguration> {
try {
const config = await this.readConfig()
const apiConfig = config.apiConfigs[name]
if (!apiConfig) {
throw new Error(`Config '${name}' not found`)
}
config.currentApiConfigName = name;
await this.writeConfig(config)
return apiConfig
} catch (error) {
throw new Error(`Failed to load config: ${error}`)
}
}
/**
* Delete a config by name
*/
async DeleteConfig(name: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
// Don't allow deleting the default config
if (Object.keys(currentConfig.apiConfigs).length === 1) {
throw new Error(`Cannot delete the last remaining configuration.`)
}
delete currentConfig.apiConfigs[name]
await this.writeConfig(currentConfig)
} catch (error) {
throw new Error(`Failed to delete config: ${error}`)
}
}
/**
* Set the current active API configuration
*/
async SetCurrentConfig(name: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
currentConfig.currentApiConfigName = name
await this.writeConfig(currentConfig)
} catch (error) {
throw new Error(`Failed to set current config: ${error}`)
}
}
/**
* Check if a config exists by name
*/
async HasConfig(name: string): Promise<boolean> {
try {
const config = await this.readConfig()
return name in config.apiConfigs
} catch (error) {
throw new Error(`Failed to check config existence: ${error}`)
}
}
private async readConfig(): Promise<ApiConfigData> {
try {
const configKey = `${this.SCOPE_PREFIX}api_config`
const content = await this.context.secrets.get(configKey)
if (!content) {
return this.defaultConfig
}
return JSON.parse(content)
} catch (error) {
throw new Error(`Failed to read config from secrets: ${error}`)
}
}
private async writeConfig(config: ApiConfigData): Promise<void> {
try {
const configKey = `${this.SCOPE_PREFIX}api_config`
const content = JSON.stringify(config, null, 2)
await this.context.secrets.store(configKey, content)
} catch (error) {
throw new Error(`Failed to write config to secrets: ${error}`)
}
}
}

View File

@@ -0,0 +1,384 @@
import { ExtensionContext } from 'vscode'
import { ConfigManager, ApiConfigData } from '../ConfigManager'
import { ApiConfiguration } from '../../../shared/api'
// Mock VSCode ExtensionContext
const mockSecrets = {
get: jest.fn(),
store: jest.fn(),
delete: jest.fn()
}
const mockContext = {
secrets: mockSecrets
} as unknown as ExtensionContext
describe('ConfigManager', () => {
let configManager: ConfigManager
beforeEach(() => {
jest.clearAllMocks()
configManager = new ConfigManager(mockContext)
})
describe('initConfig', () => {
it('should not write to storage when secrets.get returns null', async () => {
// Mock readConfig to return null
mockSecrets.get.mockResolvedValueOnce(null)
await configManager.initConfig()
// Should not write to storage because readConfig returns defaultConfig
expect(mockSecrets.store).not.toHaveBeenCalled()
})
it('should not initialize config if it exists', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: {
default: {}
}
}))
await configManager.initConfig()
expect(mockSecrets.store).not.toHaveBeenCalled()
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockRejectedValue(new Error('Storage failed'))
await expect(configManager.initConfig()).rejects.toThrow(
'Failed to initialize config: Error: Failed to read config from secrets: Error: Storage failed'
)
})
})
describe('ListConfig', () => {
it('should list all available configs', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const configs = await configManager.ListConfig()
expect(configs).toEqual([
{ name: 'default', apiProvider: undefined },
{ name: 'test', apiProvider: 'anthropic' }
])
})
it('should handle empty config file', async () => {
const emptyConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(emptyConfig))
const configs = await configManager.ListConfig()
expect(configs).toEqual([])
})
it('should throw error if reading from secrets fails', async () => {
mockSecrets.get.mockRejectedValue(new Error('Read failed'))
await expect(configManager.ListConfig()).rejects.toThrow(
'Failed to list configs: Error: Failed to read config from secrets: Error: Read failed'
)
})
})
describe('SaveConfig', () => {
it('should save new config', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: {
default: {}
}
}))
const newConfig: ApiConfiguration = {
apiProvider: 'anthropic',
apiKey: 'test-key'
}
await configManager.SaveConfig('test', newConfig)
const expectedConfig = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: newConfig
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should update existing config', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
test: {
apiProvider: 'anthropic',
apiKey: 'old-key'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const updatedConfig: ApiConfiguration = {
apiProvider: 'anthropic',
apiKey: 'new-key'
}
await configManager.SaveConfig('test', updatedConfig)
const expectedConfig = {
currentApiConfigName: 'default',
apiConfigs: {
test: updatedConfig
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
await expect(configManager.SaveConfig('test', {})).rejects.toThrow(
'Failed to save config: Error: Failed to write config to secrets: Error: Storage failed'
)
})
})
describe('DeleteConfig', () => {
it('should delete existing config', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
await configManager.DeleteConfig('test')
const expectedConfig = {
currentApiConfigName: 'default',
apiConfigs: {
default: {}
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should throw error when trying to delete non-existent config', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
await expect(configManager.DeleteConfig('nonexistent')).rejects.toThrow(
"Config 'nonexistent' not found"
)
})
it('should throw error when trying to delete last remaining config', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
await expect(configManager.DeleteConfig('default')).rejects.toThrow(
'Cannot delete the last remaining configuration.'
)
})
})
describe('LoadConfig', () => {
it('should load config and update current config name', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
test: {
apiProvider: 'anthropic',
apiKey: 'test-key'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const config = await configManager.LoadConfig('test')
expect(config).toEqual({
apiProvider: 'anthropic',
apiKey: 'test-key'
})
const expectedConfig = {
currentApiConfigName: 'test',
apiConfigs: {
test: {
apiProvider: 'anthropic',
apiKey: 'test-key'
}
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should throw error when config does not exist', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
await expect(configManager.LoadConfig('nonexistent')).rejects.toThrow(
"Config 'nonexistent' not found"
)
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: {
test: { apiProvider: 'anthropic' }
}
}))
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
await expect(configManager.LoadConfig('test')).rejects.toThrow(
'Failed to load config: Error: Failed to write config to secrets: Error: Storage failed'
)
})
})
describe('SetCurrentConfig', () => {
it('should set current config', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
await configManager.SetCurrentConfig('test')
const expectedConfig = {
currentApiConfigName: 'test',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should throw error when config does not exist', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
await expect(configManager.SetCurrentConfig('nonexistent')).rejects.toThrow(
"Config 'nonexistent' not found"
)
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: {
test: { apiProvider: 'anthropic' }
}
}))
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
await expect(configManager.SetCurrentConfig('test')).rejects.toThrow(
'Failed to set current config: Error: Failed to write config to secrets: Error: Storage failed'
)
})
})
describe('HasConfig', () => {
it('should return true for existing config', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const hasConfig = await configManager.HasConfig('test')
expect(hasConfig).toBe(true)
})
it('should return false for non-existent config', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
const hasConfig = await configManager.HasConfig('nonexistent')
expect(hasConfig).toBe(false)
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockRejectedValue(new Error('Storage failed'))
await expect(configManager.HasConfig('test')).rejects.toThrow(
'Failed to check config existence: Error: Failed to read config from secrets: Error: Storage failed'
)
})
})
})

View File

@@ -12,9 +12,9 @@ import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme" import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub" import { McpHub } from "../../services/mcp/McpHub"
import { ApiProvider, ModelInfo } from "../../shared/api" import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array" import { findLast } from "../../shared/array"
import { ExtensionMessage } from "../../shared/ExtensionMessage" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem" import { HistoryItem } from "../../shared/HistoryItem"
import { WebviewMessage } from "../../shared/WebviewMessage" import { WebviewMessage } from "../../shared/WebviewMessage"
import { fileExistsAtPath } from "../../utils/fs" import { fileExistsAtPath } from "../../utils/fs"
@@ -23,8 +23,10 @@ import { openMention } from "../mentions"
import { getNonce } from "./getNonce" import { getNonce } from "./getNonce"
import { getUri } from "./getUri" import { getUri } from "./getUri"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { enhancePrompt } from "../../utils/enhance-prompt" import { enhancePrompt } from "../../utils/enhance-prompt"
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git" import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
import { ConfigManager } from "../config/ConfigManager"
/* /*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -85,6 +87,9 @@ type GlobalStateKey =
| "mcpEnabled" | "mcpEnabled"
| "alwaysApproveResubmit" | "alwaysApproveResubmit"
| "requestDelaySeconds" | "requestDelaySeconds"
| "currentApiConfigName"
| "listApiConfigMeta"
export const GlobalFileNames = { export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json", apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json", uiMessages: "ui_messages.json",
@@ -103,6 +108,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private workspaceTracker?: WorkspaceTracker private workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub mcpHub?: McpHub
private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement
configManager: ConfigManager
constructor( constructor(
readonly context: vscode.ExtensionContext, readonly context: vscode.ExtensionContext,
@@ -112,6 +118,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
ClineProvider.activeInstances.add(this) ClineProvider.activeInstances.add(this)
this.workspaceTracker = new WorkspaceTracker(this) this.workspaceTracker = new WorkspaceTracker(this)
this.mcpHub = new McpHub(this) this.mcpHub = new McpHub(this)
this.configManager = new ConfigManager(this.context)
} }
/* /*
@@ -235,7 +242,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled, diffEnabled,
fuzzyMatchThreshold fuzzyMatchThreshold
} = await this.getState() } = await this.getState()
this.cline = new Cline( this.cline = new Cline(
this, this,
apiConfiguration, apiConfiguration,
@@ -255,7 +262,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled, diffEnabled,
fuzzyMatchThreshold fuzzyMatchThreshold
} = await this.getState() } = await this.getState()
this.cline = new Cline( this.cline = new Cline(
this, this,
apiConfiguration, apiConfiguration,
@@ -321,15 +328,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// Use a nonce to only allow a specific script to be run. // Use a nonce to only allow a specific script to be run.
/* /*
content security policy of your webview to only allow scripts that have a specific nonce content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection - 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:; - since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
*/ */
const nonce = getNonce() const nonce = getNonce()
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below // Tip: Install the es6-string-html VS Code extension to enable code highlighting below
@@ -410,6 +417,55 @@ export class ClineProvider implements vscode.WebviewViewProvider {
} }
} }
}) })
this.configManager.ListConfig().then(async (listApiConfig) => {
if (!listApiConfig) {
return
}
if (listApiConfig.length === 1) {
// check if first time init then sync with exist config
if (!checkExistKey(listApiConfig[0])) {
const {
apiConfiguration,
} = await this.getState()
await this.configManager.SaveConfig(listApiConfig[0].name ?? "default", apiConfiguration)
listApiConfig[0].apiProvider = apiConfiguration.apiProvider
}
}
let currentConfigName = await this.getGlobalState("currentApiConfigName") as string
if (currentConfigName) {
if (!await this.configManager.HasConfig(currentConfigName)) {
// current config name not valid, get first config in list
await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name)
if (listApiConfig?.[0]?.name) {
const apiConfig = await this.configManager.LoadConfig(listApiConfig?.[0]?.name);
await Promise.all([
this.updateGlobalState("listApiConfigMeta", listApiConfig),
this.postMessageToWebview({ type: "listApiConfig", listApiConfig }),
this.updateApiConfiguration(apiConfig),
])
await this.postStateToWebview()
return
}
}
}
await Promise.all(
[
await this.updateGlobalState("listApiConfigMeta", listApiConfig),
await this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
]
)
}).catch(console.error);
break break
case "newTask": case "newTask":
// Code that should run in response to the hello message command // Code that should run in response to the hello message command
@@ -424,70 +480,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break break
case "apiConfiguration": case "apiConfiguration":
if (message.apiConfiguration) { if (message.apiConfiguration) {
const { await this.updateApiConfiguration(message.apiConfiguration)
apiProvider,
apiModelId,
apiKey,
glamaModelId,
glamaModelInfo,
glamaApiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterUseMiddleOutTransform,
} = message.apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
await this.storeSecret("apiKey", apiKey)
await this.updateGlobalState("glamaModelId", glamaModelId)
await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
await this.storeSecret("glamaApiKey", glamaApiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
await this.storeSecret("awsSessionToken", awsSessionToken)
await this.updateGlobalState("awsRegion", awsRegion)
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
await this.updateGlobalState("vertexProjectId", vertexProjectId)
await this.updateGlobalState("vertexRegion", vertexRegion)
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
await this.storeSecret("openAiApiKey", openAiApiKey)
await this.updateGlobalState("openAiModelId", openAiModelId)
await this.updateGlobalState("ollamaModelId", ollamaModelId)
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
await this.storeSecret("geminiApiKey", geminiApiKey)
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
await this.storeSecret("deepSeekApiKey", message.apiConfiguration.deepSeekApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
if (this.cline) {
this.cline.api = buildApiHandler(message.apiConfiguration)
}
} }
await this.postStateToWebview() await this.postStateToWebview()
break break
@@ -566,7 +559,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
if (message?.values?.baseUrl && message?.values?.apiKey) { if (message?.values?.baseUrl && message?.values?.apiKey) {
const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey) const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey)
this.postMessageToWebview({ type: "openAiModels", openAiModels }) this.postMessageToWebview({ type: "openAiModels", openAiModels })
} }
break break
case "openImage": case "openImage":
openImage(message.text!) openImage(message.text!)
@@ -805,6 +798,113 @@ export class ClineProvider implements vscode.WebviewViewProvider {
} }
break break
} }
case "upsertApiConfiguration":
if (message.text && message.apiConfiguration) {
try {
await this.configManager.SaveConfig(message.text, message.apiConfiguration);
let listApiConfig = await this.configManager.ListConfig();
await Promise.all([
this.updateApiConfiguration(message.apiConfiguration),
this.updateGlobalState("currentApiConfigName", message.text),
this.updateGlobalState("listApiConfigMeta", listApiConfig),
])
this.postStateToWebview()
} catch (error) {
console.error("Error create new api configuration:", error)
vscode.window.showErrorMessage("Failed to create api configuration")
}
}
break
case "renameApiConfiguration":
if (message.values && message.apiConfiguration) {
try {
const { oldName, newName } = message.values
await this.configManager.SaveConfig(newName, message.apiConfiguration);
await this.configManager.DeleteConfig(oldName)
let listApiConfig = await this.configManager.ListConfig();
await Promise.all([
this.updateGlobalState("currentApiConfigName", newName),
this.updateGlobalState("listApiConfigMeta", listApiConfig),
])
this.postStateToWebview()
} catch (error) {
console.error("Error create new api configuration:", error)
vscode.window.showErrorMessage("Failed to create api configuration")
}
}
break
case "loadApiConfiguration":
if (message.text) {
try {
const apiConfig = await this.configManager.LoadConfig(message.text);
await Promise.all([
this.updateGlobalState("currentApiConfigName", message.text),
this.updateApiConfiguration(apiConfig),
])
await this.postStateToWebview()
} catch (error) {
console.error("Error load api configuration:", error)
vscode.window.showErrorMessage("Failed to load api configuration")
}
}
break
case "deleteApiConfiguration":
if (message.text) {
const answer = await vscode.window.showInformationMessage(
"Are you sure you want to delete this configuration profile?",
{ modal: true },
"Yes",
)
if (answer !== "Yes") {
break
}
try {
await this.configManager.DeleteConfig(message.text);
let listApiConfig = await this.configManager.ListConfig()
let currentApiConfigName = await this.getGlobalState("currentApiConfigName")
if (message.text === currentApiConfigName) {
await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name)
if (listApiConfig?.[0]?.name) {
const apiConfig = await this.configManager.LoadConfig(listApiConfig?.[0]?.name);
await Promise.all([
this.updateGlobalState("listApiConfigMeta", listApiConfig),
this.updateApiConfiguration(apiConfig),
])
await this.postStateToWebview()
}
}
} catch (error) {
console.error("Error delete api configuration:", error)
vscode.window.showErrorMessage("Failed to delete api configuration")
}
}
break
case "getListApiConfiguration":
try {
let listApiConfig = await this.configManager.ListConfig();
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
} catch (error) {
console.error("Error get list api configuration:", error)
vscode.window.showErrorMessage("Failed to get list api configuration")
}
break
} }
}, },
null, null,
@@ -812,6 +912,74 @@ export class ClineProvider implements vscode.WebviewViewProvider {
) )
} }
private async updateApiConfiguration(apiConfiguration: ApiConfiguration) {
const {
apiProvider,
apiModelId,
apiKey,
glamaModelId,
glamaModelInfo,
glamaApiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterUseMiddleOutTransform,
} = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
await this.storeSecret("apiKey", apiKey)
await this.updateGlobalState("glamaModelId", glamaModelId)
await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
await this.storeSecret("glamaApiKey", glamaApiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
await this.storeSecret("awsSessionToken", awsSessionToken)
await this.updateGlobalState("awsRegion", awsRegion)
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
await this.updateGlobalState("vertexProjectId", vertexProjectId)
await this.updateGlobalState("vertexRegion", vertexRegion)
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
await this.storeSecret("openAiApiKey", openAiApiKey)
await this.updateGlobalState("openAiModelId", openAiModelId)
await this.updateGlobalState("ollamaModelId", ollamaModelId)
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
await this.storeSecret("geminiApiKey", geminiApiKey)
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}
}
async updateCustomInstructions(instructions?: string) { async updateCustomInstructions(instructions?: string) {
// User may be clearing the field // User may be clearing the field
await this.updateGlobalState("customInstructions", instructions || undefined) await this.updateGlobalState("customInstructions", instructions || undefined)
@@ -1256,8 +1424,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled, mcpEnabled,
alwaysApproveResubmit, alwaysApproveResubmit,
requestDelaySeconds, requestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
} = await this.getState() } = await this.getState()
const allowedCommands = vscode.workspace const allowedCommands = vscode.workspace
.getConfiguration('roo-cline') .getConfiguration('roo-cline')
.get<string[]>('allowedCommands') || [] .get<string[]>('allowedCommands') || []
@@ -1290,6 +1460,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled: mcpEnabled ?? true, mcpEnabled: mcpEnabled ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false, alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: requestDelaySeconds ?? 5, requestDelaySeconds: requestDelaySeconds ?? 5,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
} }
} }
@@ -1397,6 +1569,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled, mcpEnabled,
alwaysApproveResubmit, alwaysApproveResubmit,
requestDelaySeconds, requestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
] = await Promise.all([ ] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>, this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1449,6 +1623,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>, this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>, this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>, this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
]) ])
let apiProvider: ApiProvider let apiProvider: ApiProvider
@@ -1545,6 +1721,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpEnabled: mcpEnabled ?? true, mcpEnabled: mcpEnabled ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false, alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: requestDelaySeconds ?? 5, requestDelaySeconds: requestDelaySeconds ?? 5,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
} }
} }

View File

@@ -1,6 +1,6 @@
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello' // type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello'
import { ApiConfiguration, ModelInfo } from "./api" import { ApiConfiguration, ApiProvider, ModelInfo } from "./api"
import { HistoryItem } from "./HistoryItem" import { HistoryItem } from "./HistoryItem"
import { McpServer } from "./mcp" import { McpServer } from "./mcp"
import { GitCommit } from "../utils/git" import { GitCommit } from "../utils/git"
@@ -23,6 +23,7 @@ export interface ExtensionMessage {
| "mcpServers" | "mcpServers"
| "enhancedPrompt" | "enhancedPrompt"
| "commitSearchResults" | "commitSearchResults"
| "listApiConfig"
text?: string text?: string
action?: action?:
| "chatButtonClicked" | "chatButtonClicked"
@@ -42,6 +43,12 @@ export interface ExtensionMessage {
openAiModels?: string[] openAiModels?: string[]
mcpServers?: McpServer[] mcpServers?: McpServer[]
commits?: GitCommit[] commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[]
}
export interface ApiConfigMeta {
name: string
apiProvider?: ApiProvider
} }
export interface ExtensionState { export interface ExtensionState {
@@ -50,6 +57,8 @@ export interface ExtensionState {
taskHistory: HistoryItem[] taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean shouldShowAnnouncement: boolean
apiConfiguration?: ApiConfiguration apiConfiguration?: ApiConfiguration
currentApiConfigName?: string
listApiConfigMeta?: ApiConfigMeta[]
customInstructions?: string customInstructions?: string
alwaysAllowReadOnly?: boolean alwaysAllowReadOnly?: boolean
alwaysAllowWrite?: boolean alwaysAllowWrite?: boolean

View File

@@ -5,6 +5,12 @@ export type AudioType = "notification" | "celebration" | "progress_loop"
export interface WebviewMessage { export interface WebviewMessage {
type: type:
| "apiConfiguration" | "apiConfiguration"
| "currentApiConfigName"
| "upsertApiConfiguration"
| "deleteApiConfiguration"
| "loadApiConfiguration"
| "renameApiConfiguration"
| "getListApiConfiguration"
| "customInstructions" | "customInstructions"
| "allowedCommands" | "allowedCommands"
| "alwaysAllowReadOnly" | "alwaysAllowReadOnly"
@@ -54,6 +60,7 @@ export interface WebviewMessage {
| "searchCommits" | "searchCommits"
| "alwaysApproveResubmit" | "alwaysApproveResubmit"
| "requestDelaySeconds" | "requestDelaySeconds"
| "setApiConfigPassword"
text?: string text?: string
disabled?: boolean disabled?: boolean
askResponse?: ClineAskResponse askResponse?: ClineAskResponse

View File

@@ -0,0 +1,19 @@
import { ApiConfiguration } from "../shared/api";
export function checkExistKey(config: ApiConfiguration | undefined) {
return 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;
}

View File

@@ -44,9 +44,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, },
ref, ref,
) => { ) => {
const { filePaths, apiConfiguration } = useExtensionState() const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<any[]>([]) 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 // Handle enhanced prompt response
useEffect(() => { useEffect(() => {
@@ -649,13 +661,62 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
style={{ style={{
position: "absolute", position: "absolute",
paddingTop: 4, paddingTop: 4,
bottom: 14, bottom: 32,
left: 22, left: 22,
right: 67, right: 67,
zIndex: 2, 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" }}> <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 }}> <span style={{ display: "flex", alignItems: "center", gap: 12 }}>
{apiConfiguration?.apiProvider === "openrouter" && ( {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 debounce from "debounce"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDeepCompareEffect, useEvent, useMount } from "react-use" import { useDeepCompareEffect, useEvent, useMount } from "react-use"
@@ -868,12 +868,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
<div style={{ padding: "0 20px", flexShrink: 0 }}> <div style={{ padding: "0 20px", flexShrink: 0 }}>
<h2>What can I do for you?</h2> <h2>What can I do for you?</h2>
<p> <p>
Thanks to{" "} Thanks to the latest breakthroughs in agentic coding capabilities,
<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 handle complex software development tasks step-by-step. With tools that let me create 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 & 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 (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

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

View File

@@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
const GlamaModelPicker: React.FC = () => { const GlamaModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, glamaModels } = useExtensionState() const { apiConfiguration, setApiConfiguration, glamaModels, onUpdateApiConfig } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId) const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1) const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -22,11 +22,14 @@ const GlamaModelPicker: React.FC = () => {
const handleModelChange = (newModelId: string) => { const handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it // could be setting invalid model id/undefined info but validation will catch it
setApiConfiguration({ const apiConfig = {
...apiConfiguration, ...apiConfiguration,
glamaModelId: newModelId, glamaModelId: newModelId,
glamaModelInfo: glamaModels[newModelId], glamaModelInfo: glamaModels[newModelId],
}) }
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setSearchTerm(newModelId) setSearchTerm(newModelId)
} }
@@ -34,6 +37,13 @@ const GlamaModelPicker: React.FC = () => {
return normalizeApiConfiguration(apiConfiguration) return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration]) }, [apiConfiguration])
useEffect(() => {
if (apiConfiguration?.glamaModelId && apiConfiguration?.glamaModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.glamaModelId)
}
}, [apiConfiguration, searchTerm])
useMount(() => { useMount(() => {
vscode.postMessage({ type: "refreshGlamaModels" }) vscode.postMessage({ type: "refreshGlamaModels" })
}) })

View File

@@ -8,7 +8,7 @@ import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView" import { highlight } from "../history/HistoryView"
const OpenAiModelPicker: React.FC = () => { const OpenAiModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, openAiModels } = useExtensionState() const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "") const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "")
const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1) const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -18,13 +18,22 @@ const OpenAiModelPicker: React.FC = () => {
const handleModelChange = (newModelId: string) => { const handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it // could be setting invalid model id/undefined info but validation will catch it
setApiConfiguration({ const apiConfig = {
...apiConfiguration, ...apiConfiguration,
openAiModelId: newModelId, openAiModelId: newModelId,
}) }
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setSearchTerm(newModelId) setSearchTerm(newModelId)
} }
useEffect(() => {
if (apiConfiguration?.openAiModelId && apiConfiguration?.openAiModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.openAiModelId)
}
}, [apiConfiguration, searchTerm])
useEffect(() => { useEffect(() => {
if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) { if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) {
return return

View File

@@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
const OpenRouterModelPicker: React.FC = () => { const OpenRouterModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState() const { apiConfiguration, setApiConfiguration, openRouterModels, onUpdateApiConfig } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId) const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1) const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -22,11 +22,14 @@ const OpenRouterModelPicker: React.FC = () => {
const handleModelChange = (newModelId: string) => { const handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it // could be setting invalid model id/undefined info but validation will catch it
setApiConfiguration({ const apiConfig = {
...apiConfiguration, ...apiConfiguration,
openRouterModelId: newModelId, openRouterModelId: newModelId,
openRouterModelInfo: openRouterModels[newModelId], openRouterModelInfo: openRouterModels[newModelId],
}) }
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setSearchTerm(newModelId) setSearchTerm(newModelId)
} }
@@ -34,6 +37,12 @@ const OpenRouterModelPicker: React.FC = () => {
return normalizeApiConfiguration(apiConfiguration) return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration]) }, [apiConfiguration])
useEffect(() => {
if (apiConfiguration?.openRouterModelId && apiConfiguration?.openRouterModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.openRouterModelId)
}
}, [apiConfiguration, searchTerm])
useMount(() => { useMount(() => {
vscode.postMessage({ type: "refreshOpenRouterModels" }) vscode.postMessage({ type: "refreshOpenRouterModels" })
}) })

View File

@@ -5,6 +5,7 @@ import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode" import { vscode } from "../../utils/vscode"
import ApiOptions from "./ApiOptions" import ApiOptions from "./ApiOptions"
import McpEnabledToggle from "../mcp/McpEnabledToggle" import McpEnabledToggle from "../mcp/McpEnabledToggle"
import ApiConfigManager from "./ApiConfigManager"
const IS_DEV = false // FIXME: use flags when packaging const IS_DEV = false // FIXME: use flags when packaging
@@ -55,10 +56,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysApproveResubmit, setAlwaysApproveResubmit,
requestDelaySeconds, requestDelaySeconds,
setRequestDelaySeconds, setRequestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
} = useExtensionState() } = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined) const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("") const [commandInput, setCommandInput] = useState("")
const handleSubmit = () => { const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration) const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
@@ -89,6 +93,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration
})
onDone() onDone()
} }
} }
@@ -152,8 +163,37 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}> style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
<div style={{ marginBottom: 5 }}> <div style={{ marginBottom: 5 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Provider Settings</h3> <h3 style={{ color: "var(--vscode-foreground)", margin: 0, marginBottom: 15 }}>Provider Settings</h3>
<ApiConfigManager
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
onSelectConfig={(configName: string) => {
vscode.postMessage({
type: "loadApiConfiguration",
text: configName
})
}}
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 <ApiOptions
showModelOptions={true}
apiErrorMessage={apiErrorMessage} apiErrorMessage={apiErrorMessage}
modelIdErrorMessage={modelIdErrorMessage} modelIdErrorMessage={modelIdErrorMessage}
/> />
@@ -405,10 +445,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 5 }}> <div style={{ marginBottom: 5 }}>
<VSCodeCheckbox <VSCodeCheckbox
checked={alwaysAllowMcp} checked={alwaysAllowMcp}
onChange={(e: any) => { onChange={(e: any) => setAlwaysAllowMcp(e.target.checked)}>
setAlwaysAllowMcp(e.target.checked)
vscode.postMessage({ type: "alwaysAllowMcp", bool: e.target.checked })
}}>
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span> <span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
</VSCodeCheckbox> </VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}> <p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>

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 // Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({ jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick, appearance }: any) => ( 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', () => { describe('SettingsView - Allowed Commands', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() 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 { useEffect, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext" import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration } from "../../utils/validate" 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" }}> <div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, padding: "0 20px" }}>
<h2>Hi, I'm Cline</h2> <h2>Hi, I'm Cline</h2>
<p> <p>
I can do all kinds of tasks thanks to the latest breakthroughs in{" "} I can do all kinds of tasks thanks to the latest breakthroughs in agentic coding capabilities
<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>{" "}
and access to tools that let me create & edit files, explore complex projects, use the browser, and 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 execute terminal commands (with your permission, of course). I can even use MCP to create new tools and
extend my own capabilities. extend my own capabilities.
</p> </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" }}> <div style={{ marginTop: "10px" }}>
<ApiOptions showModelOptions={false} /> <ApiOptions />
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}> <VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}>
Let's go! Let's go!
</VSCodeButton> </VSCodeButton>

View File

@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from "react" import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
import { useEvent } from "react-use" import { useEvent } from "react-use"
import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage" import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
import { import {
ApiConfiguration, ApiConfiguration,
ModelInfo, ModelInfo,
@@ -13,6 +13,9 @@ import { vscode } from "../utils/vscode"
import { convertTextMateToHljs } from "../utils/textMateToHljs" import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { findLastIndex } from "../../../src/shared/array" import { findLastIndex } from "../../../src/shared/array"
import { McpServer } from "../../../src/shared/mcp" import { McpServer } from "../../../src/shared/mcp"
import {
checkExistKey
} from "../../../src/shared/checkExistApiConfig"
export interface ExtensionStateContextType extends ExtensionState { export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean didHydrateState: boolean
@@ -50,6 +53,9 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysApproveResubmit: (value: boolean) => void setAlwaysApproveResubmit: (value: boolean) => void
requestDelaySeconds: number requestDelaySeconds: number
setRequestDelaySeconds: (value: number) => void setRequestDelaySeconds: (value: number) => void
setCurrentApiConfigName: (value: string) => void
setListApiConfigMeta: (value: ApiConfigMeta[]) => void
onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
} }
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined) export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -72,7 +78,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
terminalOutputLineLimit: 500, terminalOutputLineLimit: 500,
mcpEnabled: true, mcpEnabled: true,
alwaysApproveResubmit: false, alwaysApproveResubmit: false,
requestDelaySeconds: 5 requestDelaySeconds: 5,
currentApiConfigName: 'default',
listApiConfigMeta: [],
}) })
const [didHydrateState, setDidHydrateState] = useState(false) const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false) const [showWelcome, setShowWelcome] = useState(false)
@@ -88,27 +96,24 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [openAiModels, setOpenAiModels] = useState<string[]>([]) const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([]) 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 handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data const message: ExtensionMessage = event.data
switch (message.type) { switch (message.type) {
case "state": { case "state": {
setState(message.state!) setState(message.state!)
const config = message.state?.apiConfiguration const config = message.state?.apiConfiguration
const hasKey = config const hasKey = checkExistKey(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
setShowWelcome(!hasKey) setShowWelcome(!hasKey)
setDidHydrateState(true) setDidHydrateState(true)
break break
@@ -162,8 +167,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setMcpServers(message.mcpServers ?? []) setMcpServers(message.mcpServers ?? [])
break break
} }
case "listApiConfig": {
setListApiConfigMeta(message.listApiConfig ?? [])
break
}
} }
}, []) }, [setListApiConfigMeta])
useEvent("message", handleMessage) useEvent("message", handleMessage)
@@ -208,7 +217,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })), setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })),
setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })),
setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: 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> return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>