From 66c54855342416d09af7d6c289549adb1bd5bda9 Mon Sep 17 00:00:00 2001 From: ColemanRoo Date: Fri, 3 Jan 2025 14:26:26 -0600 Subject: [PATCH 01/34] Set up vscode integration test --- .gitignore | 3 + .vscode-test.mjs | 15 +- package.json | 4 + src/exports/cline.d.ts | 5 + src/exports/index.ts | 2 + src/test/extension.test.ts | 354 +++++++++++++++++++++++++++++++++++-- src/test/tsconfig.json | 19 ++ 7 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 src/test/tsconfig.json 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/package.json b/package.json index 7d82a24..0f9666b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "url": "https://github.com/RooVetGit/Roo-Cline" }, "homepage": "https://github.com/RooVetGit/Roo-Cline", + "enabledApiProposals": [ + "extensionRuntime" + ], "categories": [ "AI", "Chat", @@ -159,6 +162,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", 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/test/extension.test.ts b/src/test/extension.test.ts index 35bee7b..d57654b 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'); -// 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'; +suite('Roo Cline Extension Test Suite', () => { + vscode.window.showInformationMessage('Starting Roo Cline extension tests.'); -suite("Extension Test Suite", () => { - vscode.window.showInformationMessage("Start all tests.") + test('Extension should be present', () => { + const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline'); + assert.notStrictEqual(extension, undefined); + }); - test("Sample test", () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)) - assert.strictEqual(-1, [1, 2, 3].indexOf(0)) - }) -}) + 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 testEnvPath = path.join(__dirname, '.test_env'); + const envContent = fs.readFileSync(testEnvPath, 'utf8'); + const match = envContent.match(/OPEN_ROUTER_API_KEY=(.+)/); + if (!match) { + done(new Error('OpenRouter API key should be present in .test_env')); + return; + } + const apiKey = match[1]; + 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 testEnvPath = path.join(__dirname, '.test_env'); + const envContent = fs.readFileSync(testEnvPath, 'utf8'); + const match = envContent.match(/OPEN_ROUTER_API_KEY=(.+)/); + if (!match) { + assert.fail('OpenRouter API key should be present in .test_env'); + return; + } + await provider.storeSecret('openRouterApiKey', match[1]); + + // 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 From 8e54360a8606b8aa217918fd84674993122fffef Mon Sep 17 00:00:00 2001 From: ColemanRoo Date: Fri, 3 Jan 2025 21:35:25 -0600 Subject: [PATCH 02/34] Switch to using dotenv to get test environment variables --- package-lock.json | 14 ++++++++++++++ package.json | 3 ++- src/test/extension.test.ts | 24 ++++++++++++------------ 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6143970..fb2065a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "@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", @@ -8000,6 +8001,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", diff --git a/package.json b/package.json index 0f9666b..0ac1434 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,7 @@ "@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", @@ -196,9 +197,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/test/extension.test.ts b/src/test/extension.test.ts index d57654b..7377f3f 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -2,6 +2,11 @@ const assert = require('assert'); const vscode = require('vscode'); const path = require('path'); const fs = require('fs'); +const dotenv = require('dotenv'); + +// Load test environment variables +const testEnvPath = path.join(__dirname, '.test_env'); +dotenv.config({ path: testEnvPath }); suite('Roo Cline Extension Test Suite', () => { vscode.window.showInformationMessage('Starting Roo Cline extension tests.'); @@ -34,14 +39,11 @@ suite('Roo Cline Extension Test Suite', () => { } // Verify API key is set and valid - const testEnvPath = path.join(__dirname, '.test_env'); - const envContent = fs.readFileSync(testEnvPath, 'utf8'); - const match = envContent.match(/OPEN_ROUTER_API_KEY=(.+)/); - if (!match) { - done(new Error('OpenRouter API key should be present in .test_env')); + const apiKey = process.env.OPEN_ROUTER_API_KEY; + if (!apiKey) { + done(new Error('OPEN_ROUTER_API_KEY environment variable is not set')); return; } - const apiKey = match[1]; if (!apiKey.startsWith('sk-or-v1-')) { done(new Error('OpenRouter API key should have correct format')); return; @@ -180,14 +182,12 @@ suite('Roo Cline Extension Test Suite', () => { // Set up API configuration await provider.updateGlobalState('apiProvider', 'openrouter'); await provider.updateGlobalState('openRouterModelId', 'anthropic/claude-3.5-sonnet'); - const testEnvPath = path.join(__dirname, '.test_env'); - const envContent = fs.readFileSync(testEnvPath, 'utf8'); - const match = envContent.match(/OPEN_ROUTER_API_KEY=(.+)/); - if (!match) { - assert.fail('OpenRouter API key should be present in .test_env'); + 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', match[1]); + await provider.storeSecret('openRouterApiKey', apiKey); // Create webview panel with development options const extensionUri = extension.extensionUri; From 87ba95b2884e00200da8958ce9995b15cb9d9d39 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 6 Jan 2025 23:27:17 -0500 Subject: [PATCH 03/34] Allow deleting single messages --- .changeset/funny-candles-exist.md | 5 + src/core/webview/ClineProvider.ts | 60 ++++++- .../webview/__tests__/ClineProvider.test.ts | 154 +++++++++++++++++- 3 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 .changeset/funny-candles-exist.md diff --git a/.changeset/funny-candles-exist.md b/.changeset/funny-candles-exist.md new file mode 100644 index 0000000..87965a8 --- /dev/null +++ b/.changeset/funny-candles-exist.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Allow deleting single messages or all subsequent messages diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0c62ba6..c6b1eab 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -689,21 +689,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 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) } } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 2add65e..6283ba5 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' })) } }) @@ -380,4 +387,149 @@ describe('ClineProvider', () => { 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() + }) + }) }) From 7a88aaf96f0609521ce73ebcec644e8f2f718d19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Jan 2025 04:37:51 +0000 Subject: [PATCH 04/34] changeset version bump --- .changeset/funny-candles-exist.md | 5 ----- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/funny-candles-exist.md diff --git a/.changeset/funny-candles-exist.md b/.changeset/funny-candles-exist.md deleted file mode 100644 index 87965a8..0000000 --- a/.changeset/funny-candles-exist.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Allow deleting single messages or all subsequent messages diff --git a/CHANGELOG.md b/CHANGELOG.md index fbce017..97755d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Roo Cline Changelog +## 2.2.43 + +### Patch Changes + +- Allow deleting single messages or all subsequent messages + ## [2.2.42] - Add a Git section to the context mentions diff --git a/package-lock.json b/package-lock.json index 74ae8ce..51ea26c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roo-cline", - "version": "2.2.42", + "version": "2.2.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "2.2.42", + "version": "2.2.43", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.26.0", diff --git a/package.json b/package.json index 4868781..bc76900 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.43", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", From c533fe08a59008dd41b5f3eb7d64b44199da6ad1 Mon Sep 17 00:00:00 2001 From: R00-B0T Date: Tue, 7 Jan 2025 04:38:27 +0000 Subject: [PATCH 05/34] Updating CHANGELOG.md format --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97755d3..6551f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,6 @@ # Roo Cline Changelog -## 2.2.43 - -### Patch Changes +## [2.2.43] - Allow deleting single messages or all subsequent messages From 80387a4d27f56dbb1d06a6a68e727c13d810551c Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 00:05:23 -0500 Subject: [PATCH 06/34] Remove API proposals --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index bc76900..689f779 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,6 @@ "url": "https://github.com/RooVetGit/Roo-Cline" }, "homepage": "https://github.com/RooVetGit/Roo-Cline", - "enabledApiProposals": [ - "extensionRuntime" - ], "categories": [ "AI", "Chat", From fe22d1ff2d147fe3edbb842ccf25e638cdabefda Mon Sep 17 00:00:00 2001 From: RaySinner <118297374+RaySinner@users.noreply.github.com> Date: Tue, 7 Jan 2025 16:26:34 +0300 Subject: [PATCH 07/34] feat: add retry request control with delay settings - Add requestDelaySeconds setting for configuring delay between retry attempts - Add alwaysApproveResubmit option for automatic retry approval - Add api_req_retry_delayed message type for delayed retries - Update UI components to support new retry control settings --- src/core/Cline.ts | 190 ++++++++++-------- src/core/webview/ClineProvider.ts | 48 +++-- .../webview/__tests__/ClineProvider.test.ts | 70 +++++-- src/shared/ExtensionMessage.ts | 59 +++--- src/shared/WebviewMessage.ts | 100 ++++----- .../src/components/settings/SettingsView.tsx | 46 ++++- .../src/context/ExtensionStateContext.tsx | 32 +-- 7 files changed, 337 insertions(+), 208 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 84bf47c..4c4d53d 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -205,10 +205,10 @@ export class Cline { const taskMessage = this.clineMessages[0] // first message is always the task say const lastRelevantMessage = this.clineMessages[ - findLastIndex( - this.clineMessages, - (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), - ) + findLastIndex( + this.clineMessages, + (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), + ) ] await this.providerRef.deref()?.updateTaskHistory({ id: this.taskId, @@ -390,8 +390,7 @@ export class Cline { async sayAndCreateMissingParamError(toolName: ToolUseName, paramName: string, relPath?: string) { await this.say( "error", - `Cline tried to use ${toolName}${ - relPath ? ` for '${relPath.toPosix()}'` : "" + `Cline tried to use ${toolName}${relPath ? ` for '${relPath.toPosix()}'` : "" } without value for required parameter '${paramName}'. Retrying...`, ) return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) @@ -449,7 +448,7 @@ export class Cline { // need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with cline messages let existingApiConversationHistory: Anthropic.Messages.MessageParam[] = - await this.getSavedApiConversationHistory() + await this.getSavedApiConversationHistory() // Now present the cline messages to the user and ask if they want to resume @@ -560,8 +559,8 @@ export class Cline { : [{ type: "text", text: lastMessage.content }] if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { const assistantContent = Array.isArray(previousAssistantMessage.content) - ? previousAssistantMessage.content - : [{ type: "text", text: previousAssistantMessage.content }] + ? previousAssistantMessage.content + : [{ type: "text", text: previousAssistantMessage.content }] const toolUseBlocks = assistantContent.filter( (block) => block.type === "tool_use", @@ -626,10 +625,9 @@ export class Cline { newUserContent.push({ type: "text", text: - `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${ - wasRecent - ? "\n\nIMPORTANT: If the last tool use was a write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents." - : "" + `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${wasRecent + ? "\n\nIMPORTANT: If the last tool use was a write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents." + : "" }` + (responseText ? `\n\nNew instructions for task continuation:\n\n${responseText}\n` @@ -743,8 +741,7 @@ export class Cline { return [ true, formatResponse.toolResult( - `Command is still running in the user's terminal.${ - result.length > 0 ? `\nHere's the output so far:\n${result}` : "" + `Command is still running in the user's terminal.${result.length > 0 ? `\nHere's the output so far:\n${result}` : "" }\n\nThe user provided the following feedback:\n\n${userFeedback.text}\n`, userFeedback.images, ), @@ -756,8 +753,7 @@ export class Cline { } else { return [ false, - `Command is still running in the user's terminal.${ - result.length > 0 ? `\nHere's the output so far:\n${result}` : "" + `Command is still running in the user's terminal.${result.length > 0 ? `\nHere's the output so far:\n${result}` : "" }\n\nYou will be updated on the terminal status and new output in the future.`, ] } @@ -766,7 +762,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 @@ -810,18 +806,41 @@ 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) { + // Automatically retry with delay + await this.say( + "error", + `Error (${ + error.message?.toLowerCase().includes("429") || + error.message?.toLowerCase().includes("rate limit") || + error.message?.toLowerCase().includes("too many requests") || + error.message?.toLowerCase().includes("throttled") + ? "rate limit" + : error.message?.includes("500") || error.message?.includes("503") + ? "internal server error" + : "unknown" + }). ↺ Retrying...`, + ) + await this.say("api_req_retry_delayed") + await delay((requestDelaySeconds || 5) * 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 @@ -912,9 +931,8 @@ export class Cline { case "apply_diff": return `[${block.name} for '${block.params.path}']` case "search_files": - return `[${block.name} for '${block.params.regex}'${ - block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" - }]` + return `[${block.name} for '${block.params.regex}'${block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" + }]` case "list_files": return `[${block.name} for '${block.params.path}']` case "list_code_definition_names": @@ -1100,7 +1118,7 @@ export class Cline { if (block.partial) { // update gui message const partialMessage = JSON.stringify(sharedMessageProps) - await this.ask("tool", partialMessage, block.partial).catch(() => {}) + await this.ask("tool", partialMessage, block.partial).catch(() => { }) // update editor if (!this.diffViewProvider.isEditing) { // open the editor and prepare to stream content in @@ -1136,7 +1154,7 @@ export class Cline { if (!this.diffViewProvider.isEditing) { // show gui message before showing edit animation const partialMessage = JSON.stringify(sharedMessageProps) - await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor + await this.ask("tool", partialMessage, true).catch(() => { }) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor await this.diffViewProvider.open(relPath) } await this.diffViewProvider.update(everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, true) @@ -1174,10 +1192,10 @@ export class Cline { content: fileExists ? undefined : newContent, diff: fileExists ? formatResponse.createPrettyPatch( - relPath, - this.diffViewProvider.originalContent, - newContent, - ) + relPath, + this.diffViewProvider.originalContent, + newContent, + ) : undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage) @@ -1199,13 +1217,13 @@ export class Cline { ) pushToolResult( `The user made the following updates to your content:\n\n${userEdits}\n\n` + - `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + - `\n${addLineNumbers(finalContent || '')}\n\n\n` + - `Please note:\n` + - `1. You do not need to re-write the file with these changes, as they have already been applied.\n` + - `2. Proceed with the task using this updated file content as the new baseline.\n` + - `3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` + - `${newProblemsMessage}`, + `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + + `\n${addLineNumbers(finalContent || '')}\n\n\n` + + `Please note:\n` + + `1. You do not need to re-write the file with these changes, as they have already been applied.\n` + + `2. Proceed with the task using this updated file content as the new baseline.\n` + + `3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` + + `${newProblemsMessage}`, ) } else { pushToolResult( @@ -1234,7 +1252,7 @@ export class Cline { if (block.partial) { // update gui message const partialMessage = JSON.stringify(sharedMessageProps) - await this.ask("tool", partialMessage, block.partial).catch(() => {}) + await this.ask("tool", partialMessage, block.partial).catch(() => { }) break } else { if (!relPath) { @@ -1263,9 +1281,9 @@ export class Cline { // Apply the diff to the original content const diffResult = this.diffStrategy?.applyDiff( - originalContent, - diffContent, - parseInt(block.params.start_line ?? ''), + originalContent, + diffContent, + parseInt(block.params.start_line ?? ''), parseInt(block.params.end_line ?? '') ) ?? { success: false, @@ -1317,13 +1335,13 @@ export class Cline { ) pushToolResult( `The user made the following updates to your content:\n\n${userEdits}\n\n` + - `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + - `\n${addLineNumbers(finalContent || '')}\n\n\n` + - `Please note:\n` + - `1. You do not need to re-write the file with these changes, as they have already been applied.\n` + - `2. Proceed with the task using this updated file content as the new baseline.\n` + - `3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` + - `${newProblemsMessage}`, + `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + + `\n${addLineNumbers(finalContent || '')}\n\n\n` + + `Please note:\n` + + `1. You do not need to re-write the file with these changes, as they have already been applied.\n` + + `2. Proceed with the task using this updated file content as the new baseline.\n` + + `3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` + + `${newProblemsMessage}`, ) } else { pushToolResult(`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`) @@ -1349,7 +1367,7 @@ export class Cline { ...sharedMessageProps, content: undefined, } satisfies ClineSayTool) - await this.ask("tool", partialMessage, block.partial).catch(() => {}) + await this.ask("tool", partialMessage, block.partial).catch(() => { }) break } else { if (!relPath) { @@ -1391,7 +1409,7 @@ export class Cline { ...sharedMessageProps, content: "", } satisfies ClineSayTool) - await this.ask("tool", partialMessage, block.partial).catch(() => {}) + await this.ask("tool", partialMessage, block.partial).catch(() => { }) break } else { if (!relDirPath) { @@ -1431,7 +1449,7 @@ export class Cline { ...sharedMessageProps, content: "", } satisfies ClineSayTool) - await this.ask("tool", partialMessage, block.partial).catch(() => {}) + await this.ask("tool", partialMessage, block.partial).catch(() => { }) break } else { if (!relDirPath) { @@ -1476,7 +1494,7 @@ export class Cline { ...sharedMessageProps, content: "", } satisfies ClineSayTool) - await this.ask("tool", partialMessage, block.partial).catch(() => {}) + await this.ask("tool", partialMessage, block.partial).catch(() => { }) break } else { if (!relDirPath) { @@ -1531,7 +1549,7 @@ export class Cline { "browser_action_launch", removeClosingTag("url", url), block.partial - ).catch(() => {}) + ).catch(() => { }) } else { await this.say( "browser_action", @@ -1631,8 +1649,7 @@ export class Cline { await this.say("browser_action_result", JSON.stringify(browserActionResult)) pushToolResult( formatResponse.toolResult( - `The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${ - browserActionResult.logs || "(No new logs)" + `The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${browserActionResult.logs || "(No new logs)" }\n\n(REMEMBER: if you need to proceed to using non-\`browser_action\` tools or launch a new browser, you MUST first close this browser. For example, if after analyzing the logs and screenshot you need to edit a file, you must first close the browser before you can use the write_to_file tool.)`, browserActionResult.screenshot ? [browserActionResult.screenshot] : [], ), @@ -1659,7 +1676,7 @@ export class Cline { try { if (block.partial) { await this.ask("command", removeClosingTag("command", command), block.partial).catch( - () => {} + () => { } ) break } else { @@ -1700,7 +1717,7 @@ export class Cline { toolName: removeClosingTag("tool_name", tool_name), arguments: removeClosingTag("arguments", mcp_arguments), } satisfies ClineAskUseMcpServer) - await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => {}) + await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => { }) break } else { if (!server_name) { @@ -1761,19 +1778,19 @@ export class Cline { // TODO: add progress indicator and ability to parse images and non-text responses const toolResultPretty = (toolResult?.isError ? "Error:\n" : "") + - toolResult?.content - .map((item) => { - if (item.type === "text") { - return item.text - } - if (item.type === "resource") { - const { blob, ...rest } = item.resource - return JSON.stringify(rest, null, 2) - } - return "" - }) - .filter(Boolean) - .join("\n\n") || "(No response)" + toolResult?.content + .map((item) => { + if (item.type === "text") { + return item.text + } + if (item.type === "resource") { + const { blob, ...rest } = item.resource + return JSON.stringify(rest, null, 2) + } + return "" + }) + .filter(Boolean) + .join("\n\n") || "(No response)" await this.say("mcp_server_response", toolResultPretty) pushToolResult(formatResponse.toolResult(toolResultPretty)) break @@ -1793,7 +1810,7 @@ export class Cline { serverName: removeClosingTag("server_name", server_name), uri: removeClosingTag("uri", uri), } satisfies ClineAskUseMcpServer) - await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => {}) + await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => { }) break } else { if (!server_name) { @@ -1849,7 +1866,7 @@ export class Cline { try { if (block.partial) { await this.ask("followup", removeClosingTag("question", question), block.partial).catch( - () => {}, + () => { }, ) break } else { @@ -1908,7 +1925,7 @@ export class Cline { "command", removeClosingTag("command", command), block.partial, - ).catch(() => {}) + ).catch(() => { }) } else { // last message is completion_result // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) @@ -1922,7 +1939,7 @@ export class Cline { "command", removeClosingTag("command", command), block.partial, - ).catch(() => {}) + ).catch(() => { }) } } else { // no command, still outputting partial result @@ -2148,10 +2165,9 @@ export class Cline { type: "text", text: assistantMessage + - `\n\n[${ - cancelReason === "streaming_failed" - ? "Response interrupted by API Error" - : "Response interrupted by user" + `\n\n[${cancelReason === "streaming_failed" + ? "Response interrupted by API Error" + : "Response interrupted by user" }]`, }, ], @@ -2405,7 +2421,7 @@ export class Cline { await pWaitFor(() => busyTerminals.every((t) => !this.terminalManager.isProcessHot(t.id)), { interval: 100, timeout: 15_000, - }).catch(() => {}) + }).catch(() => { }) } // we want to get diagnostics AFTER terminal cools down for a few reasons: terminal could be scaffolding a project, dev servers (compilers like webpack) will first re-compile and then send diagnostics, etc diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c6b1eab..b70d814 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -83,6 +83,8 @@ type GlobalStateKey = | "writeDelayMs" | "terminalOutputLineLimit" | "mcpEnabled" + | "alwaysApproveResubmit" + | "requestDelaySeconds" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", @@ -233,7 +235,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -253,7 +255,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -319,15 +321,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Use a nonce to only allow a specific script to be run. /* - content security policy of your webview to only allow scripts that have a specific nonce - create a content security policy meta tag so that only loading scripts with a nonce is allowed - As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. - + content security policy of your webview to only allow scripts that have a specific nonce + create a content security policy meta tag so that only loading scripts with a nonce is allowed + As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. + - 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection - since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:; - in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. - */ + in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. + */ const nonce = getNonce() // Tip: Install the es6-string-html VS Code extension to enable code highlighting below @@ -555,7 +557,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels }) break case "refreshGlamaModels": - await this.refreshGlamaModels() + await this.refreshGlamaModels() break case "refreshOpenRouterModels": await this.refreshOpenRouterModels() @@ -564,7 +566,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message?.values?.baseUrl && message?.values?.apiKey) { const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey) this.postMessageToWebview({ type: "openAiModels", openAiModels }) - } + } break case "openImage": openImage(message.text!) @@ -675,6 +677,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() @@ -1224,9 +1234,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async getStateToPostToWebview() { - const { - apiConfiguration, - lastShownAnnouncementId, + const { + apiConfiguration, + lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, @@ -1244,8 +1254,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { terminalOutputLineLimit, fuzzyMatchThreshold, mcpEnabled, + alwaysApproveResubmit, + requestDelaySeconds, } = await this.getState() - + const allowedCommands = vscode.workspace .getConfiguration('roo-cline') .get('allowedCommands') || [] @@ -1276,6 +1288,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { terminalOutputLineLimit: terminalOutputLineLimit ?? 500, fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0, mcpEnabled: mcpEnabled ?? true, + alwaysApproveResubmit: alwaysApproveResubmit ?? false, + requestDelaySeconds: requestDelaySeconds ?? 5, } } @@ -1381,6 +1395,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { screenshotQuality, terminalOutputLineLimit, mcpEnabled, + alwaysApproveResubmit, + requestDelaySeconds, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1431,6 +1447,8 @@ 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, ]) let apiProvider: ApiProvider @@ -1525,6 +1543,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { return langMap[vscodeLang.split('-')[0]] ?? 'English'; })(), mcpEnabled: mcpEnabled ?? true, + alwaysApproveResubmit: alwaysApproveResubmit ?? false, + requestDelaySeconds: requestDelaySeconds ?? 5, } } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 6283ba5..c3470fe 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -146,8 +146,8 @@ jest.mock('../../../integrations/misc/extract-text', () => ({ // Spy on console.error and console.log to suppress expected messages beforeAll(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}) - jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(console, 'error').mockImplementation(() => { }) + jest.spyOn(console, 'log').mockImplementation(() => { }) }) afterAll(() => { @@ -230,7 +230,7 @@ describe('ClineProvider', () => { test('resolveWebviewView sets up webview correctly', () => { provider.resolveWebviewView(mockWebviewView) - + expect(mockWebviewView.webview.options).toEqual({ enableScripts: true, localResourceRoots: [mockContext.extensionUri] @@ -240,7 +240,7 @@ describe('ClineProvider', () => { test('postMessageToWebview sends message to webview', async () => { provider.resolveWebviewView(mockWebviewView) - + const mockState: ExtensionState = { version: '1.0.0', preferredLanguage: 'English', @@ -263,14 +263,16 @@ describe('ClineProvider', () => { browserViewportSize: "900x600", fuzzyMatchThreshold: 1.0, mcpEnabled: true, + alwaysApproveResubmit: false, + requestDelaySeconds: 5, } - - const message: ExtensionMessage = { - type: 'state', + + const message: ExtensionMessage = { + type: 'state', state: mockState } await provider.postMessageToWebview(message) - + expect(mockPostMessage).toHaveBeenCalledWith(message) }) @@ -301,7 +303,7 @@ describe('ClineProvider', () => { test('getState returns correct initial state', async () => { const state = await provider.getState() - + expect(state).toHaveProperty('apiConfiguration') expect(state.apiConfiguration).toHaveProperty('apiProvider') expect(state).toHaveProperty('customInstructions') @@ -318,7 +320,7 @@ describe('ClineProvider', () => { test('preferredLanguage defaults to VSCode language when not set', async () => { // Mock VSCode language as Spanish (vscode.env as any).language = 'es-ES'; - + const state = await provider.getState(); expect(state.preferredLanguage).toBe('Spanish'); }) @@ -326,7 +328,7 @@ describe('ClineProvider', () => { test('preferredLanguage defaults to English for unsupported VSCode language', async () => { // Mock VSCode language as an unsupported language (vscode.env as any).language = 'unsupported-LANG'; - + const state = await provider.getState(); expect(state.preferredLanguage).toBe('English'); }) @@ -334,9 +336,9 @@ describe('ClineProvider', () => { test('diffEnabled defaults to true when not set', async () => { // Mock globalState.get to return undefined for diffEnabled (mockContext.globalState.get as jest.Mock).mockReturnValue(undefined) - + const state = await provider.getState() - + expect(state.diffEnabled).toBe(true) }) @@ -348,7 +350,7 @@ describe('ClineProvider', () => { } return null }) - + const state = await provider.getState() expect(state.writeDelayMs).toBe(1000) }) @@ -356,9 +358,9 @@ describe('ClineProvider', () => { test('handles writeDelayMs message', async () => { provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - + await messageHandler({ type: 'writeDelayMs', value: 2000 }) - + expect(mockContext.globalState.update).toHaveBeenCalledWith('writeDelayMs', 2000) expect(mockPostMessage).toHaveBeenCalled() }) @@ -382,6 +384,42 @@ 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') diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 30df793..f9e5c61 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -8,28 +8,28 @@ import { GitCommit } from "../utils/git" // webview will hold state export interface ExtensionMessage { type: - | "action" - | "state" - | "selectedImages" - | "ollamaModels" - | "lmStudioModels" - | "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" text?: string action?: - | "chatButtonClicked" - | "mcpButtonClicked" - | "settingsButtonClicked" - | "historyButtonClicked" - | "didBecomeVisible" + | "chatButtonClicked" + | "mcpButtonClicked" + | "settingsButtonClicked" + | "historyButtonClicked" + | "didBecomeVisible" invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" state?: ExtensionState images?: string[] @@ -56,6 +56,8 @@ export interface ExtensionState { alwaysAllowExecute?: boolean alwaysAllowBrowser?: boolean alwaysAllowMcp?: boolean + alwaysApproveResubmit?: boolean + requestDelaySeconds: number uriScheme?: string allowedCommands?: string[] soundEnabled?: boolean @@ -103,6 +105,7 @@ export type ClineSay = | "user_feedback" | "user_feedback_diff" | "api_req_retried" + | "api_req_retry_delayed" | "command_output" | "tool" | "shell_integration_warning" @@ -114,14 +117,14 @@ export type ClineSay = export interface ClineSayTool { tool: - | "editedExistingFile" - | "appliedDiff" - | "newFileCreated" - | "readFile" - | "listFilesTopLevel" - | "listFilesRecursive" - | "listCodeDefinitionNames" - | "searchFiles" + | "editedExistingFile" + | "appliedDiff" + | "newFileCreated" + | "readFile" + | "listFilesTopLevel" + | "listFilesRecursive" + | "listCodeDefinitionNames" + | "searchFiles" path?: string diff?: string content?: string diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 48eeb4a..e5c9b64 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,57 +1,59 @@ -import { ApiConfiguration, ApiProvider } from "./api" +import { ApiConfiguration } from "./api" 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" - | "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" + | "apiConfiguration" + | "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" + | "alwaysApproveResubmit" + | "requestDelaySeconds" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5ae0858..d24bd49 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -51,6 +51,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { terminalOutputLineLimit, setTerminalOutputLineLimit, mcpEnabled, + alwaysApproveResubmit, + setAlwaysApproveResubmit, + requestDelaySeconds, + setRequestDelaySeconds, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) @@ -83,6 +87,8 @@ 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 }) onDone() } } @@ -355,11 +361,47 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { Always approve browser actions

- Automatically perform browser actions without requiring approval
+ Automatically perform browser actions without requiring approval
Note: Only applies when the model supports computer use

+
+ setAlwaysApproveResubmit(e.target.checked)}> + Always approve resubmit request + +

