mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
merge: resolve conflicts after upstream merge
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,3 +11,6 @@ roo-cline-*.vsix
|
||||
|
||||
# Local prompts and rules
|
||||
/local-prompts
|
||||
|
||||
# Test environment
|
||||
.test_env
|
||||
@@ -1,5 +1,14 @@
|
||||
import { defineConfig } from "@vscode/test-cli"
|
||||
import { defineConfig } from '@vscode/test-cli';
|
||||
|
||||
export default defineConfig({
|
||||
files: "out/test/**/*.test.js",
|
||||
})
|
||||
files: 'src/test/extension.test.ts',
|
||||
workspaceFolder: '.',
|
||||
mocha: {
|
||||
timeout: 60000,
|
||||
ui: 'tdd'
|
||||
},
|
||||
launchArgs: [
|
||||
'--enable-proposed-api=RooVeterinaryInc.roo-cline',
|
||||
'--disable-extensions'
|
||||
]
|
||||
});
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
||||
# Roo Cline Changelog
|
||||
|
||||
## [2.2.45]
|
||||
|
||||
- Save different API configurations to quickly switch between providers and settings (thanks @samhvw8!)
|
||||
|
||||
## [2.2.44]
|
||||
|
||||
- Automatically retry failed API requests with a configurable delay (thanks @RaySinner!)
|
||||
|
||||
## [2.2.43]
|
||||
|
||||
- Allow deleting single messages or all subsequent messages
|
||||
|
||||
## [2.2.42]
|
||||
|
||||
- Add a Git section to the context mentions
|
||||
|
||||
@@ -7,6 +7,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
|
||||
- Drag and drop images into chats
|
||||
- Delete messages from chats
|
||||
- @-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)
|
||||
- Sound effects for feedback
|
||||
- Option to use browsers of different sizes and adjust screenshot quality
|
||||
@@ -23,6 +24,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
|
||||
- Per-tool MCP auto-approval
|
||||
- Enable/disable individual MCP servers
|
||||
- Enable/disable the MCP feature overall
|
||||
- Automatically retry failed API requests with a configurable delay
|
||||
- Configurable delay after auto-writes to allow diagnostics to detect potential problems
|
||||
- Control the number of terminal output lines to pass to the model when executing commands
|
||||
- Runs alongside the original Cline
|
||||
|
||||
@@ -32,6 +32,9 @@ module.exports = {
|
||||
modulePathIgnorePatterns: [
|
||||
'.vscode-test'
|
||||
],
|
||||
reporters: [
|
||||
["jest-simple-dot-reporter", {}]
|
||||
],
|
||||
setupFiles: [],
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
|
||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "roo-cline",
|
||||
"version": "2.2.42",
|
||||
"version": "2.2.45",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "roo-cline",
|
||||
"version": "2.2.42",
|
||||
"version": "2.2.45",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
@@ -56,10 +56,12 @@
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
"@vscode/test-cli": "^0.0.9",
|
||||
"@vscode/test-electron": "^2.4.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^8.57.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-simple-dot-reporter": "^1.0.5",
|
||||
"lint-staged": "^15.2.11",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"ts-jest": "^29.2.5",
|
||||
@@ -8000,6 +8002,19 @@
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/duck": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
|
||||
@@ -10879,6 +10894,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-simple-dot-reporter": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/jest-simple-dot-reporter/-/jest-simple-dot-reporter-1.0.5.tgz",
|
||||
"integrity": "sha512-cZLFG/C7k0+WYoIGGuGXKm0vmJiXlWG/m3uCZ4RaMPYxt8lxjdXMLHYkxXaQ7gVWaSPe7uAPCEUcRxthC5xskg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/jest-snapshot": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"displayName": "Roo Cline",
|
||||
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
|
||||
"publisher": "RooVeterinaryInc",
|
||||
"version": "2.2.42",
|
||||
"version": "2.2.45",
|
||||
"icon": "assets/icons/rocket.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
@@ -176,6 +176,7 @@
|
||||
"start:webview": "cd webview-ui && npm run start",
|
||||
"test": "jest && npm run test:webview",
|
||||
"test:webview": "cd webview-ui && npm run test",
|
||||
"test:extension": "vscode-test",
|
||||
"prepare": "husky",
|
||||
"publish:marketplace": "vsce publish",
|
||||
"publish": "npm run build && changeset publish && npm install --package-lock-only",
|
||||
@@ -198,10 +199,12 @@
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
"@vscode/test-cli": "^0.0.9",
|
||||
"@vscode/test-electron": "^2.4.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"esbuild": "^0.24.0",
|
||||
"eslint": "^8.57.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-simple-dot-reporter": "^1.0.5",
|
||||
"lint-staged": "^15.2.11",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"ts-jest": "^29.2.5",
|
||||
@@ -209,9 +212,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.706.0",
|
||||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.4.1",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.706.0",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
|
||||
@@ -24,6 +24,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
switch (this.getModel().id) {
|
||||
case "o1":
|
||||
case "o1-preview":
|
||||
case "o1-mini": {
|
||||
// o1 doesnt support streaming, non-1 temp, or system prompt
|
||||
|
||||
@@ -766,7 +766,7 @@ export class Cline {
|
||||
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
|
||||
let mcpHub: McpHub | undefined
|
||||
|
||||
const { mcpEnabled } = await this.providerRef.deref()?.getState() ?? {}
|
||||
const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds } = await this.providerRef.deref()?.getState() ?? {}
|
||||
|
||||
if (mcpEnabled ?? true) {
|
||||
mcpHub = this.providerRef.deref()?.mcpHub
|
||||
@@ -799,8 +799,30 @@ export class Cline {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Anthropic.MessageParam by spreading only the API-required properties
|
||||
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => ({ role, content }))
|
||||
// Clean conversation history by:
|
||||
// 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 iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
@@ -810,6 +832,20 @@ export class Cline {
|
||||
yield firstChunk.value
|
||||
} catch (error) {
|
||||
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
|
||||
if (alwaysApproveResubmit) {
|
||||
const requestDelay = requestDelaySeconds || 5
|
||||
// Automatically retry with delay
|
||||
await this.say(
|
||||
"error",
|
||||
`${error.message ?? "Unknown error"} ↺ Retrying in ${requestDelay} seconds...`,
|
||||
)
|
||||
await this.say("api_req_retry_delayed")
|
||||
await delay(requestDelay * 1000)
|
||||
await this.say("api_req_retried")
|
||||
// delegate generator output from the recursive call
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
} else {
|
||||
const { response } = await this.ask(
|
||||
"api_req_failed",
|
||||
error.message ?? JSON.stringify(serializeError(error), null, 2),
|
||||
@@ -823,6 +859,7 @@ export class Cline {
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// no error, so we can continue to yield all remaining chunks
|
||||
// (needs to be placed outside of try/catch since it we want caller to handle errors not with api_req_failed as that is reserved for first chunk failures only)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Cline } from '../Cline';
|
||||
import { ClineProvider } from '../webview/ClineProvider';
|
||||
import { ApiConfiguration } from '../../shared/api';
|
||||
import { ApiConfiguration, ModelInfo } from '../../shared/api';
|
||||
import { ApiStreamChunk } from '../../api/transform/stream';
|
||||
import { Anthropic } from '@anthropic-ai/sdk';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// Mock all MCP-related modules
|
||||
@@ -498,6 +499,133 @@ describe('Cline', () => {
|
||||
expect(passedMessage).not.toHaveProperty('ts');
|
||||
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]'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
164
src/core/config/ConfigManager.ts
Normal file
164
src/core/config/ConfigManager.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
384
src/core/config/__tests__/ConfigManager.test.ts
Normal file
384
src/core/config/__tests__/ConfigManager.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,9 +12,9 @@ import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
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 { ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
@@ -23,8 +23,10 @@ import { openMention } from "../mentions"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
|
||||
import { checkExistKey } from "../../shared/checkExistApiConfig"
|
||||
import { enhancePrompt } from "../../utils/enhance-prompt"
|
||||
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
|
||||
@@ -84,6 +86,10 @@ type GlobalStateKey =
|
||||
| "writeDelayMs"
|
||||
| "terminalOutputLineLimit"
|
||||
| "mcpEnabled"
|
||||
| "alwaysApproveResubmit"
|
||||
| "requestDelaySeconds"
|
||||
| "currentApiConfigName"
|
||||
| "listApiConfigMeta"
|
||||
| "vsCodeLmModelSelector"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
@@ -104,6 +110,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
private workspaceTracker?: WorkspaceTracker
|
||||
mcpHub?: McpHub
|
||||
private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement
|
||||
configManager: ConfigManager
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
@@ -113,6 +120,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
ClineProvider.activeInstances.add(this)
|
||||
this.workspaceTracker = new WorkspaceTracker(this)
|
||||
this.mcpHub = new McpHub(this)
|
||||
this.configManager = new ConfigManager(this.context)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -411,6 +419,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
|
||||
case "newTask":
|
||||
// Code that should run in response to the hello message command
|
||||
@@ -491,6 +548,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
if (this.cline) {
|
||||
this.cline.api = buildApiHandler(message.apiConfiguration)
|
||||
}
|
||||
await this.updateApiConfiguration(message.apiConfiguration)
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
@@ -684,6 +742,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.updateGlobalState("fuzzyMatchThreshold", message.value)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "alwaysApproveResubmit":
|
||||
await this.updateGlobalState("alwaysApproveResubmit", message.bool ?? false)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "requestDelaySeconds":
|
||||
await this.updateGlobalState("requestDelaySeconds", message.value ?? 5)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "preferredLanguage":
|
||||
await this.updateGlobalState("preferredLanguage", message.text)
|
||||
await this.postStateToWebview()
|
||||
@@ -698,21 +764,65 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
break
|
||||
case "deleteMessage": {
|
||||
const answer = await vscode.window.showInformationMessage(
|
||||
"Are you sure you want to delete this message and all subsequent messages?",
|
||||
"What would you like to delete?",
|
||||
{ modal: true },
|
||||
"Yes",
|
||||
"No"
|
||||
"Just this message",
|
||||
"This and all subsequent messages",
|
||||
)
|
||||
if (answer === "Yes" && this.cline && typeof message.value === 'number' && message.value) {
|
||||
if ((answer === "Just this message" || answer === "This and all subsequent messages") &&
|
||||
this.cline && typeof message.value === 'number' && message.value) {
|
||||
const timeCutoff = message.value - 1000; // 1 second buffer before the message to delete
|
||||
const messageIndex = this.cline.clineMessages.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
|
||||
const apiConversationHistoryIndex = this.cline.apiConversationHistory.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
const { historyItem } = await this.getTaskWithId(this.cline.taskId)
|
||||
|
||||
if (answer === "Just this message") {
|
||||
// Find the next user message first
|
||||
const nextUserMessage = this.cline.clineMessages
|
||||
.slice(messageIndex + 1)
|
||||
.find(msg => msg.type === "say" && msg.say === "user_feedback")
|
||||
|
||||
// Handle UI messages
|
||||
if (nextUserMessage) {
|
||||
// Find absolute index of next user message
|
||||
const nextUserMessageIndex = this.cline.clineMessages.findIndex(msg => msg === nextUserMessage)
|
||||
// Keep messages before current message and after next user message
|
||||
await this.cline.overwriteClineMessages([
|
||||
...this.cline.clineMessages.slice(0, messageIndex),
|
||||
...this.cline.clineMessages.slice(nextUserMessageIndex)
|
||||
])
|
||||
} else {
|
||||
// If no next user message, keep only messages before current message
|
||||
await this.cline.overwriteClineMessages(
|
||||
this.cline.clineMessages.slice(0, messageIndex)
|
||||
)
|
||||
}
|
||||
|
||||
// Handle API messages
|
||||
if (apiConversationHistoryIndex !== -1) {
|
||||
if (nextUserMessage && nextUserMessage.ts) {
|
||||
// Keep messages before current API message and after next user message
|
||||
await this.cline.overwriteApiConversationHistory([
|
||||
...this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex),
|
||||
...this.cline.apiConversationHistory.filter(msg => msg.ts && msg.ts >= nextUserMessage.ts)
|
||||
])
|
||||
} else {
|
||||
// If no next user message, keep only messages before current API message
|
||||
await this.cline.overwriteApiConversationHistory(
|
||||
this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (answer === "This and all subsequent messages") {
|
||||
// Delete this message and all that follow
|
||||
await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex))
|
||||
if (apiConversationHistoryIndex !== -1) {
|
||||
await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex))
|
||||
}
|
||||
}
|
||||
|
||||
await this.initClineWithHistoryItem(historyItem)
|
||||
}
|
||||
}
|
||||
@@ -760,6 +870,113 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
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,
|
||||
@@ -767,6 +984,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) {
|
||||
// User may be clearing the field
|
||||
await this.updateGlobalState("customInstructions", instructions || undefined)
|
||||
@@ -1220,9 +1505,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
terminalOutputLineLimit,
|
||||
fuzzyMatchThreshold,
|
||||
mcpEnabled,
|
||||
alwaysApproveResubmit,
|
||||
requestDelaySeconds,
|
||||
currentApiConfigName,
|
||||
listApiConfigMeta,
|
||||
} = await this.getState()
|
||||
|
||||
|
||||
const allowedCommands = vscode.workspace
|
||||
.getConfiguration('roo-cline')
|
||||
.get<string[]>('allowedCommands') || []
|
||||
@@ -1253,6 +1541,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
|
||||
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
|
||||
mcpEnabled: mcpEnabled ?? true,
|
||||
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
|
||||
requestDelaySeconds: requestDelaySeconds ?? 5,
|
||||
currentApiConfigName: currentApiConfigName ?? "default",
|
||||
listApiConfigMeta: listApiConfigMeta ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1358,7 +1650,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
screenshotQuality,
|
||||
terminalOutputLineLimit,
|
||||
mcpEnabled,
|
||||
vsCodeLmModelSelector,
|
||||
alwaysApproveResubmit,
|
||||
requestDelaySeconds,
|
||||
currentApiConfigName,
|
||||
listApiConfigMeta,
|
||||
vsCodeLmModelSelector
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("apiModelId") as Promise<string | undefined>,
|
||||
@@ -1409,7 +1705,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.getGlobalState("screenshotQuality") as Promise<number | undefined>,
|
||||
this.getGlobalState("terminalOutputLineLimit") as Promise<number | undefined>,
|
||||
this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
|
||||
this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
|
||||
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
|
||||
this.getGlobalState("vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
|
||||
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
@@ -1505,6 +1806,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
return langMap[vscodeLang.split('-')[0]] ?? 'English';
|
||||
})(),
|
||||
mcpEnabled: mcpEnabled ?? true,
|
||||
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
|
||||
requestDelaySeconds: requestDelaySeconds ?? 5,
|
||||
currentApiConfigName: currentApiConfigName ?? "default",
|
||||
listApiConfigMeta: listApiConfigMeta ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ jest.mock('vscode', () => ({
|
||||
joinPath: jest.fn(),
|
||||
file: jest.fn()
|
||||
},
|
||||
window: {
|
||||
showInformationMessage: jest.fn(),
|
||||
},
|
||||
workspace: {
|
||||
getConfiguration: jest.fn().mockReturnValue({
|
||||
get: jest.fn().mockReturnValue([]),
|
||||
@@ -123,7 +126,11 @@ jest.mock('../../Cline', () => {
|
||||
Cline: jest.fn().mockImplementation(() => ({
|
||||
abortTask: jest.fn(),
|
||||
handleWebviewAskResponse: jest.fn(),
|
||||
clineMessages: []
|
||||
clineMessages: [],
|
||||
apiConversationHistory: [],
|
||||
overwriteClineMessages: jest.fn(),
|
||||
overwriteApiConversationHistory: jest.fn(),
|
||||
taskId: 'test-task-id'
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -256,6 +263,7 @@ describe('ClineProvider', () => {
|
||||
browserViewportSize: "900x600",
|
||||
fuzzyMatchThreshold: 1.0,
|
||||
mcpEnabled: true,
|
||||
requestDelaySeconds: 5
|
||||
}
|
||||
|
||||
const message: ExtensionMessage = {
|
||||
@@ -375,9 +383,190 @@ describe('ClineProvider', () => {
|
||||
expect(mockPostMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('requestDelaySeconds defaults to 5 seconds', async () => {
|
||||
// Mock globalState.get to return undefined for requestDelaySeconds
|
||||
(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
|
||||
if (key === 'requestDelaySeconds') {
|
||||
return undefined
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const state = await provider.getState()
|
||||
expect(state.requestDelaySeconds).toBe(5)
|
||||
})
|
||||
|
||||
test('alwaysApproveResubmit defaults to false', async () => {
|
||||
// Mock globalState.get to return undefined for alwaysApproveResubmit
|
||||
(mockContext.globalState.get as jest.Mock).mockReturnValue(undefined)
|
||||
|
||||
const state = await provider.getState()
|
||||
expect(state.alwaysApproveResubmit).toBe(false)
|
||||
})
|
||||
|
||||
test('handles request delay settings messages', async () => {
|
||||
provider.resolveWebviewView(mockWebviewView)
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
|
||||
// Test alwaysApproveResubmit
|
||||
await messageHandler({ type: 'alwaysApproveResubmit', bool: true })
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith('alwaysApproveResubmit', true)
|
||||
expect(mockPostMessage).toHaveBeenCalled()
|
||||
|
||||
// Test requestDelaySeconds
|
||||
await messageHandler({ type: 'requestDelaySeconds', value: 10 })
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith('requestDelaySeconds', 10)
|
||||
expect(mockPostMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('file content includes line numbers', async () => {
|
||||
const { extractTextFromFile } = require('../../../integrations/misc/extract-text')
|
||||
const result = await extractTextFromFile('test.js')
|
||||
expect(result).toBe('1 | const x = 1;\n2 | const y = 2;\n3 | const z = 3;')
|
||||
})
|
||||
|
||||
describe('deleteMessage', () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.showInformationMessage
|
||||
;(vscode.window.showInformationMessage as jest.Mock) = jest.fn()
|
||||
provider.resolveWebviewView(mockWebviewView)
|
||||
})
|
||||
|
||||
test('handles "Just this message" deletion correctly', async () => {
|
||||
// Mock user selecting "Just this message"
|
||||
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('Just this message')
|
||||
|
||||
// Setup mock messages
|
||||
const mockMessages = [
|
||||
{ ts: 1000, type: 'say', say: 'user_feedback' }, // User message 1
|
||||
{ ts: 2000, type: 'say', say: 'tool' }, // Tool message
|
||||
{ ts: 3000, type: 'say', say: 'text', value: 4000 }, // Message to delete
|
||||
{ ts: 4000, type: 'say', say: 'browser_action' }, // Response to delete
|
||||
{ ts: 5000, type: 'say', say: 'user_feedback' }, // Next user message
|
||||
{ ts: 6000, type: 'say', say: 'user_feedback' } // Final message
|
||||
]
|
||||
|
||||
const mockApiHistory = [
|
||||
{ ts: 1000 },
|
||||
{ ts: 2000 },
|
||||
{ ts: 3000 },
|
||||
{ ts: 4000 },
|
||||
{ ts: 5000 },
|
||||
{ ts: 6000 }
|
||||
]
|
||||
|
||||
// Setup Cline instance with mock data
|
||||
const mockCline = {
|
||||
clineMessages: mockMessages,
|
||||
apiConversationHistory: mockApiHistory,
|
||||
overwriteClineMessages: jest.fn(),
|
||||
overwriteApiConversationHistory: jest.fn(),
|
||||
taskId: 'test-task-id',
|
||||
abortTask: jest.fn(),
|
||||
handleWebviewAskResponse: jest.fn()
|
||||
}
|
||||
// @ts-ignore - accessing private property for testing
|
||||
provider.cline = mockCline
|
||||
|
||||
// Mock getTaskWithId
|
||||
;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({
|
||||
historyItem: { id: 'test-task-id' }
|
||||
})
|
||||
|
||||
// Trigger message deletion
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
await messageHandler({ type: 'deleteMessage', value: 4000 })
|
||||
|
||||
// Verify correct messages were kept
|
||||
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
|
||||
mockMessages[0],
|
||||
mockMessages[1],
|
||||
mockMessages[4],
|
||||
mockMessages[5]
|
||||
])
|
||||
|
||||
// Verify correct API messages were kept
|
||||
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
|
||||
mockApiHistory[0],
|
||||
mockApiHistory[1],
|
||||
mockApiHistory[4],
|
||||
mockApiHistory[5]
|
||||
])
|
||||
})
|
||||
|
||||
test('handles "This and all subsequent messages" deletion correctly', async () => {
|
||||
// Mock user selecting "This and all subsequent messages"
|
||||
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('This and all subsequent messages')
|
||||
|
||||
// Setup mock messages
|
||||
const mockMessages = [
|
||||
{ ts: 1000, type: 'say', say: 'user_feedback' },
|
||||
{ ts: 2000, type: 'say', say: 'text', value: 3000 }, // Message to delete
|
||||
{ ts: 3000, type: 'say', say: 'user_feedback' },
|
||||
{ ts: 4000, type: 'say', say: 'user_feedback' }
|
||||
]
|
||||
|
||||
const mockApiHistory = [
|
||||
{ ts: 1000 },
|
||||
{ ts: 2000 },
|
||||
{ ts: 3000 },
|
||||
{ ts: 4000 }
|
||||
]
|
||||
|
||||
// Setup Cline instance with mock data
|
||||
const mockCline = {
|
||||
clineMessages: mockMessages,
|
||||
apiConversationHistory: mockApiHistory,
|
||||
overwriteClineMessages: jest.fn(),
|
||||
overwriteApiConversationHistory: jest.fn(),
|
||||
taskId: 'test-task-id',
|
||||
abortTask: jest.fn(),
|
||||
handleWebviewAskResponse: jest.fn()
|
||||
}
|
||||
// @ts-ignore - accessing private property for testing
|
||||
provider.cline = mockCline
|
||||
|
||||
// Mock getTaskWithId
|
||||
;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({
|
||||
historyItem: { id: 'test-task-id' }
|
||||
})
|
||||
|
||||
// Trigger message deletion
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
await messageHandler({ type: 'deleteMessage', value: 3000 })
|
||||
|
||||
// Verify only messages before the deleted message were kept
|
||||
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
|
||||
mockMessages[0]
|
||||
])
|
||||
|
||||
// Verify only API messages before the deleted message were kept
|
||||
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
|
||||
mockApiHistory[0]
|
||||
])
|
||||
})
|
||||
|
||||
test('handles Cancel correctly', async () => {
|
||||
// Mock user selecting "Cancel"
|
||||
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('Cancel')
|
||||
|
||||
const mockCline = {
|
||||
clineMessages: [{ ts: 1000 }, { ts: 2000 }],
|
||||
apiConversationHistory: [{ ts: 1000 }, { ts: 2000 }],
|
||||
overwriteClineMessages: jest.fn(),
|
||||
overwriteApiConversationHistory: jest.fn(),
|
||||
taskId: 'test-task-id'
|
||||
}
|
||||
// @ts-ignore - accessing private property for testing
|
||||
provider.cline = mockCline
|
||||
|
||||
// Trigger message deletion
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
await messageHandler({ type: 'deleteMessage', value: 2000 })
|
||||
|
||||
// Verify no messages were deleted
|
||||
expect(mockCline.overwriteClineMessages).not.toHaveBeenCalled()
|
||||
expect(mockCline.overwriteApiConversationHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
5
src/exports/cline.d.ts
vendored
5
src/exports/cline.d.ts
vendored
@@ -34,4 +34,9 @@ export interface ClineAPI {
|
||||
* Simulates pressing the secondary button in the chat interface.
|
||||
*/
|
||||
pressSecondaryButton(): Promise<void>
|
||||
|
||||
/**
|
||||
* The sidebar provider instance.
|
||||
*/
|
||||
sidebarProvider: ClineSidebarProvider
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
|
||||
invoke: "secondaryButtonClick",
|
||||
})
|
||||
},
|
||||
|
||||
sidebarProvider: sidebarProvider,
|
||||
}
|
||||
|
||||
return api
|
||||
|
||||
@@ -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'
|
||||
|
||||
import { ApiConfiguration, ModelInfo } from "./api"
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "./api"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer } from "./mcp"
|
||||
import { GitCommit } from "../utils/git"
|
||||
@@ -14,9 +14,6 @@ export interface ExtensionMessage {
|
||||
| "selectedImages"
|
||||
| "ollamaModels"
|
||||
| "lmStudioModels"
|
||||
| "vsCodeLmModels"
|
||||
| "vsCodeLmApiAvailable"
|
||||
| "requestVsCodeLmModels"
|
||||
| "theme"
|
||||
| "workspaceUpdated"
|
||||
| "invoke"
|
||||
@@ -27,7 +24,10 @@ export interface ExtensionMessage {
|
||||
| "mcpServers"
|
||||
| "enhancedPrompt"
|
||||
| "commitSearchResults"
|
||||
|
||||
| "listApiConfig"
|
||||
| "vsCodeLmModels"
|
||||
| "vsCodeLmApiAvailable"
|
||||
| "requestVsCodeLmModels"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
@@ -48,6 +48,12 @@ export interface ExtensionMessage {
|
||||
openAiModels?: string[]
|
||||
mcpServers?: McpServer[]
|
||||
commits?: GitCommit[]
|
||||
listApiConfig?: ApiConfigMeta[]
|
||||
}
|
||||
|
||||
export interface ApiConfigMeta {
|
||||
name: string
|
||||
apiProvider?: ApiProvider
|
||||
}
|
||||
|
||||
export interface ExtensionState {
|
||||
@@ -56,12 +62,16 @@ export interface ExtensionState {
|
||||
taskHistory: HistoryItem[]
|
||||
shouldShowAnnouncement: boolean
|
||||
apiConfiguration?: ApiConfiguration
|
||||
currentApiConfigName?: string
|
||||
listApiConfigMeta?: ApiConfigMeta[]
|
||||
customInstructions?: string
|
||||
alwaysAllowReadOnly?: boolean
|
||||
alwaysAllowWrite?: boolean
|
||||
alwaysAllowExecute?: boolean
|
||||
alwaysAllowBrowser?: boolean
|
||||
alwaysAllowMcp?: boolean
|
||||
alwaysApproveResubmit?: boolean
|
||||
requestDelaySeconds: number
|
||||
uriScheme?: string
|
||||
allowedCommands?: string[]
|
||||
soundEnabled?: boolean
|
||||
@@ -109,6 +119,7 @@ export type ClineSay =
|
||||
| "user_feedback"
|
||||
| "user_feedback_diff"
|
||||
| "api_req_retried"
|
||||
| "api_req_retry_delayed"
|
||||
| "command_output"
|
||||
| "tool"
|
||||
| "shell_integration_warning"
|
||||
|
||||
@@ -5,6 +5,12 @@ export type AudioType = "notification" | "celebration" | "progress_loop"
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "apiConfiguration"
|
||||
| "currentApiConfigName"
|
||||
| "upsertApiConfiguration"
|
||||
| "deleteApiConfiguration"
|
||||
| "loadApiConfiguration"
|
||||
| "renameApiConfiguration"
|
||||
| "getListApiConfiguration"
|
||||
| "customInstructions"
|
||||
| "allowedCommands"
|
||||
| "alwaysAllowReadOnly"
|
||||
@@ -23,11 +29,11 @@ export interface WebviewMessage {
|
||||
| "resetState"
|
||||
| "requestOllamaModels"
|
||||
| "requestLmStudioModels"
|
||||
| "requestVsCodeLmModels"
|
||||
| "openImage"
|
||||
| "openFile"
|
||||
| "openMention"
|
||||
| "cancelTask"
|
||||
| "refreshGlamaModels"
|
||||
| "refreshOpenRouterModels"
|
||||
| "refreshOpenAiModels"
|
||||
| "alwaysAllowBrowser"
|
||||
@@ -51,9 +57,12 @@ export interface WebviewMessage {
|
||||
| "deleteMessage"
|
||||
| "terminalOutputLineLimit"
|
||||
| "mcpEnabled"
|
||||
| "refreshGlamaModels"
|
||||
| "searchCommits"
|
||||
|
||||
| "refreshGlamaModels"
|
||||
| "alwaysApproveResubmit"
|
||||
| "requestDelaySeconds"
|
||||
| "setApiConfigPassword"
|
||||
| "requestVsCodeLmModels"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
askResponse?: ClineAskResponse
|
||||
|
||||
@@ -486,6 +486,14 @@ export type OpenAiNativeModelId = keyof typeof openAiNativeModels
|
||||
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-4o"
|
||||
export const openAiNativeModels = {
|
||||
// don't support tool use yet
|
||||
"o1": {
|
||||
maxTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 15,
|
||||
outputPrice: 60,
|
||||
},
|
||||
"o1-preview": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 128_000,
|
||||
|
||||
19
src/shared/checkExistApiConfig.ts
Normal file
19
src/shared/checkExistApiConfig.ts
Normal 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;
|
||||
}
|
||||
@@ -1,15 +1,345 @@
|
||||
import * as assert from "assert"
|
||||
const assert = require('assert');
|
||||
const vscode = require('vscode');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
// You can import and use all API from the 'vscode' module
|
||||
// as well as import your extension to test it
|
||||
import * as vscode from "vscode"
|
||||
// import * as myExtension from '../../extension';
|
||||
// Load test environment variables
|
||||
const testEnvPath = path.join(__dirname, '.test_env');
|
||||
dotenv.config({ path: testEnvPath });
|
||||
|
||||
suite("Extension Test Suite", () => {
|
||||
vscode.window.showInformationMessage("Start all tests.")
|
||||
suite('Roo Cline Extension Test Suite', () => {
|
||||
vscode.window.showInformationMessage('Starting Roo Cline extension tests.');
|
||||
|
||||
test("Sample test", () => {
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(5))
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(0))
|
||||
})
|
||||
})
|
||||
test('Extension should be present', () => {
|
||||
const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline');
|
||||
assert.notStrictEqual(extension, undefined);
|
||||
});
|
||||
|
||||
test('Extension should activate', async () => {
|
||||
const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline');
|
||||
if (!extension) {
|
||||
assert.fail('Extension not found');
|
||||
}
|
||||
await extension.activate();
|
||||
assert.strictEqual(extension.isActive, true);
|
||||
});
|
||||
|
||||
test('OpenRouter API key and models should be configured correctly', function(done) {
|
||||
// @ts-ignore
|
||||
this.timeout(60000); // Increase timeout to 60s for network requests
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Get extension instance
|
||||
const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline');
|
||||
if (!extension) {
|
||||
done(new Error('Extension not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify API key is set and valid
|
||||
const apiKey = process.env.OPEN_ROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
done(new Error('OPEN_ROUTER_API_KEY environment variable is not set'));
|
||||
return;
|
||||
}
|
||||
if (!apiKey.startsWith('sk-or-v1-')) {
|
||||
done(new Error('OpenRouter API key should have correct format'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate extension and get provider
|
||||
const api = await extension.activate();
|
||||
if (!api) {
|
||||
done(new Error('Extension API not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the provider from the extension's exports
|
||||
const provider = api.sidebarProvider;
|
||||
if (!provider) {
|
||||
done(new Error('Provider not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the API configuration
|
||||
await provider.updateGlobalState('apiProvider', 'openrouter');
|
||||
await provider.storeSecret('openRouterApiKey', apiKey);
|
||||
|
||||
// Set up timeout to fail test if models don't load
|
||||
const timeout = setTimeout(() => {
|
||||
done(new Error('Timeout waiting for models to load'));
|
||||
}, 30000);
|
||||
|
||||
// Wait for models to be loaded
|
||||
const checkModels = setInterval(async () => {
|
||||
try {
|
||||
const models = await provider.readOpenRouterModels();
|
||||
if (!models) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(checkModels);
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Verify expected Claude models are available
|
||||
const expectedModels = [
|
||||
'anthropic/claude-3.5-sonnet:beta',
|
||||
'anthropic/claude-3-sonnet:beta',
|
||||
'anthropic/claude-3.5-sonnet',
|
||||
'anthropic/claude-3.5-sonnet-20240620',
|
||||
'anthropic/claude-3.5-sonnet-20240620:beta',
|
||||
'anthropic/claude-3.5-haiku:beta'
|
||||
];
|
||||
|
||||
for (const modelId of expectedModels) {
|
||||
assert.strictEqual(
|
||||
modelId in models,
|
||||
true,
|
||||
`Model ${modelId} should be available`
|
||||
);
|
||||
}
|
||||
|
||||
done();
|
||||
} catch (error) {
|
||||
clearInterval(checkModels);
|
||||
clearTimeout(timeout);
|
||||
done(error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Trigger model loading
|
||||
await provider.refreshOpenRouterModels();
|
||||
|
||||
} catch (error) {
|
||||
done(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
test('Commands should be registered', async () => {
|
||||
const commands = await vscode.commands.getCommands(true);
|
||||
|
||||
// Test core commands are registered
|
||||
const expectedCommands = [
|
||||
'roo-cline.plusButtonClicked',
|
||||
'roo-cline.mcpButtonClicked',
|
||||
'roo-cline.historyButtonClicked',
|
||||
'roo-cline.popoutButtonClicked',
|
||||
'roo-cline.settingsButtonClicked',
|
||||
'roo-cline.openInNewTab'
|
||||
];
|
||||
|
||||
for (const cmd of expectedCommands) {
|
||||
assert.strictEqual(
|
||||
commands.includes(cmd),
|
||||
true,
|
||||
`Command ${cmd} should be registered`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Views should be registered', () => {
|
||||
const view = vscode.window.createWebviewPanel(
|
||||
'roo-cline.SidebarProvider',
|
||||
'Roo Cline',
|
||||
vscode.ViewColumn.One,
|
||||
{}
|
||||
);
|
||||
assert.notStrictEqual(view, undefined);
|
||||
view.dispose();
|
||||
});
|
||||
|
||||
test('Should handle prompt and response correctly', async function() {
|
||||
// @ts-ignore
|
||||
this.timeout(60000); // Increase timeout for API request
|
||||
|
||||
const timeout = 30000;
|
||||
const interval = 1000;
|
||||
|
||||
// Get extension instance
|
||||
const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline');
|
||||
if (!extension) {
|
||||
assert.fail('Extension not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate extension and get API
|
||||
const api = await extension.activate();
|
||||
if (!api) {
|
||||
assert.fail('Extension API not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get provider
|
||||
const provider = api.sidebarProvider;
|
||||
if (!provider) {
|
||||
assert.fail('Provider not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up API configuration
|
||||
await provider.updateGlobalState('apiProvider', 'openrouter');
|
||||
await provider.updateGlobalState('openRouterModelId', 'anthropic/claude-3.5-sonnet');
|
||||
const apiKey = process.env.OPEN_ROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
assert.fail('OPEN_ROUTER_API_KEY environment variable is not set');
|
||||
return;
|
||||
}
|
||||
await provider.storeSecret('openRouterApiKey', apiKey);
|
||||
|
||||
// Create webview panel with development options
|
||||
const extensionUri = extension.extensionUri;
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
'roo-cline.SidebarProvider',
|
||||
'Roo Cline',
|
||||
vscode.ViewColumn.One,
|
||||
{
|
||||
enableScripts: true,
|
||||
enableCommandUris: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [extensionUri]
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
// Initialize webview with development context
|
||||
panel.webview.options = {
|
||||
enableScripts: true,
|
||||
enableCommandUris: true,
|
||||
localResourceRoots: [extensionUri]
|
||||
};
|
||||
|
||||
// Initialize provider with panel
|
||||
provider.resolveWebviewView(panel);
|
||||
|
||||
// Set up message tracking
|
||||
let webviewReady = false;
|
||||
let messagesReceived = false;
|
||||
const originalPostMessage = provider.postMessageToWebview.bind(provider);
|
||||
// @ts-ignore
|
||||
provider.postMessageToWebview = async (message) => {
|
||||
if (message.type === 'state') {
|
||||
webviewReady = true;
|
||||
console.log('Webview state received:', message);
|
||||
if (message.state?.clineMessages?.length > 0) {
|
||||
messagesReceived = true;
|
||||
console.log('Messages in state:', message.state.clineMessages);
|
||||
}
|
||||
}
|
||||
await originalPostMessage(message);
|
||||
};
|
||||
|
||||
// Wait for webview to launch and receive initial state
|
||||
let startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (webviewReady) {
|
||||
// Wait an additional second for webview to fully initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
if (!webviewReady) {
|
||||
throw new Error('Timeout waiting for webview to be ready');
|
||||
}
|
||||
|
||||
// Send webviewDidLaunch to initialize chat
|
||||
await provider.postMessageToWebview({ type: 'webviewDidLaunch' });
|
||||
console.log('Sent webviewDidLaunch');
|
||||
|
||||
// Wait for webview to fully initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Restore original postMessage
|
||||
provider.postMessageToWebview = originalPostMessage;
|
||||
|
||||
// Wait for OpenRouter models to be fully loaded
|
||||
startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const models = await provider.readOpenRouterModels();
|
||||
if (models && Object.keys(models).length > 0) {
|
||||
console.log('OpenRouter models loaded');
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
// Send prompt
|
||||
const prompt = "Hello world, what is your name?";
|
||||
console.log('Sending prompt:', prompt);
|
||||
|
||||
// Start task
|
||||
try {
|
||||
await api.startNewTask(prompt);
|
||||
console.log('Task started');
|
||||
} catch (error) {
|
||||
console.error('Error starting task:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait for task to appear in history with tokens
|
||||
startTime = Date.now();
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const state = await provider.getState();
|
||||
const task = state.taskHistory?.[0];
|
||||
if (task && task.tokensOut > 0) {
|
||||
console.log('Task completed with tokens:', task);
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
// Wait for messages to be processed
|
||||
startTime = Date.now();
|
||||
let responseReceived = false;
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Check provider.clineMessages
|
||||
const messages = provider.clineMessages;
|
||||
if (messages && messages.length > 0) {
|
||||
console.log('Provider messages:', JSON.stringify(messages, null, 2));
|
||||
// @ts-ignore
|
||||
const hasResponse = messages.some(m =>
|
||||
m.type === 'say' &&
|
||||
m.text &&
|
||||
m.text.toLowerCase().includes('cline')
|
||||
);
|
||||
if (hasResponse) {
|
||||
console.log('Found response containing "Cline" in provider messages');
|
||||
responseReceived = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check provider.cline.clineMessages
|
||||
const clineMessages = provider.cline?.clineMessages;
|
||||
if (clineMessages && clineMessages.length > 0) {
|
||||
console.log('Cline messages:', JSON.stringify(clineMessages, null, 2));
|
||||
// @ts-ignore
|
||||
const hasResponse = clineMessages.some(m =>
|
||||
m.type === 'say' &&
|
||||
m.text &&
|
||||
m.text.toLowerCase().includes('cline')
|
||||
);
|
||||
if (hasResponse) {
|
||||
console.log('Found response containing "Cline" in cline messages');
|
||||
responseReceived = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
if (!responseReceived) {
|
||||
console.log('Final provider state:', await provider.getState());
|
||||
console.log('Final cline messages:', provider.cline?.clineMessages);
|
||||
throw new Error('Did not receive expected response containing "Cline"');
|
||||
}
|
||||
} finally {
|
||||
panel.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
19
src/test/tsconfig.json
Normal file
19
src/test/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "../..",
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitThis": false,
|
||||
"alwaysStrict": false,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "../..",
|
||||
"paths": {
|
||||
"*": ["*", "src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", ".vscode-test"]
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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
|
||||
|
||||
225
webview-ui/src/components/settings/ApiConfigManager.tsx
Normal file
225
webview-ui/src/components/settings/ApiConfigManager.tsx
Normal 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)
|
||||
@@ -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">
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -360,13 +406,46 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
</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)" }}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user