diff --git a/.gitignore b/.gitignore index ad38c8b..4914bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ roo-cline-*.vsix # Local prompts and rules /local-prompts + +# Test environment +.test_env \ No newline at end of file diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 605b44f..1ce01d1 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -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' + ] +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index fbce017..2b12425 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index bf2a627..07f0e7f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/jest.config.js b/jest.config.js index 1ef4d79..549b518 100644 --- a/jest.config.js +++ b/jest.config.js @@ -32,6 +32,9 @@ module.exports = { modulePathIgnorePatterns: [ '.vscode-test' ], + reporters: [ + ["jest-simple-dot-reporter", {}] + ], setupFiles: [], globals: { 'ts-jest': { diff --git a/package-lock.json b/package-lock.json index d68e66a..3e5911d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d0e4127..38de820 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 70d55b7..139b3a2 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -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 diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 84bf47c..9f650c2 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -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,18 +832,33 @@ 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. - const { response } = await this.ask( - "api_req_failed", - error.message ?? JSON.stringify(serializeError(error), null, 2), - ) - if (response !== "yesButtonClicked") { - // this will never happen since if noButtonClicked, we will clear current task, aborting this instance - throw new Error("API request failed") + 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), + ) + if (response !== "yesButtonClicked") { + // this will never happen since if noButtonClicked, we will clear current task, aborting this instance + throw new Error("API request failed") + } + await this.say("api_req_retried") + // delegate generator output from the recursive call + yield* this.attemptApiRequest(previousApiReqIndex) + return } - await this.say("api_req_retried") - // delegate generator output from the recursive call - yield* this.attemptApiRequest(previousApiReqIndex) - return } // no error, so we can continue to yield all remaining chunks diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 4e9b98b..c21b172 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -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; + + 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]' + }); + }); }); }); }); diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts new file mode 100644 index 0000000..7e4393d --- /dev/null +++ b/src/core/config/ConfigManager.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`) + } + } +} \ No newline at end of file diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts new file mode 100644 index 0000000..b8170ee --- /dev/null +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -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' + ) + }) + }) +}) \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 493383d..44d22d5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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) - await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex)) - if (apiConversationHistoryIndex !== -1) { - await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex)) + + 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('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, this.getGlobalState("apiModelId") as Promise, @@ -1409,7 +1705,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("screenshotQuality") as Promise, this.getGlobalState("terminalOutputLineLimit") as Promise, this.getGlobalState("mcpEnabled") as Promise, + this.getGlobalState("alwaysApproveResubmit") as Promise, + this.getGlobalState("requestDelaySeconds") as Promise, + this.getGlobalState("currentApiConfigName") as Promise, + this.getGlobalState("listApiConfigMeta") as Promise, this.getGlobalState("vsCodeLmModelSelector") as Promise, + ]) 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 ?? [], } } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 2add65e..0a7ee9a 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -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() + }) + }) }) diff --git a/src/exports/cline.d.ts b/src/exports/cline.d.ts index 1ae285f..fcf93fc 100644 --- a/src/exports/cline.d.ts +++ b/src/exports/cline.d.ts @@ -34,4 +34,9 @@ export interface ClineAPI { * Simulates pressing the secondary button in the chat interface. */ pressSecondaryButton(): Promise + + /** + * The sidebar provider instance. + */ + sidebarProvider: ClineSidebarProvider } diff --git a/src/exports/index.ts b/src/exports/index.ts index 04d26d8..a0680b0 100644 --- a/src/exports/index.ts +++ b/src/exports/index.ts @@ -56,6 +56,8 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi invoke: "secondaryButtonClick", }) }, + + sidebarProvider: sidebarProvider, } return api diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index b849ea3..7985e6c 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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" @@ -9,25 +9,25 @@ import { GitCommit } from "../utils/git" export interface ExtensionMessage { type: - | "action" - | "state" - | "selectedImages" - | "ollamaModels" - | "lmStudioModels" - | "vsCodeLmModels" - | "vsCodeLmApiAvailable" - | "requestVsCodeLmModels" - | "theme" - | "workspaceUpdated" - | "invoke" - | "partialMessage" - | "glamaModels" - | "openRouterModels" - | "openAiModels" - | "mcpServers" - | "enhancedPrompt" - | "commitSearchResults" - + | "action" + | "state" + | "selectedImages" + | "ollamaModels" + | "lmStudioModels" + | "theme" + | "workspaceUpdated" + | "invoke" + | "partialMessage" + | "glamaModels" + | "openRouterModels" + | "openAiModels" + | "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" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index a0cc726..57afa7e 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -4,56 +4,65 @@ export type AudioType = "notification" | "celebration" | "progress_loop" export interface WebviewMessage { type: - | "apiConfiguration" - | "customInstructions" - | "allowedCommands" - | "alwaysAllowReadOnly" - | "alwaysAllowWrite" - | "alwaysAllowExecute" - | "webviewDidLaunch" - | "newTask" - | "askResponse" - | "clearTask" - | "didShowAnnouncement" - | "selectImages" - | "exportCurrentTask" - | "showTaskWithId" - | "deleteTaskWithId" - | "exportTaskWithId" - | "resetState" - | "requestOllamaModels" - | "requestLmStudioModels" - | "requestVsCodeLmModels" - | "openImage" - | "openFile" - | "openMention" - | "cancelTask" - | "refreshOpenRouterModels" - | "refreshOpenAiModels" - | "alwaysAllowBrowser" - | "alwaysAllowMcp" - | "playSound" - | "soundEnabled" - | "soundVolume" - | "diffEnabled" - | "browserViewportSize" - | "screenshotQuality" - | "openMcpSettings" - | "restartMcpServer" - | "toggleToolAlwaysAllow" - | "toggleMcpServer" - | "fuzzyMatchThreshold" - | "preferredLanguage" - | "writeDelayMs" - | "enhancePrompt" - | "enhancedPrompt" - | "draggedImages" - | "deleteMessage" - | "terminalOutputLineLimit" - | "mcpEnabled" - | "refreshGlamaModels" - | "searchCommits" - + | "apiConfiguration" + | "currentApiConfigName" + | "upsertApiConfiguration" + | "deleteApiConfiguration" + | "loadApiConfiguration" + | "renameApiConfiguration" + | "getListApiConfiguration" + | "customInstructions" + | "allowedCommands" + | "alwaysAllowReadOnly" + | "alwaysAllowWrite" + | "alwaysAllowExecute" + | "webviewDidLaunch" + | "newTask" + | "askResponse" + | "clearTask" + | "didShowAnnouncement" + | "selectImages" + | "exportCurrentTask" + | "showTaskWithId" + | "deleteTaskWithId" + | "exportTaskWithId" + | "resetState" + | "requestOllamaModels" + | "requestLmStudioModels" + | "openImage" + | "openFile" + | "openMention" + | "cancelTask" + | "refreshGlamaModels" + | "refreshOpenRouterModels" + | "refreshOpenAiModels" + | "alwaysAllowBrowser" + | "alwaysAllowMcp" + | "playSound" + | "soundEnabled" + | "soundVolume" + | "diffEnabled" + | "browserViewportSize" + | "screenshotQuality" + | "openMcpSettings" + | "restartMcpServer" + | "toggleToolAlwaysAllow" + | "toggleMcpServer" + | "fuzzyMatchThreshold" + | "preferredLanguage" + | "writeDelayMs" + | "enhancePrompt" + | "enhancedPrompt" + | "draggedImages" + | "deleteMessage" + | "terminalOutputLineLimit" + | "mcpEnabled" + | "searchCommits" + | "refreshGlamaModels" + | "alwaysApproveResubmit" + | "requestDelaySeconds" + | "setApiConfigPassword" + | "requestVsCodeLmModels" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/src/shared/api.ts b/src/shared/api.ts index 4e60670..9be710e 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -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, diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts new file mode 100644 index 0000000..b347ccf --- /dev/null +++ b/src/shared/checkExistApiConfig.ts @@ -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; +} diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 35bee7b..7377f3f 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -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(); + } + }); +}); diff --git a/src/test/tsconfig.json b/src/test/tsconfig.json new file mode 100644 index 0000000..0560c90 --- /dev/null +++ b/src/test/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 2078013..3954094 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -44,9 +44,21 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, apiConfiguration } = useExtensionState() + const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [gitCommits, setGitCommits] = useState([]) + 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( style={{ position: "absolute", paddingTop: 4, - bottom: 14, + bottom: 32, left: 22, right: 67, zIndex: 2, }} /> )} + {(listApiConfigMeta || []).length > 1 && ( +
+ +
+ )}
{apiConfiguration?.apiProvider === "openrouter" && ( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index db11547..12a7e93 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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

What can I do for you?

- Thanks to{" "} - - Claude 3.5 Sonnet's agentic coding capabilities, - {" "} + 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 diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx new file mode 100644 index 0000000..e05af14 --- /dev/null +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -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(); + + // 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 ( +

+
+ + + {editState ? ( +
+ 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(); + } + }} + /> + + + + + + +
+ ) : ( + <> +
+ + + + + {currentApiConfigName && ( + <> + + + + + + + + )} +
+

+ Save different API configurations to quickly switch between providers and settings +

+ + )} +
+
+ ) +} + +export default memo(ApiConfigManager) \ No newline at end of file diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 1322526..6adac91 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -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([]) const [lmStudioModels, setLmStudioModels] = useState([]) const [vsCodeLmModels, setVsCodeLmModels] = useState([]) @@ -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 }:

)} - {selectedProvider === "glama" && showModelOptions && } + {selectedProvider === "glama" && } - {selectedProvider === "openrouter" && showModelOptions && } + {selectedProvider === "openrouter" && } {selectedProvider !== "glama" && selectedProvider !== "openrouter" && selectedProvider !== "openai" && selectedProvider !== "ollama" && - selectedProvider !== "lmstudio" && - showModelOptions && ( + selectedProvider !== "lmstudio" && ( <>