+ Automatically retry request when server returns an error response, with a configurable delay +

+ {alwaysApproveResubmit && ( +
+
+ setRequestDelaySeconds(parseInt(e.target.value))} + style={{ + flex: 1, + accentColor: 'var(--vscode-button-background)', + height: '2px' + }} + /> + + {requestDelaySeconds}s + +
+

+ Delay before retrying the request +

+
+ )} +
+
{
-

Notification Settings

+

Notification Settings

setSoundEnabled(e.target.checked)}> Enable sound effects diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 52ee1e4..b6a922d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -46,6 +46,10 @@ 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 } export const ExtensionStateContext = createContext(undefined) @@ -67,6 +71,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode screenshotQuality: 75, terminalOutputLineLimit: 500, mcpEnabled: true, + alwaysApproveResubmit: false, + requestDelaySeconds: 5 }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -90,18 +96,18 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode 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) + config.apiKey, + config.glamaApiKey, + config.openRouterApiKey, + config.awsRegion, + config.vertexProjectId, + config.openAiApiKey, + config.ollamaModelId, + config.lmStudioModelId, + config.geminiApiKey, + config.openAiNativeApiKey, + config.deepSeekApiKey, + ].some((key) => key !== undefined) : false setShowWelcome(!hasKey) setDidHydrateState(true) @@ -201,6 +207,8 @@ 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 })) } return {children} From c9de9cda6644e9bffdb2e9f3d6ca38733d0dca4f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 10:25:51 -0500 Subject: [PATCH 08/34] Revert some whitespace changes --- src/core/Cline.ts | 143 +++++++++--------- src/core/webview/ClineProvider.ts | 22 +-- .../webview/__tests__/ClineProvider.test.ts | 35 +++-- src/shared/ExtensionMessage.ts | 56 +++---- src/shared/WebviewMessage.ts | 102 ++++++------- .../src/context/ExtensionStateContext.tsx | 24 +-- 6 files changed, 194 insertions(+), 188 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 4c4d53d..afbae37 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -205,10 +205,10 @@ export class Cline { const taskMessage = this.clineMessages[0] // first message is always the task say const lastRelevantMessage = this.clineMessages[ - findLastIndex( - this.clineMessages, - (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), - ) + findLastIndex( + this.clineMessages, + (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), + ) ] await this.providerRef.deref()?.updateTaskHistory({ id: this.taskId, @@ -390,7 +390,8 @@ export class Cline { async sayAndCreateMissingParamError(toolName: ToolUseName, paramName: string, relPath?: string) { await this.say( "error", - `Cline tried to use ${toolName}${relPath ? ` for '${relPath.toPosix()}'` : "" + `Cline tried to use ${toolName}${ + relPath ? ` for '${relPath.toPosix()}'` : "" } without value for required parameter '${paramName}'. Retrying...`, ) return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) @@ -448,7 +449,7 @@ export class Cline { // need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with cline messages let existingApiConversationHistory: Anthropic.Messages.MessageParam[] = - await this.getSavedApiConversationHistory() + await this.getSavedApiConversationHistory() // Now present the cline messages to the user and ask if they want to resume @@ -559,8 +560,8 @@ export class Cline { : [{ type: "text", text: lastMessage.content }] if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { const assistantContent = Array.isArray(previousAssistantMessage.content) - ? previousAssistantMessage.content - : [{ type: "text", text: previousAssistantMessage.content }] + ? previousAssistantMessage.content + : [{ type: "text", text: previousAssistantMessage.content }] const toolUseBlocks = assistantContent.filter( (block) => block.type === "tool_use", @@ -625,9 +626,10 @@ export class Cline { newUserContent.push({ type: "text", text: - `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${wasRecent - ? "\n\nIMPORTANT: If the last tool use was a write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents." - : "" + `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${ + wasRecent + ? "\n\nIMPORTANT: If the last tool use was a write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents." + : "" }` + (responseText ? `\n\nNew instructions for task continuation:\n\n${responseText}\n` @@ -741,7 +743,8 @@ export class Cline { return [ true, formatResponse.toolResult( - `Command is still running in the user's terminal.${result.length > 0 ? `\nHere's the output so far:\n${result}` : "" + `Command is still running in the user's terminal.${ + result.length > 0 ? `\nHere's the output so far:\n${result}` : "" }\n\nThe user provided the following feedback:\n\n${userFeedback.text}\n`, userFeedback.images, ), @@ -753,7 +756,8 @@ export class Cline { } else { return [ false, - `Command is still running in the user's terminal.${result.length > 0 ? `\nHere's the output so far:\n${result}` : "" + `Command is still running in the user's terminal.${ + result.length > 0 ? `\nHere's the output so far:\n${result}` : "" }\n\nYou will be updated on the terminal status and new output in the future.`, ] } @@ -931,8 +935,9 @@ export class Cline { case "apply_diff": return `[${block.name} for '${block.params.path}']` case "search_files": - return `[${block.name} for '${block.params.regex}'${block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" - }]` + return `[${block.name} for '${block.params.regex}'${ + block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" + }]` case "list_files": return `[${block.name} for '${block.params.path}']` case "list_code_definition_names": @@ -1118,7 +1123,7 @@ export class Cline { if (block.partial) { // update gui message const partialMessage = JSON.stringify(sharedMessageProps) - await this.ask("tool", partialMessage, block.partial).catch(() => { }) + await this.ask("tool", partialMessage, block.partial).catch(() => {}) // update editor if (!this.diffViewProvider.isEditing) { // open the editor and prepare to stream content in @@ -1154,7 +1159,7 @@ export class Cline { if (!this.diffViewProvider.isEditing) { // show gui message before showing edit animation const partialMessage = JSON.stringify(sharedMessageProps) - await this.ask("tool", partialMessage, true).catch(() => { }) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor + await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor await this.diffViewProvider.open(relPath) } await this.diffViewProvider.update(everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, true) @@ -1192,10 +1197,10 @@ export class Cline { content: fileExists ? undefined : newContent, diff: fileExists ? formatResponse.createPrettyPatch( - relPath, - this.diffViewProvider.originalContent, - newContent, - ) + relPath, + this.diffViewProvider.originalContent, + newContent, + ) : undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage) @@ -1217,13 +1222,13 @@ export class Cline { ) pushToolResult( `The user made the following updates to your content:\n\n${userEdits}\n\n` + - `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + - `\n${addLineNumbers(finalContent || '')}\n\n\n` + - `Please note:\n` + - `1. You do not need to re-write the file with these changes, as they have already been applied.\n` + - `2. Proceed with the task using this updated file content as the new baseline.\n` + - `3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` + - `${newProblemsMessage}`, + `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + + `\n${addLineNumbers(finalContent || '')}\n\n\n` + + `Please note:\n` + + `1. You do not need to re-write the file with these changes, as they have already been applied.\n` + + `2. Proceed with the task using this updated file content as the new baseline.\n` + + `3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` + + `${newProblemsMessage}`, ) } else { pushToolResult( @@ -1252,7 +1257,7 @@ export class Cline { if (block.partial) { // update gui message const partialMessage = JSON.stringify(sharedMessageProps) - await this.ask("tool", partialMessage, block.partial).catch(() => { }) + await this.ask("tool", partialMessage, block.partial).catch(() => {}) break } else { if (!relPath) { @@ -1281,9 +1286,9 @@ export class Cline { // Apply the diff to the original content const diffResult = this.diffStrategy?.applyDiff( - originalContent, - diffContent, - parseInt(block.params.start_line ?? ''), + originalContent, + diffContent, + parseInt(block.params.start_line ?? ''), parseInt(block.params.end_line ?? '') ) ?? { success: false, @@ -1335,13 +1340,13 @@ export class Cline { ) pushToolResult( `The user made the following updates to your content:\n\n${userEdits}\n\n` + - `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + - `\n${addLineNumbers(finalContent || '')}\n\n\n` + - `Please note:\n` + - `1. You do not need to re-write the file with these changes, as they have already been applied.\n` + - `2. Proceed with the task using this updated file content as the new baseline.\n` + - `3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` + - `${newProblemsMessage}`, + `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + + `\n${addLineNumbers(finalContent || '')}\n\n\n` + + `Please note:\n` + + `1. You do not need to re-write the file with these changes, as they have already been applied.\n` + + `2. Proceed with the task using this updated file content as the new baseline.\n` + + `3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` + + `${newProblemsMessage}`, ) } else { pushToolResult(`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`) @@ -1367,7 +1372,7 @@ export class Cline { ...sharedMessageProps, content: undefined, } satisfies ClineSayTool) - await this.ask("tool", partialMessage, block.partial).catch(() => { }) + await this.ask("tool", partialMessage, block.partial).catch(() => {}) break } else { if (!relPath) { @@ -1409,7 +1414,7 @@ export class Cline { ...sharedMessageProps, content: "", } satisfies ClineSayTool) - await this.ask("tool", partialMessage, block.partial).catch(() => { }) + await this.ask("tool", partialMessage, block.partial).catch(() => {}) break } else { if (!relDirPath) { @@ -1449,7 +1454,7 @@ export class Cline { ...sharedMessageProps, content: "", } satisfies ClineSayTool) - await this.ask("tool", partialMessage, block.partial).catch(() => { }) + await this.ask("tool", partialMessage, block.partial).catch(() => {}) break } else { if (!relDirPath) { @@ -1494,7 +1499,7 @@ export class Cline { ...sharedMessageProps, content: "", } satisfies ClineSayTool) - await this.ask("tool", partialMessage, block.partial).catch(() => { }) + await this.ask("tool", partialMessage, block.partial).catch(() => {}) break } else { if (!relDirPath) { @@ -1549,7 +1554,7 @@ export class Cline { "browser_action_launch", removeClosingTag("url", url), block.partial - ).catch(() => { }) + ).catch(() => {}) } else { await this.say( "browser_action", @@ -1649,7 +1654,8 @@ export class Cline { await this.say("browser_action_result", JSON.stringify(browserActionResult)) pushToolResult( formatResponse.toolResult( - `The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${browserActionResult.logs || "(No new logs)" + `The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${ + browserActionResult.logs || "(No new logs)" }\n\n(REMEMBER: if you need to proceed to using non-\`browser_action\` tools or launch a new browser, you MUST first close this browser. For example, if after analyzing the logs and screenshot you need to edit a file, you must first close the browser before you can use the write_to_file tool.)`, browserActionResult.screenshot ? [browserActionResult.screenshot] : [], ), @@ -1676,7 +1682,7 @@ export class Cline { try { if (block.partial) { await this.ask("command", removeClosingTag("command", command), block.partial).catch( - () => { } + () => {} ) break } else { @@ -1717,7 +1723,7 @@ export class Cline { toolName: removeClosingTag("tool_name", tool_name), arguments: removeClosingTag("arguments", mcp_arguments), } satisfies ClineAskUseMcpServer) - await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => { }) + await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => {}) break } else { if (!server_name) { @@ -1778,19 +1784,19 @@ export class Cline { // TODO: add progress indicator and ability to parse images and non-text responses const toolResultPretty = (toolResult?.isError ? "Error:\n" : "") + - toolResult?.content - .map((item) => { - if (item.type === "text") { - return item.text - } - if (item.type === "resource") { - const { blob, ...rest } = item.resource - return JSON.stringify(rest, null, 2) - } - return "" - }) - .filter(Boolean) - .join("\n\n") || "(No response)" + toolResult?.content + .map((item) => { + if (item.type === "text") { + return item.text + } + if (item.type === "resource") { + const { blob, ...rest } = item.resource + return JSON.stringify(rest, null, 2) + } + return "" + }) + .filter(Boolean) + .join("\n\n") || "(No response)" await this.say("mcp_server_response", toolResultPretty) pushToolResult(formatResponse.toolResult(toolResultPretty)) break @@ -1810,7 +1816,7 @@ export class Cline { serverName: removeClosingTag("server_name", server_name), uri: removeClosingTag("uri", uri), } satisfies ClineAskUseMcpServer) - await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => { }) + await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => {}) break } else { if (!server_name) { @@ -1866,7 +1872,7 @@ export class Cline { try { if (block.partial) { await this.ask("followup", removeClosingTag("question", question), block.partial).catch( - () => { }, + () => {}, ) break } else { @@ -1925,7 +1931,7 @@ export class Cline { "command", removeClosingTag("command", command), block.partial, - ).catch(() => { }) + ).catch(() => {}) } else { // last message is completion_result // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) @@ -1939,7 +1945,7 @@ export class Cline { "command", removeClosingTag("command", command), block.partial, - ).catch(() => { }) + ).catch(() => {}) } } else { // no command, still outputting partial result @@ -2165,9 +2171,10 @@ export class Cline { type: "text", text: assistantMessage + - `\n\n[${cancelReason === "streaming_failed" - ? "Response interrupted by API Error" - : "Response interrupted by user" + `\n\n[${ + cancelReason === "streaming_failed" + ? "Response interrupted by API Error" + : "Response interrupted by user" }]`, }, ], @@ -2421,7 +2428,7 @@ export class Cline { await pWaitFor(() => busyTerminals.every((t) => !this.terminalManager.isProcessHot(t.id)), { interval: 100, timeout: 15_000, - }).catch(() => { }) + }).catch(() => {}) } // we want to get diagnostics AFTER terminal cools down for a few reasons: terminal could be scaffolding a project, dev servers (compilers like webpack) will first re-compile and then send diagnostics, etc diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b70d814..579a12e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -235,7 +235,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -255,7 +255,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -321,15 +321,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Use a nonce to only allow a specific script to be run. /* - content security policy of your webview to only allow scripts that have a specific nonce - create a content security policy meta tag so that only loading scripts with a nonce is allowed - As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. - + content security policy of your webview to only allow scripts that have a specific nonce + create a content security policy meta tag so that only loading scripts with a nonce is allowed + As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. + - 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection - since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:; - in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. - */ + in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. + */ const nonce = getNonce() // Tip: Install the es6-string-html VS Code extension to enable code highlighting below @@ -557,7 +557,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels }) break case "refreshGlamaModels": - await this.refreshGlamaModels() + await this.refreshGlamaModels() break case "refreshOpenRouterModels": await this.refreshOpenRouterModels() @@ -566,7 +566,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message?.values?.baseUrl && message?.values?.apiKey) { const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey) this.postMessageToWebview({ type: "openAiModels", openAiModels }) - } + } break case "openImage": openImage(message.text!) @@ -1257,7 +1257,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysApproveResubmit, requestDelaySeconds, } = await this.getState() - + const allowedCommands = vscode.workspace .getConfiguration('roo-cline') .get('allowedCommands') || [] diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index c3470fe..0a7ee9a 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -146,8 +146,8 @@ jest.mock('../../../integrations/misc/extract-text', () => ({ // Spy on console.error and console.log to suppress expected messages beforeAll(() => { - jest.spyOn(console, 'error').mockImplementation(() => { }) - jest.spyOn(console, 'log').mockImplementation(() => { }) + jest.spyOn(console, 'error').mockImplementation(() => {}) + jest.spyOn(console, 'log').mockImplementation(() => {}) }) afterAll(() => { @@ -230,7 +230,7 @@ describe('ClineProvider', () => { test('resolveWebviewView sets up webview correctly', () => { provider.resolveWebviewView(mockWebviewView) - + expect(mockWebviewView.webview.options).toEqual({ enableScripts: true, localResourceRoots: [mockContext.extensionUri] @@ -240,7 +240,7 @@ describe('ClineProvider', () => { test('postMessageToWebview sends message to webview', async () => { provider.resolveWebviewView(mockWebviewView) - + const mockState: ExtensionState = { version: '1.0.0', preferredLanguage: 'English', @@ -263,16 +263,15 @@ describe('ClineProvider', () => { browserViewportSize: "900x600", fuzzyMatchThreshold: 1.0, mcpEnabled: true, - alwaysApproveResubmit: false, - requestDelaySeconds: 5, + requestDelaySeconds: 5 } - - const message: ExtensionMessage = { - type: 'state', + + const message: ExtensionMessage = { + type: 'state', state: mockState } await provider.postMessageToWebview(message) - + expect(mockPostMessage).toHaveBeenCalledWith(message) }) @@ -303,7 +302,7 @@ describe('ClineProvider', () => { test('getState returns correct initial state', async () => { const state = await provider.getState() - + expect(state).toHaveProperty('apiConfiguration') expect(state.apiConfiguration).toHaveProperty('apiProvider') expect(state).toHaveProperty('customInstructions') @@ -320,7 +319,7 @@ describe('ClineProvider', () => { test('preferredLanguage defaults to VSCode language when not set', async () => { // Mock VSCode language as Spanish (vscode.env as any).language = 'es-ES'; - + const state = await provider.getState(); expect(state.preferredLanguage).toBe('Spanish'); }) @@ -328,7 +327,7 @@ describe('ClineProvider', () => { test('preferredLanguage defaults to English for unsupported VSCode language', async () => { // Mock VSCode language as an unsupported language (vscode.env as any).language = 'unsupported-LANG'; - + const state = await provider.getState(); expect(state.preferredLanguage).toBe('English'); }) @@ -336,9 +335,9 @@ describe('ClineProvider', () => { test('diffEnabled defaults to true when not set', async () => { // Mock globalState.get to return undefined for diffEnabled (mockContext.globalState.get as jest.Mock).mockReturnValue(undefined) - + const state = await provider.getState() - + expect(state.diffEnabled).toBe(true) }) @@ -350,7 +349,7 @@ describe('ClineProvider', () => { } return null }) - + const state = await provider.getState() expect(state.writeDelayMs).toBe(1000) }) @@ -358,9 +357,9 @@ describe('ClineProvider', () => { test('handles writeDelayMs message', async () => { provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] - + await messageHandler({ type: 'writeDelayMs', value: 2000 }) - + expect(mockContext.globalState.update).toHaveBeenCalledWith('writeDelayMs', 2000) expect(mockPostMessage).toHaveBeenCalled() }) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index f9e5c61..6b877a0 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -8,28 +8,28 @@ import { GitCommit } from "../utils/git" // webview will hold state export interface ExtensionMessage { type: - | "action" - | "state" - | "selectedImages" - | "ollamaModels" - | "lmStudioModels" - | "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" text?: string action?: - | "chatButtonClicked" - | "mcpButtonClicked" - | "settingsButtonClicked" - | "historyButtonClicked" - | "didBecomeVisible" + | "chatButtonClicked" + | "mcpButtonClicked" + | "settingsButtonClicked" + | "historyButtonClicked" + | "didBecomeVisible" invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" state?: ExtensionState images?: string[] @@ -117,14 +117,14 @@ export type ClineSay = export interface ClineSayTool { tool: - | "editedExistingFile" - | "appliedDiff" - | "newFileCreated" - | "readFile" - | "listFilesTopLevel" - | "listFilesRecursive" - | "listCodeDefinitionNames" - | "searchFiles" + | "editedExistingFile" + | "appliedDiff" + | "newFileCreated" + | "readFile" + | "listFilesTopLevel" + | "listFilesRecursive" + | "listCodeDefinitionNames" + | "searchFiles" path?: string diff?: string content?: string diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index e5c9b64..0ca7cb3 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,59 +1,59 @@ -import { ApiConfiguration } from "./api" +import { ApiConfiguration, ApiProvider } from "./api" 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" - | "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" - | "alwaysApproveResubmit" - | "requestDelaySeconds" + | "apiConfiguration" + | "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" + | "alwaysApproveResubmit" + | "requestDelaySeconds" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index b6a922d..131364b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -96,18 +96,18 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode 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) + config.apiKey, + config.glamaApiKey, + config.openRouterApiKey, + config.awsRegion, + config.vertexProjectId, + config.openAiApiKey, + config.ollamaModelId, + config.lmStudioModelId, + config.geminiApiKey, + config.openAiNativeApiKey, + config.deepSeekApiKey, + ].some((key) => key !== undefined) : false setShowWelcome(!hasKey) setDidHydrateState(true) From 0ff8fe45d4729a692529e4c8eb7b240c605594cb Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 10:34:49 -0500 Subject: [PATCH 09/34] UI tweaks --- src/core/Cline.ts | 5 +++-- webview-ui/src/components/settings/SettingsView.tsx | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index afbae37..e1ba0c1 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -811,6 +811,7 @@ export class Cline { } 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", @@ -823,10 +824,10 @@ export class Cline { : error.message?.includes("500") || error.message?.includes("503") ? "internal server error" : "unknown" - }). ↺ Retrying...`, + }). ↺ Retrying in ${requestDelay} seconds...`, ) await this.say("api_req_retry_delayed") - await delay((requestDelaySeconds || 5) * 1000) + await delay(requestDelay * 1000) await this.say("api_req_retried") // delegate generator output from the recursive call yield* this.attemptApiRequest(previousApiReqIndex) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index d24bd49..956b76b 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -370,10 +370,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setAlwaysApproveResubmit(e.target.checked)}> - Always approve resubmit request + Always retry failed API requests

- Automatically retry request when server returns an error response, with a configurable delay + Automatically retry failed API requests when server returns an error response

{alwaysApproveResubmit && (
From f7a98c7f51132af52a3fdfeeaab86046fadb91c9 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 10:37:12 -0500 Subject: [PATCH 10/34] Release --- .changeset/slow-ladybugs-invite.md | 5 +++++ README.md | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/slow-ladybugs-invite.md diff --git a/.changeset/slow-ladybugs-invite.md b/.changeset/slow-ladybugs-invite.md new file mode 100644 index 0000000..eca059e --- /dev/null +++ b/.changeset/slow-ladybugs-invite.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Automatically retry failed API requests with a configurable delay (thanks @RaySinner!) diff --git a/README.md b/README.md index bf2a627..d067902 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,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 From 69a85b2093de64b602374520acf01aec9510d4f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Jan 2025 15:51:07 +0000 Subject: [PATCH 11/34] changeset version bump --- .changeset/slow-ladybugs-invite.md | 5 ----- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/slow-ladybugs-invite.md diff --git a/.changeset/slow-ladybugs-invite.md b/.changeset/slow-ladybugs-invite.md deleted file mode 100644 index eca059e..0000000 --- a/.changeset/slow-ladybugs-invite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Automatically retry failed API requests with a configurable delay (thanks @RaySinner!) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6551f52..b6267a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Roo Cline Changelog +## 2.2.44 + +### Patch Changes + +- Automatically retry failed API requests with a configurable delay (thanks @RaySinner!) + ## [2.2.43] - Allow deleting single messages or all subsequent messages diff --git a/package-lock.json b/package-lock.json index 51ea26c..8c88b6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roo-cline", - "version": "2.2.43", + "version": "2.2.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "2.2.43", + "version": "2.2.44", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.26.0", diff --git a/package.json b/package.json index 689f779..ff3bb8a 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.43", + "version": "2.2.44", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", From 305efaa7d08d69fe974949c1fa6b152e4a7eff62 Mon Sep 17 00:00:00 2001 From: R00-B0T Date: Tue, 7 Jan 2025 15:51:47 +0000 Subject: [PATCH 12/34] Updating CHANGELOG.md format --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6267a8..373107e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,6 @@ # Roo Cline Changelog -## 2.2.44 - -### Patch Changes +## [2.2.44] - Automatically retry failed API requests with a configurable delay (thanks @RaySinner!) From 0aa5f1fb854ebde19a46b785f85bbc5940c39c37 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 20 Dec 2024 09:45:30 -0500 Subject: [PATCH 13/34] Add the o1 model --- src/api/providers/openai-native.ts | 1 + src/shared/api.ts | 8 ++++++++ 2 files changed, 9 insertions(+) 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/shared/api.ts b/src/shared/api.ts index b30c5ed..426e95b 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -481,6 +481,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, From 00ac5b48bf098df3bd7100cf231c6afbc262ebee Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 13:53:43 -0500 Subject: [PATCH 14/34] Better API error messages --- src/core/Cline.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index e1ba0c1..840caff 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -815,16 +815,7 @@ export class Cline { // Automatically retry with delay await this.say( "error", - `Error (${ - error.message?.toLowerCase().includes("429") || - error.message?.toLowerCase().includes("rate limit") || - error.message?.toLowerCase().includes("too many requests") || - error.message?.toLowerCase().includes("throttled") - ? "rate limit" - : error.message?.includes("500") || error.message?.includes("503") - ? "internal server error" - : "unknown" - }). ↺ Retrying in ${requestDelay} seconds...`, + `${error.message ?? "Unknown error"} ↺ Retrying in ${requestDelay} seconds...`, ) await this.say("api_req_retry_delayed") await delay(requestDelay * 1000) From 305b1342ede53d2f3e227709e3489e94ee13c329 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 16:35:45 -0500 Subject: [PATCH 15/34] Use jest-simple-dot-reporter to cut down on test noise --- jest.config.js | 3 +++ package-lock.json | 7 +++++++ package.json | 1 + 3 files changed, 11 insertions(+) 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 8c88b6c..3200254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "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", @@ -10893,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 ff3bb8a..b49d4b5 100644 --- a/package.json +++ b/package.json @@ -187,6 +187,7 @@ "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", From 352f34d8ce39b12b4645f0d223b5545758779d7d Mon Sep 17 00:00:00 2001 From: sam hoang Date: Sun, 5 Jan 2025 00:52:00 +0700 Subject: [PATCH 16/34] feat: config manager using secret store --- src/core/config/ConfigManager.ts | 153 ++++++++ .../config/__tests__/ConfigManager.test.ts | 348 ++++++++++++++++++ src/core/webview/ClineProvider.ts | 308 ++++++++++++---- src/shared/ExtensionMessage.ts | 11 +- src/shared/WebviewMessage.ts | 7 + src/shared/checkExistApiConfig.ts | 19 + .../components/settings/ApiConfigManager.tsx | 165 +++++++++ .../src/components/settings/SettingsView.tsx | 49 +++ .../settings/__tests__/SettingsView.test.tsx | 22 ++ .../src/context/ExtensionStateContext.tsx | 40 +- 10 files changed, 1026 insertions(+), 96 deletions(-) create mode 100644 src/core/config/ConfigManager.ts create mode 100644 src/core/config/__tests__/ConfigManager.test.ts create mode 100644 src/shared/checkExistApiConfig.ts create mode 100644 webview-ui/src/components/settings/ApiConfigManager.tsx diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts new file mode 100644 index 0000000..9082cf7 --- /dev/null +++ b/src/core/config/ConfigManager.ts @@ -0,0 +1,153 @@ +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 = "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() + console.log("config", config) + 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}`) + } + } + + 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..a6527ab --- /dev/null +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -0,0 +1,348 @@ +import { ExtensionContext } from 'vscode' +import { ConfigManager } from '../ConfigManager' +import { ApiConfiguration } from '../../../shared/api' +import { ApiConfigData } from '../ConfigManager' + +// 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( + '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( + '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( + '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( + '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( + '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' + ) + }) + }) +}) \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 579a12e..97d3521 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 @@ -43,6 +45,7 @@ type SecretKey = | "geminiApiKey" | "openAiNativeApiKey" | "deepSeekApiKey" + | "apiConfigPassword" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -85,6 +88,9 @@ type GlobalStateKey = | "mcpEnabled" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "currentApiConfigName" + | "listApiConfigMeta" + export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", @@ -103,6 +109,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, @@ -112,6 +119,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) } /* @@ -235,7 +243,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -255,7 +263,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -321,15 +329,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Use a nonce to only allow a specific script to be run. /* - content security policy of your webview to only allow scripts that have a specific nonce - create a content security policy meta tag so that only loading scripts with a nonce is allowed - As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. - + content security policy of your webview to only allow scripts that have a specific nonce + create a content security policy meta tag so that only loading scripts with a nonce is allowed + As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. + - 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection - since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:; - in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. - */ + in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. + */ const nonce = getNonce() // Tip: Install the es6-string-html VS Code extension to enable code highlighting below @@ -410,6 +418,33 @@ 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]) && listApiConfig[0].name === "default") { + const { + apiConfiguration, + } = await this.getState() + await this.configManager.SaveConfig("default", apiConfiguration) + listApiConfig[0].apiProvider = apiConfiguration.apiProvider + } + } + + 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 @@ -424,70 +459,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { break case "apiConfiguration": if (message.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, - azureApiVersion, - openAiStreamingEnabled, - openRouterModelId, - openRouterModelInfo, - openRouterUseMiddleOutTransform, - } = message.apiConfiguration - await this.updateGlobalState("apiProvider", apiProvider) - await this.updateGlobalState("apiModelId", apiModelId) - await this.storeSecret("apiKey", apiKey) - await this.updateGlobalState("glamaModelId", glamaModelId) - await this.updateGlobalState("glamaModelInfo", glamaModelInfo) - await this.storeSecret("glamaApiKey", glamaApiKey) - await this.storeSecret("openRouterApiKey", openRouterApiKey) - await this.storeSecret("awsAccessKey", awsAccessKey) - await this.storeSecret("awsSecretKey", awsSecretKey) - await this.storeSecret("awsSessionToken", awsSessionToken) - await this.updateGlobalState("awsRegion", awsRegion) - await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference) - await this.updateGlobalState("vertexProjectId", vertexProjectId) - await this.updateGlobalState("vertexRegion", vertexRegion) - await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl) - await this.storeSecret("openAiApiKey", openAiApiKey) - await this.updateGlobalState("openAiModelId", openAiModelId) - await this.updateGlobalState("ollamaModelId", ollamaModelId) - await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl) - await this.updateGlobalState("lmStudioModelId", lmStudioModelId) - await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl) - await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl) - await this.storeSecret("geminiApiKey", geminiApiKey) - await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey) - await this.storeSecret("deepSeekApiKey", message.apiConfiguration.deepSeekApiKey) - await this.updateGlobalState("azureApiVersion", azureApiVersion) - await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled) - await this.updateGlobalState("openRouterModelId", openRouterModelId) - await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo) - await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform) - if (this.cline) { - this.cline.api = buildApiHandler(message.apiConfiguration) - } + await this.updateApiConfiguration(message.apiConfiguration) } await this.postStateToWebview() break @@ -566,7 +538,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message?.values?.baseUrl && message?.values?.apiKey) { const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey) this.postMessageToWebview({ type: "openAiModels", openAiModels }) - } + } break case "openImage": openImage(message.text!) @@ -805,6 +777,106 @@ 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.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) { + try { + await this.configManager.DeleteConfig(message.text); + let currentApiConfigName = (await this.getGlobalState("currentApiConfigName") as string) ?? "default" + + if (message.text === currentApiConfigName) { + await this.updateGlobalState("currentApiConfigName", "default") + } + + let listApiConfig = await this.configManager.ListConfig(); + await this.updateGlobalState("listApiConfigMeta", listApiConfig) + this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + + } 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 + case "setApiConfigPassword": + if (message.text) { + try { + await this.storeSecret("apiConfigPassword", message.text !== "" ? message.text : undefined) + } catch (error) { + console.error("Error set apiKey password:", error) + vscode.window.showErrorMessage("Failed to set apiKey password") + } + } + break } }, null, @@ -812,6 +884,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) @@ -1256,8 +1396,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, + apiKeyPassword } = await this.getState() - + const allowedCommands = vscode.workspace .getConfiguration('roo-cline') .get('allowedCommands') || [] @@ -1290,6 +1433,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + currentApiConfigName: currentApiConfigName ?? "default", + listApiConfigMeta: listApiConfigMeta ?? [], + apiKeyPassword: apiKeyPassword ?? "" } } @@ -1397,6 +1543,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, + apiKeyPassword, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1449,6 +1598,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { 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.getSecret("apiConfigPassword") as Promise, ]) let apiProvider: ApiProvider @@ -1545,6 +1697,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + currentApiConfigName: currentApiConfigName ?? "default", + listApiConfigMeta: listApiConfigMeta ?? [], + apiKeyPassword: apiKeyPassword ?? "" } } @@ -1622,6 +1777,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "geminiApiKey", "openAiNativeApiKey", "deepSeekApiKey", + "apiConfigPassword" ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 6b877a0..8972958 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" @@ -23,6 +23,7 @@ export interface ExtensionMessage { | "mcpServers" | "enhancedPrompt" | "commitSearchResults" + | "listApiConfig" text?: string action?: | "chatButtonClicked" @@ -42,6 +43,12 @@ export interface ExtensionMessage { openAiModels?: string[] mcpServers?: McpServer[] commits?: GitCommit[] + listApiConfig?: ApiConfigMeta[] +} + +export interface ApiConfigMeta { + name: string + apiProvider?: ApiProvider } export interface ExtensionState { @@ -50,6 +57,8 @@ export interface ExtensionState { taskHistory: HistoryItem[] shouldShowAnnouncement: boolean apiConfiguration?: ApiConfiguration + currentApiConfigName?: string + listApiConfigMeta?: ApiConfigMeta[] customInstructions?: string alwaysAllowReadOnly?: boolean alwaysAllowWrite?: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 0ca7cb3..4072526 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -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" @@ -54,6 +60,7 @@ export interface WebviewMessage { | "searchCommits" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "setApiConfigPassword" text?: string disabled?: boolean askResponse?: ClineAskResponse 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/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx new file mode 100644 index 0000000..2464840 --- /dev/null +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -0,0 +1,165 @@ +import { VSCodeButton, VSCodeDivider, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { memo, 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 + // setDraftNewConfig: (mode: boolean) => void +} + +const ApiConfigManager = ({ + currentApiConfigName, + listApiConfigMeta, + onSelectConfig, + onDeleteConfig, + onRenameConfig, + onUpsertConfig, + // setDraftNewConfig, +}: ApiConfigManagerProps) => { + const [isNewMode, setIsNewMode] = useState(false); + const [isRenameMode, setIsRenameMode] = useState(false); + const [newConfigName, setNewConfigName] = useState(""); + const [renamedConfigName, setRenamedConfigName] = useState(""); + + const handleNewConfig = () => { + setIsNewMode(true); + setNewConfigName(""); + // setDraftNewConfig(true) + }; + + const handleSaveNewConfig = () => { + if (newConfigName.trim()) { + onUpsertConfig(newConfigName.trim()); + setIsNewMode(false); + setNewConfigName(""); + // setDraftNewConfig(false) + } + }; + + const handleCancelNewConfig = () => { + setIsNewMode(false); + setNewConfigName(""); + // setDraftNewConfig(false) + }; + + const handleStartRename = () => { + setIsRenameMode(true); + setRenamedConfigName(currentApiConfigName || ""); + }; + + const handleSaveRename = () => { + if (renamedConfigName.trim() && currentApiConfigName) { + onRenameConfig(currentApiConfigName, renamedConfigName.trim()); + setIsRenameMode(false); + setRenamedConfigName(""); + } + }; + + const handleCancelRename = () => { + setIsRenameMode(false); + setRenamedConfigName(""); + }; + + return ( +
+ +
+ {isNewMode ? ( + <> + setNewConfigName(e.target.value)} + placeholder="Enter configuration name" + style={{ flexGrow: 1 }} + /> + + Save + + + Cancel + + + ) : isRenameMode ? ( + <> + setRenamedConfigName(e.target.value)} + placeholder="Enter new name" + style={{ flexGrow: 1 }} + /> + + Save + + + Cancel + + + ) : ( + <> + + + New + + + Rename + + onDeleteConfig(currentApiConfigName!)} + > + Delete + + + )} +
+ +
+ ) +} + +export default memo(ApiConfigManager) \ No newline at end of file diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 956b76b..8940833 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -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 @@ -55,10 +56,15 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setAlwaysApproveResubmit, requestDelaySeconds, setRequestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) const [commandInput, setCommandInput] = useState("") + // const [draftNewMode, setDraftNewMode] = useState(false) + + const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) @@ -89,6 +95,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) + vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration + }) + onDone() } } @@ -150,6 +163,42 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
+
+ { + 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 + }) + }} + // setDraftNewConfig={(mode: boolean) => { + // setDraftNewMode(mode) + // }} + /> +
+

Provider Settings

({ }, })) +// Mock ApiConfigManager component +jest.mock('../ApiConfigManager', () => ({ + __esModule: true, + default: ({ currentApiConfigName, listApiConfigMeta, onSelectConfig, onDeleteConfig, onRenameConfig, onUpsertConfig }: any) => ( +
+ Current config: {currentApiConfigName} +
+ ) +})) + // 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() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 131364b..4aa874e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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 @@ -50,6 +53,8 @@ export interface ExtensionStateContextType extends ExtensionState { setAlwaysApproveResubmit: (value: boolean) => void requestDelaySeconds: number setRequestDelaySeconds: (value: number) => void + setCurrentApiConfigName: (value: string) => void + setListApiConfigMeta: (value: ApiConfigMeta[]) => void } export const ExtensionStateContext = createContext(undefined) @@ -72,7 +77,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode terminalOutputLineLimit: 500, mcpEnabled: true, alwaysApproveResubmit: false, - requestDelaySeconds: 5 + requestDelaySeconds: 5, + currentApiConfigName: 'default', + listApiConfigMeta: [], }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -88,27 +95,16 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) + + const setListApiConfigMeta = useCallback((value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [setState]) + 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 @@ -162,8 +158,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpServers(message.mcpServers ?? []) break } + case "listApiConfig": { + setListApiConfigMeta(message.listApiConfig ?? []) + break + } } - }, []) + }, [setListApiConfigMeta]) useEvent("message", handleMessage) @@ -208,7 +208,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode 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 })) + setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), + setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), + setListApiConfigMeta } return {children} From 840276b2976e5c29a4a040b2be2e8ac43c5ca224 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Sun, 5 Jan 2025 11:09:16 +0700 Subject: [PATCH 17/34] chore: remove verbose log --- src/core/config/ConfigManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 9082cf7..960babe 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -29,7 +29,6 @@ export class ConfigManager { async initConfig(): Promise { try { const config = await this.readConfig() - console.log("config", config) if (!config) { await this.writeConfig(this.defaultConfig) } From c3fa10b367f81638f848a4077c0623e62cf14fa6 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 04:21:21 -0500 Subject: [PATCH 18/34] UI cleanup --- .../src/components/chat/ChatTextArea.tsx | 62 +++- .../components/settings/ApiConfigManager.tsx | 286 +++++++++++------- .../src/components/settings/SettingsView.tsx | 8 +- 3 files changed, 235 insertions(+), 121 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 2078013..8762466 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(() => { @@ -656,6 +668,54 @@ const ChatTextArea = forwardRef( }} /> )} + {(listApiConfigMeta || []).length > 1 && ( +
+ +
+ )}
{apiConfiguration?.apiProvider === "openrouter" && ( diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 2464840..b6cf5dd 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -1,5 +1,5 @@ -import { VSCodeButton, VSCodeDivider, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { memo, useState } from "react" +import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { memo, useEffect, useRef, useState } from "react" import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage" interface ApiConfigManagerProps { @@ -9,7 +9,6 @@ interface ApiConfigManagerProps { onDeleteConfig: (configName: string) => void onRenameConfig: (oldName: string, newName: string) => void onUpsertConfig: (configName: string) => void - // setDraftNewConfig: (mode: boolean) => void } const ApiConfigManager = ({ @@ -19,145 +18,206 @@ const ApiConfigManager = ({ onDeleteConfig, onRenameConfig, onUpsertConfig, - // setDraftNewConfig, }: ApiConfigManagerProps) => { - const [isNewMode, setIsNewMode] = useState(false); - const [isRenameMode, setIsRenameMode] = useState(false); - const [newConfigName, setNewConfigName] = useState(""); - const [renamedConfigName, setRenamedConfigName] = useState(""); + const [editState, setEditState] = useState<'new' | 'rename' | null>(null); + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(); - const handleNewConfig = () => { - setIsNewMode(true); - setNewConfigName(""); - // setDraftNewConfig(true) - }; - - const handleSaveNewConfig = () => { - if (newConfigName.trim()) { - onUpsertConfig(newConfigName.trim()); - setIsNewMode(false); - setNewConfigName(""); - // setDraftNewConfig(false) + // Focus input when entering edit mode + useEffect(() => { + if (editState) { + setTimeout(() => inputRef.current?.focus(), 0); } - }; + }, [editState]); - const handleCancelNewConfig = () => { - setIsNewMode(false); - setNewConfigName(""); - // setDraftNewConfig(false) + // Reset edit state when current profile changes + useEffect(() => { + setEditState(null); + setInputValue(""); + }, [currentApiConfigName]); + + const handleStartNew = () => { + setEditState('new'); + setInputValue(""); }; const handleStartRename = () => { - setIsRenameMode(true); - setRenamedConfigName(currentApiConfigName || ""); + setEditState('rename'); + setInputValue(currentApiConfigName || ""); }; - const handleSaveRename = () => { - if (renamedConfigName.trim() && currentApiConfigName) { - onRenameConfig(currentApiConfigName, renamedConfigName.trim()); - setIsRenameMode(false); - setRenamedConfigName(""); + 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 handleCancelRename = () => { - setIsRenameMode(false); - setRenamedConfigName(""); + 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 ( -
- -
- {isNewMode ? ( - <> +
+
+ + + {editState ? ( +
setNewConfigName(e.target.value)} - placeholder="Enter configuration name" + 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(); + } + }} /> - Save + - Cancel + - - ) : isRenameMode ? ( - <> - setRenamedConfigName(e.target.value)} - placeholder="Enter new name" - style={{ flexGrow: 1 }} - /> - - Save - - - Cancel - - +
) : ( <> - - - New - - - Rename - - onDeleteConfig(currentApiConfigName!)} - > - Delete - +
+ + + + + {currentApiConfigName && ( + <> + + + + + + + + )} +
+

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

)}
-
) } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 8940833..6a0883c 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -164,6 +164,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
+

Provider Settings

{ apiConfiguration }) }} - // setDraftNewConfig={(mode: boolean) => { - // setDraftNewMode(mode) - // }} /> -
- -
-

Provider Settings

Date: Tue, 7 Jan 2025 20:16:44 +0700 Subject: [PATCH 19/34] fix: change provider not update without done, update chatbox change provider from MrUbens --- src/core/config/ConfigManager.ts | 12 +++ .../config/__tests__/ConfigManager.test.ts | 40 +++++++++- src/core/webview/ClineProvider.ts | 76 +++++++++++++------ .../components/settings/ApiConfigManager.tsx | 4 +- .../src/components/settings/ApiOptions.tsx | 8 +- .../src/components/settings/SettingsView.tsx | 12 ++- .../src/components/welcome/WelcomeView.tsx | 2 +- 7 files changed, 121 insertions(+), 33 deletions(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 960babe..562ecb3 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -125,6 +125,18 @@ export class ConfigManager { } } + /** + * 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` diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts index a6527ab..f185ede 100644 --- a/src/core/config/__tests__/ConfigManager.test.ts +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -1,7 +1,6 @@ import { ExtensionContext } from 'vscode' -import { ConfigManager } from '../ConfigManager' +import { ConfigManager, ApiConfigData } from '../ConfigManager' import { ApiConfiguration } from '../../../shared/api' -import { ApiConfigData } from '../ConfigManager' // Mock VSCode ExtensionContext const mockSecrets = { @@ -345,4 +344,41 @@ describe('ConfigManager', () => { ) }) }) + + 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 97d3521..94b8ea5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -45,7 +45,6 @@ type SecretKey = | "geminiApiKey" | "openAiNativeApiKey" | "deepSeekApiKey" - | "apiConfigPassword" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -428,15 +427,37 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (listApiConfig.length === 1) { // check if first time init then sync with exist config - if (!checkExistKey(listApiConfig[0]) && listApiConfig[0].name === "default") { + if (!checkExistKey(listApiConfig[0])) { const { apiConfiguration, } = await this.getState() - await this.configManager.SaveConfig("default", apiConfiguration) + 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), @@ -785,6 +806,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { let listApiConfig = await this.configManager.ListConfig(); await Promise.all([ + this.updateApiConfiguration(message.apiConfiguration), this.updateGlobalState("currentApiConfigName", message.text), this.updateGlobalState("listApiConfigMeta", listApiConfig), ]) @@ -800,7 +822,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message.values && message.apiConfiguration) { try { - const {oldName, newName} = message.values + const { oldName, newName } = message.values await this.configManager.SaveConfig(newName, message.apiConfiguration); @@ -839,17 +861,37 @@ export class ClineProvider implements vscode.WebviewViewProvider { break case "deleteApiConfiguration": if (message.text) { + + const answer = await vscode.window.showInformationMessage( + "What would you like to delete this api config?", + { modal: true }, + "Yes", + "No", + ) + + if (answer === "No" || answer === undefined) { + break + } + try { await this.configManager.DeleteConfig(message.text); - let currentApiConfigName = (await this.getGlobalState("currentApiConfigName") as string) ?? "default" + let listApiConfig = await this.configManager.ListConfig() + let currentApiConfigName = await this.getGlobalState("currentApiConfigName") if (message.text === currentApiConfigName) { - await this.updateGlobalState("currentApiConfigName", "default") + 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() + } } - let listApiConfig = await this.configManager.ListConfig(); - await this.updateGlobalState("listApiConfigMeta", listApiConfig) - this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + // this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) } catch (error) { console.error("Error delete api configuration:", error) @@ -867,16 +909,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { vscode.window.showErrorMessage("Failed to get list api configuration") } break - case "setApiConfigPassword": - if (message.text) { - try { - await this.storeSecret("apiConfigPassword", message.text !== "" ? message.text : undefined) - } catch (error) { - console.error("Error set apiKey password:", error) - vscode.window.showErrorMessage("Failed to set apiKey password") - } - } - break } }, null, @@ -1398,7 +1430,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds, currentApiConfigName, listApiConfigMeta, - apiKeyPassword } = await this.getState() const allowedCommands = vscode.workspace @@ -1435,7 +1466,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds: requestDelaySeconds ?? 5, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], - apiKeyPassword: apiKeyPassword ?? "" } } @@ -1545,7 +1575,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds, currentApiConfigName, listApiConfigMeta, - apiKeyPassword, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1600,7 +1629,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("requestDelaySeconds") as Promise, this.getGlobalState("currentApiConfigName") as Promise, this.getGlobalState("listApiConfigMeta") as Promise, - this.getSecret("apiConfigPassword") as Promise, ]) let apiProvider: ApiProvider @@ -1699,7 +1727,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds: requestDelaySeconds ?? 5, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], - apiKeyPassword: apiKeyPassword ?? "" } } @@ -1777,7 +1804,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { "geminiApiKey", "openAiNativeApiKey", "deepSeekApiKey", - "apiConfigPassword" ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index b6cf5dd..e69865d 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -12,8 +12,8 @@ interface ApiConfigManagerProps { } const ApiConfigManager = ({ - currentApiConfigName, - listApiConfigMeta, + currentApiConfigName = "", + listApiConfigMeta = [], onSelectConfig, onDeleteConfig, onRenameConfig, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index f38a0a3..3ddb55f 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -46,9 +46,10 @@ interface ApiOptionsProps { showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string + onSelectProvider: (apiProvider: any) => void } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { +const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, onSelectProvider }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) @@ -130,7 +131,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: { + onSelectProvider(event.target.value); + handleInputChange("apiProvider")(event); + }} style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 6a0883c..0567d92 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -183,7 +183,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { onRenameConfig={(oldName: string, newName: string) => { vscode.postMessage({ type: "renameApiConfiguration", - values: {oldName, newName}, + values: { oldName, newName }, apiConfiguration }) }} @@ -199,6 +199,16 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { showModelOptions={true} apiErrorMessage={apiErrorMessage} modelIdErrorMessage={modelIdErrorMessage} + onSelectProvider={(apiProvider: any) => { + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { + ...apiConfiguration, + apiProvider: apiProvider, + } + }) + }} />
diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index ef15a4e..fdaedb9 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -38,7 +38,7 @@ const WelcomeView = () => { To get started, this extension needs an API provider for Claude 3.5 Sonnet.
- + {}} /> Let's go! From 20322af5df0bffcaa8b13d7abd1f4181dc3f98d8 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 09:28:45 -0500 Subject: [PATCH 20/34] Delete confirmation tweak --- src/core/webview/ClineProvider.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 94b8ea5..2e64379 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -863,13 +863,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message.text) { const answer = await vscode.window.showInformationMessage( - "What would you like to delete this api config?", + "Are you sure you want to delete this configuration profile?", { modal: true }, "Yes", - "No", ) - if (answer === "No" || answer === undefined) { + if (answer !== "Yes") { break } From 921f8844ebb9455dc3a677b0024228c02c29719d Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 8 Jan 2025 00:29:52 +0700 Subject: [PATCH 21/34] fix: config manager not update when model, key, another optionn... --- webview-ui/src/components/settings/ApiOptions.tsx | 14 ++++++-------- .../src/components/settings/GlamaModelPicker.tsx | 9 ++++++--- .../src/components/settings/OpenAiModelPicker.tsx | 9 ++++++--- .../components/settings/OpenRouterModelPicker.tsx | 9 ++++++--- .../src/components/settings/SettingsView.tsx | 10 ---------- webview-ui/src/components/welcome/WelcomeView.tsx | 2 +- webview-ui/src/context/ExtensionStateContext.tsx | 12 +++++++++++- 7 files changed, 36 insertions(+), 29 deletions(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 3ddb55f..ff1535d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -46,11 +46,10 @@ interface ApiOptionsProps { showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string - onSelectProvider: (apiProvider: any) => void } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, onSelectProvider }: ApiOptionsProps) => { - const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() +const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { + const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) @@ -58,7 +57,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, on 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(() => { @@ -131,10 +132,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, on { - onSelectProvider(event.target.value); - handleInputChange("apiProvider")(event); - }} + onChange={handleInputChange("apiProvider")} style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index 6823cc0..1b6164d 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -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) } diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 31cbddc..7979244 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -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,10 +18,13 @@ 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) } diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index bd4efd8..10086e7 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -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) } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 0567d92..2d2a04e 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -199,16 +199,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { showModelOptions={true} apiErrorMessage={apiErrorMessage} modelIdErrorMessage={modelIdErrorMessage} - onSelectProvider={(apiProvider: any) => { - vscode.postMessage({ - type: "upsertApiConfiguration", - text: currentApiConfigName, - apiConfiguration: { - ...apiConfiguration, - apiProvider: apiProvider, - } - }) - }} />
diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index fdaedb9..ef15a4e 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -38,7 +38,7 @@ const WelcomeView = () => { To get started, this extension needs an API provider for Claude 3.5 Sonnet.
- {}} /> + Let's go! diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 4aa874e..48a0757 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -55,6 +55,7 @@ export interface ExtensionStateContextType extends ExtensionState { setRequestDelaySeconds: (value: number) => void setCurrentApiConfigName: (value: string) => void setListApiConfigMeta: (value: ApiConfigMeta[]) => void + onUpdateApiConfig: (apiConfig: ApiConfiguration) => void } export const ExtensionStateContext = createContext(undefined) @@ -98,6 +99,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode 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) { @@ -210,7 +219,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), - setListApiConfigMeta + setListApiConfigMeta, + onUpdateApiConfig } return {children} From f39eaa14ffb774259d2bf1e8129ffc7cb05cf70b Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 8 Jan 2025 07:53:56 +0700 Subject: [PATCH 22/34] fix: sync model picker search terms with selected models Added useEffect hooks to GlamaModelPicker, OpenAiModelPicker, and OpenRouterModelPicker components to ensure the search term stays synchronized with the selected model ID from apiConfiguration. This prevents the search term from getting out of sync when the model is changed. --- webview-ui/src/components/settings/GlamaModelPicker.tsx | 9 +++++++++ webview-ui/src/components/settings/OpenAiModelPicker.tsx | 8 ++++++++ .../src/components/settings/OpenRouterModelPicker.tsx | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index 1b6164d..2df9984 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -37,6 +37,15 @@ const GlamaModelPicker: React.FC = () => { return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) + + useEffect(() => { + if (apiConfiguration?.glamaModelId) { + if (apiConfiguration?.glamaModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.glamaModelId) + } + } + }, [apiConfiguration, searchTerm]) + useMount(() => { vscode.postMessage({ type: "refreshGlamaModels" }) }) diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 7979244..cb8a6a4 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -28,6 +28,14 @@ const OpenAiModelPicker: React.FC = () => { setSearchTerm(newModelId) } + useEffect(() => { + if (apiConfiguration?.openAiModelId) { + if (apiConfiguration?.openAiModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.openAiModelId) + } + } + }, [apiConfiguration, searchTerm]) + useEffect(() => { if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) { return diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index 10086e7..df2883c 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -37,6 +37,14 @@ const OpenRouterModelPicker: React.FC = () => { return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) + useEffect(() => { + if (apiConfiguration?.openRouterModelId) { + if (apiConfiguration?.openRouterModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.openRouterModelId) + } + } + }, [apiConfiguration, searchTerm]) + useMount(() => { vscode.postMessage({ type: "refreshOpenRouterModels" }) }) From bb774e17eb8ce1c11f73f30361f330037973ff1c Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 20:14:12 -0500 Subject: [PATCH 23/34] Release --- .changeset/shiny-seahorses-peel.md | 5 +++++ README.md | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/shiny-seahorses-peel.md diff --git a/.changeset/shiny-seahorses-peel.md b/.changeset/shiny-seahorses-peel.md new file mode 100644 index 0000000..60f9108 --- /dev/null +++ b/.changeset/shiny-seahorses-peel.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Save different API configurations to quickly switch between providers and settings diff --git a/README.md b/README.md index d067902..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 From 2cffbc860bf1f28253a46bcd2c94d7d4d7bc9200 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 20:30:23 -0500 Subject: [PATCH 24/34] Tweak thumbnail display --- webview-ui/src/components/chat/ChatTextArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 8762466..ec94cba 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -661,7 +661,7 @@ const ChatTextArea = forwardRef( style={{ position: "absolute", paddingTop: 4, - bottom: 14, + bottom: 32, left: 22, right: 67, zIndex: 2, From 525b7424fe1fa38098419cddac4bef132d2f7823 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 22:30:21 -0500 Subject: [PATCH 25/34] Cleanup the welcome screen to be less Claude focused --- src/core/config/ConfigManager.ts | 2 +- src/core/config/__tests__/ConfigManager.test.ts | 10 +++++----- webview-ui/src/components/chat/ChatView.tsx | 9 ++------- webview-ui/src/components/settings/ApiOptions.tsx | 10 ++++------ webview-ui/src/components/settings/SettingsView.tsx | 3 --- webview-ui/src/components/welcome/WelcomeView.tsx | 13 ++++--------- 6 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 562ecb3..7e4393d 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -16,7 +16,7 @@ export class ConfigManager { default: {} } } - private readonly SCOPE_PREFIX = "cline_config_" + private readonly SCOPE_PREFIX = "roo_cline_config_" private readonly context: ExtensionContext constructor(context: ExtensionContext) { diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts index f185ede..b8170ee 100644 --- a/src/core/config/__tests__/ConfigManager.test.ts +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -121,7 +121,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -154,7 +154,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -196,7 +196,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -256,7 +256,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -314,7 +314,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) 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/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index ff1535d..cc30ae9 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -43,12 +43,11 @@ import OpenAiModelPicker from "./OpenAiModelPicker" import GlamaModelPicker from "./GlamaModelPicker" interface ApiOptionsProps { - showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { +const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) @@ -695,16 +694,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" && ( <>