From 4c292abf7a0682a33e2a59f66384d7724845d798 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 13 Jan 2025 13:06:03 -0500 Subject: [PATCH 01/15] Fix margins --- webview-ui/src/components/chat/ChatTextArea.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 4e102c6..b612b97 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -652,6 +652,7 @@ const ChatTextArea = forwardRef( borderBottom: `${thumbnailsHeight + 6}px solid transparent`, borderColor: "transparent", padding: "9px 9px 25px 9px", + marginBottom: "15px", cursor: textAreaDisabled ? "not-allowed" : undefined, flex: 1, zIndex: 1, From 2d176e5c92278a849dd3baae0218f3976104f9a7 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 12 Jan 2025 12:08:24 -0500 Subject: [PATCH 02/15] Reduce the webview test output --- webview-ui/config-overrides.js | 22 +++++++++++++++ webview-ui/package-lock.json | 50 +++++++++++++++++++++++++++++++++- webview-ui/package.json | 17 ++++-------- 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 webview-ui/config-overrides.js diff --git a/webview-ui/config-overrides.js b/webview-ui/config-overrides.js new file mode 100644 index 0000000..2078f36 --- /dev/null +++ b/webview-ui/config-overrides.js @@ -0,0 +1,22 @@ +const { override } = require('customize-cra'); + +module.exports = override(); + +// Jest configuration override +module.exports.jest = function(config) { + // Configure reporters + config.reporters = [["jest-simple-dot-reporter", {}]]; + + // Configure module name mapper for CSS modules + config.moduleNameMapper = { + ...config.moduleNameMapper, + "\\.(css|less|scss|sass)$": "identity-obj-proxy" + }; + + // Configure transform ignore patterns for ES modules + config.transformIgnorePatterns = [ + '/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|unist-util-find-after|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text|@vscode/webview-ui-toolkit|@microsoft/fast-react-wrapper|@microsoft/fast-element|@microsoft/fast-foundation|@microsoft/fast-web-utilities|exenv-es6)/)' + ]; + + return config; +} \ No newline at end of file diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index cdaf3b4..7c0edd7 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -37,7 +37,10 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@types/shell-quote": "^1.7.5", "@types/vscode-webview": "^1.57.5", - "eslint": "^8.57.0" + "customize-cra": "^1.0.0", + "eslint": "^8.57.0", + "jest-simple-dot-reporter": "^1.0.5", + "react-app-rewired": "^2.2.1" } }, "node_modules/@adobe/css-tools": { @@ -5624,6 +5627,15 @@ "version": "3.1.3", "license": "MIT" }, + "node_modules/customize-cra": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/customize-cra/-/customize-cra-1.0.0.tgz", + "integrity": "sha512-DbtaLuy59224U+xCiukkxSq8clq++MOtJ1Et7LED1fLszWe88EoblEYFBJ895sB1mC6B4uu3xPT/IjClELhMbA==", + "dev": true, + "dependencies": { + "lodash.flow": "^3.5.0" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "license": "BSD-2-Clause" @@ -9257,6 +9269,12 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "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": "27.5.1", "license": "MIT", @@ -9896,6 +9914,12 @@ "version": "4.0.8", "license": "MIT" }, + "node_modules/lodash.flow": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", + "integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==", + "dev": true + }, "node_modules/lodash.memoize": { "version": "4.1.2", "license": "MIT" @@ -12269,6 +12293,30 @@ "version": "0.13.11", "license": "MIT" }, + "node_modules/react-app-rewired": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.2.1.tgz", + "integrity": "sha512-uFQWTErXeLDrMzOJHKp0h8P1z0LV9HzPGsJ6adOtGlA/B9WfT6Shh4j2tLTTGlXOfiVx6w6iWpp7SOC5pvk+gA==", + "dev": true, + "dependencies": { + "semver": "^5.6.0" + }, + "bin": { + "react-app-rewired": "bin/index.js" + }, + "peerDependencies": { + "react-scripts": ">=2.1.3" + } + }, + "node_modules/react-app-rewired/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "license": "MIT", diff --git a/webview-ui/package.json b/webview-ui/package.json index 3ce9533..ca804ba 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -29,9 +29,9 @@ "web-vitals": "^2.1.4" }, "scripts": { - "start": "react-scripts start", + "start": "react-app-rewired start", "build": "node ./scripts/build-react-no-split.js", - "test": "react-scripts test --watchAll=false", + "test": "react-app-rewired test --watchAll=false", "eject": "react-scripts eject", "lint": "eslint src --ext ts,tsx" }, @@ -57,14 +57,9 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@types/shell-quote": "^1.7.5", "@types/vscode-webview": "^1.57.5", - "eslint": "^8.57.0" - }, - "jest": { - "transformIgnorePatterns": [ - "/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|unist-util-find-after|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text|@vscode/webview-ui-toolkit|@microsoft/fast-react-wrapper|@microsoft/fast-element|@microsoft/fast-foundation|@microsoft/fast-web-utilities|exenv-es6)/)" - ], - "moduleNameMapper": { - "\\.(css|less|scss|sass)$": "identity-obj-proxy" - } + "customize-cra": "^1.0.0", + "eslint": "^8.57.0", + "jest-simple-dot-reporter": "^1.0.5", + "react-app-rewired": "^2.2.1" } } From 4027e1c10c92dd3362a2afee1fd00db177f5053a Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 13 Jan 2025 16:16:58 -0500 Subject: [PATCH 03/15] Add non-streaming completePrompt to all providers --- src/api/providers/__tests__/anthropic.test.ts | 73 +++- src/api/providers/__tests__/bedrock.test.ts | 102 +++++ src/api/providers/__tests__/gemini.test.ts | 60 ++- src/api/providers/__tests__/glama.test.ts | 226 ++++++++++ src/api/providers/__tests__/lmstudio.test.ts | 212 ++++----- src/api/providers/__tests__/ollama.test.ts | 208 ++++----- .../providers/__tests__/openai-native.test.ts | 407 +++++++++--------- src/api/providers/__tests__/openai.test.ts | 26 ++ src/api/providers/__tests__/vertex.test.ts | 80 +++- src/api/providers/anthropic.ts | 27 +- src/api/providers/bedrock.ts | 73 +++- src/api/providers/gemini.ts | 26 +- src/api/providers/glama.ts | 26 +- src/api/providers/lmstudio.ts | 20 +- src/api/providers/ollama.ts | 21 +- src/api/providers/openai-native.ts | 37 +- src/api/providers/openai.ts | 22 +- src/api/providers/vertex.ts | 27 +- 18 files changed, 1235 insertions(+), 438 deletions(-) create mode 100644 src/api/providers/__tests__/glama.test.ts diff --git a/src/api/providers/__tests__/anthropic.test.ts b/src/api/providers/__tests__/anthropic.test.ts index f730f78..d0357d7 100644 --- a/src/api/providers/__tests__/anthropic.test.ts +++ b/src/api/providers/__tests__/anthropic.test.ts @@ -46,7 +46,42 @@ jest.mock('@anthropic-ai/sdk', () => { } }, messages: { - create: mockCreate + create: mockCreate.mockImplementation(async (options) => { + if (!options.stream) { + return { + id: 'test-completion', + content: [ + { type: 'text', text: 'Test response' } + ], + role: 'assistant', + model: options.model, + usage: { + input_tokens: 10, + output_tokens: 5 + } + } + } + return { + async *[Symbol.asyncIterator]() { + yield { + type: 'message_start', + message: { + usage: { + input_tokens: 10, + output_tokens: 5 + } + } + } + yield { + type: 'content_block_start', + content_block: { + type: 'text', + text: 'Test response' + } + } + } + } + }) } })) }; @@ -144,6 +179,42 @@ describe('AnthropicHandler', () => { }); }); + describe('completePrompt', () => { + it('should complete prompt successfully', async () => { + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockCreate).toHaveBeenCalledWith({ + model: mockOptions.apiModelId, + messages: [{ role: 'user', content: 'Test prompt' }], + max_tokens: 8192, + temperature: 0, + stream: false + }); + }); + + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('Anthropic completion error: API Error'); + }); + + it('should handle non-text content', async () => { + mockCreate.mockImplementationOnce(async () => ({ + content: [{ type: 'image' }] + })); + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + + it('should handle empty response', async () => { + mockCreate.mockImplementationOnce(async () => ({ + content: [{ type: 'text', text: '' }] + })); + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + }); + describe('getModel', () => { it('should return default model if no model ID is provided', () => { const handlerWithoutModel = new AnthropicHandler({ diff --git a/src/api/providers/__tests__/bedrock.test.ts b/src/api/providers/__tests__/bedrock.test.ts index 36cccc1..e8e3f3a 100644 --- a/src/api/providers/__tests__/bedrock.test.ts +++ b/src/api/providers/__tests__/bedrock.test.ts @@ -119,6 +119,108 @@ describe('AwsBedrockHandler', () => { }); }); + describe('completePrompt', () => { + it('should complete prompt successfully', async () => { + const mockResponse = { + output: new TextEncoder().encode(JSON.stringify({ + content: 'Test response' + })) + }; + + const mockSend = jest.fn().mockResolvedValue(mockResponse); + handler['client'] = { + send: mockSend + } as unknown as BedrockRuntimeClient; + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + modelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: [{ text: 'Test prompt' }] + }) + ]), + inferenceConfig: expect.objectContaining({ + maxTokens: 5000, + temperature: 0.3, + topP: 0.1 + }) + }) + })); + }); + + it('should handle API errors', async () => { + const mockError = new Error('AWS Bedrock error'); + const mockSend = jest.fn().mockRejectedValue(mockError); + handler['client'] = { + send: mockSend + } as unknown as BedrockRuntimeClient; + + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('Bedrock completion error: AWS Bedrock error'); + }); + + it('should handle invalid response format', async () => { + const mockResponse = { + output: new TextEncoder().encode('invalid json') + }; + + const mockSend = jest.fn().mockResolvedValue(mockResponse); + handler['client'] = { + send: mockSend + } as unknown as BedrockRuntimeClient; + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + + it('should handle empty response', async () => { + const mockResponse = { + output: new TextEncoder().encode(JSON.stringify({})) + }; + + const mockSend = jest.fn().mockResolvedValue(mockResponse); + handler['client'] = { + send: mockSend + } as unknown as BedrockRuntimeClient; + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + + it('should handle cross-region inference', async () => { + handler = new AwsBedrockHandler({ + apiModelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + awsAccessKey: 'test-access-key', + awsSecretKey: 'test-secret-key', + awsRegion: 'us-east-1', + awsUseCrossRegionInference: true + }); + + const mockResponse = { + output: new TextEncoder().encode(JSON.stringify({ + content: 'Test response' + })) + }; + + const mockSend = jest.fn().mockResolvedValue(mockResponse); + handler['client'] = { + send: mockSend + } as unknown as BedrockRuntimeClient; + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + modelId: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0' + }) + })); + }); + }); + describe('getModel', () => { it('should return correct model info in test environment', () => { const modelInfo = handler.getModel(); diff --git a/src/api/providers/__tests__/gemini.test.ts b/src/api/providers/__tests__/gemini.test.ts index b979714..a59028e 100644 --- a/src/api/providers/__tests__/gemini.test.ts +++ b/src/api/providers/__tests__/gemini.test.ts @@ -6,7 +6,12 @@ import { GoogleGenerativeAI } from '@google/generative-ai'; jest.mock('@google/generative-ai', () => ({ GoogleGenerativeAI: jest.fn().mockImplementation(() => ({ getGenerativeModel: jest.fn().mockReturnValue({ - generateContentStream: jest.fn() + generateContentStream: jest.fn(), + generateContent: jest.fn().mockResolvedValue({ + response: { + text: () => 'Test response' + } + }) }) })) })); @@ -133,6 +138,59 @@ describe('GeminiHandler', () => { }); }); + describe('completePrompt', () => { + it('should complete prompt successfully', async () => { + const mockGenerateContent = jest.fn().mockResolvedValue({ + response: { + text: () => 'Test response' + } + }); + const mockGetGenerativeModel = jest.fn().mockReturnValue({ + generateContent: mockGenerateContent + }); + (handler['client'] as any).getGenerativeModel = mockGetGenerativeModel; + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockGetGenerativeModel).toHaveBeenCalledWith({ + model: 'gemini-2.0-flash-thinking-exp-1219' + }); + expect(mockGenerateContent).toHaveBeenCalledWith({ + contents: [{ role: 'user', parts: [{ text: 'Test prompt' }] }], + generationConfig: { + temperature: 0 + } + }); + }); + + it('should handle API errors', async () => { + const mockError = new Error('Gemini API error'); + const mockGenerateContent = jest.fn().mockRejectedValue(mockError); + const mockGetGenerativeModel = jest.fn().mockReturnValue({ + generateContent: mockGenerateContent + }); + (handler['client'] as any).getGenerativeModel = mockGetGenerativeModel; + + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('Gemini completion error: Gemini API error'); + }); + + it('should handle empty response', async () => { + const mockGenerateContent = jest.fn().mockResolvedValue({ + response: { + text: () => '' + } + }); + const mockGetGenerativeModel = jest.fn().mockReturnValue({ + generateContent: mockGenerateContent + }); + (handler['client'] as any).getGenerativeModel = mockGetGenerativeModel; + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + }); + describe('getModel', () => { it('should return correct model info', () => { const modelInfo = handler.getModel(); diff --git a/src/api/providers/__tests__/glama.test.ts b/src/api/providers/__tests__/glama.test.ts new file mode 100644 index 0000000..e67b80e --- /dev/null +++ b/src/api/providers/__tests__/glama.test.ts @@ -0,0 +1,226 @@ +import { GlamaHandler } from '../glama'; +import { ApiHandlerOptions } from '../../../shared/api'; +import OpenAI from 'openai'; +import { Anthropic } from '@anthropic-ai/sdk'; +import axios from 'axios'; + +// Mock OpenAI client +const mockCreate = jest.fn(); +const mockWithResponse = jest.fn(); + +jest.mock('openai', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: (...args: any[]) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ + delta: { content: 'Test response' }, + index: 0 + }], + usage: null + }; + yield { + choices: [{ + delta: {}, + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + }; + + const result = mockCreate(...args); + if (args[0].stream) { + mockWithResponse.mockReturnValue(Promise.resolve({ + data: stream, + response: { + headers: { + get: (name: string) => name === 'x-completion-request-id' ? 'test-request-id' : null + } + } + })); + result.withResponse = mockWithResponse; + } + return result; + } + } + } + })) + }; +}); + +describe('GlamaHandler', () => { + let handler: GlamaHandler; + let mockOptions: ApiHandlerOptions; + + beforeEach(() => { + mockOptions = { + apiModelId: 'anthropic/claude-3-5-sonnet', + glamaModelId: 'anthropic/claude-3-5-sonnet', + glamaApiKey: 'test-api-key' + }; + handler = new GlamaHandler(mockOptions); + mockCreate.mockClear(); + mockWithResponse.mockClear(); + + // Default mock implementation for non-streaming responses + mockCreate.mockResolvedValue({ + id: 'test-completion', + choices: [{ + message: { role: 'assistant', content: 'Test response' }, + finish_reason: 'stop', + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }); + }); + + describe('constructor', () => { + it('should initialize with provided options', () => { + expect(handler).toBeInstanceOf(GlamaHandler); + expect(handler.getModel().id).toBe(mockOptions.apiModelId); + }); + }); + + describe('createMessage', () => { + const systemPrompt = 'You are a helpful assistant.'; + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: 'Hello!' + } + ]; + + it('should handle streaming responses', async () => { + // Mock axios for token usage request + const mockAxios = jest.spyOn(axios, 'get').mockResolvedValueOnce({ + data: { + tokenUsage: { + promptTokens: 10, + completionTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0 + }, + totalCostUsd: "0.00" + } + }); + + const stream = handler.createMessage(systemPrompt, messages); + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(2); // Text chunk and usage chunk + expect(chunks[0]).toEqual({ + type: 'text', + text: 'Test response' + }); + expect(chunks[1]).toEqual({ + type: 'usage', + inputTokens: 10, + outputTokens: 5, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalCost: 0 + }); + + mockAxios.mockRestore(); + }); + + it('should handle API errors', async () => { + mockCreate.mockImplementationOnce(() => { + throw new Error('API Error'); + }); + + const stream = handler.createMessage(systemPrompt, messages); + const chunks = []; + + try { + for await (const chunk of stream) { + chunks.push(chunk); + } + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('API Error'); + } + }); + }); + + describe('completePrompt', () => { + it('should complete prompt successfully', async () => { + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ + model: mockOptions.apiModelId, + messages: [{ role: 'user', content: 'Test prompt' }], + temperature: 0, + max_tokens: 8192 + })); + }); + + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('Glama completion error: API Error'); + }); + + it('should handle empty response', async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: '' } }] + }); + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + + it('should not set max_tokens for non-Anthropic models', async () => { + // Reset mock to clear any previous calls + mockCreate.mockClear(); + + const nonAnthropicOptions = { + apiModelId: 'openai/gpt-4', + glamaModelId: 'openai/gpt-4', + glamaApiKey: 'test-key', + glamaModelInfo: { + maxTokens: 4096, + contextWindow: 8192, + supportsImages: true, + supportsPromptCache: false + } + }; + const nonAnthropicHandler = new GlamaHandler(nonAnthropicOptions); + + await nonAnthropicHandler.completePrompt('Test prompt'); + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ + model: 'openai/gpt-4', + messages: [{ role: 'user', content: 'Test prompt' }], + temperature: 0 + })); + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('max_tokens'); + }); + }); + + describe('getModel', () => { + it('should return model info', () => { + const modelInfo = handler.getModel(); + expect(modelInfo.id).toBe(mockOptions.apiModelId); + expect(modelInfo.info).toBeDefined(); + expect(modelInfo.info.maxTokens).toBe(8192); + expect(modelInfo.info.contextWindow).toBe(200_000); + }); + }); +}); \ No newline at end of file diff --git a/src/api/providers/__tests__/lmstudio.test.ts b/src/api/providers/__tests__/lmstudio.test.ts index 9e24053..6b84796 100644 --- a/src/api/providers/__tests__/lmstudio.test.ts +++ b/src/api/providers/__tests__/lmstudio.test.ts @@ -1,148 +1,160 @@ import { LmStudioHandler } from '../lmstudio'; -import { Anthropic } from '@anthropic-ai/sdk'; +import { ApiHandlerOptions } from '../../../shared/api'; import OpenAI from 'openai'; +import { Anthropic } from '@anthropic-ai/sdk'; -// Mock OpenAI SDK -jest.mock('openai', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: jest.fn() +// Mock OpenAI client +const mockCreate = jest.fn(); +jest.mock('openai', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockCreate.mockImplementation(async (options) => { + if (!options.stream) { + return { + id: 'test-completion', + choices: [{ + message: { role: 'assistant', content: 'Test response' }, + finish_reason: 'stop', + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ + delta: { content: 'Test response' }, + index: 0 + }], + usage: null + }; + yield { + choices: [{ + delta: {}, + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + }; + }) + } } - } - })) -})); + })) + }; +}); describe('LmStudioHandler', () => { let handler: LmStudioHandler; + let mockOptions: ApiHandlerOptions; beforeEach(() => { - handler = new LmStudioHandler({ - lmStudioModelId: 'mistral-7b', - lmStudioBaseUrl: 'http://localhost:1234' - }); + mockOptions = { + apiModelId: 'local-model', + lmStudioModelId: 'local-model', + lmStudioBaseUrl: 'http://localhost:1234/v1' + }; + handler = new LmStudioHandler(mockOptions); + mockCreate.mockClear(); }); describe('constructor', () => { - it('should initialize with provided config', () => { - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: 'http://localhost:1234/v1', - apiKey: 'noop' - }); + it('should initialize with provided options', () => { + expect(handler).toBeInstanceOf(LmStudioHandler); + expect(handler.getModel().id).toBe(mockOptions.lmStudioModelId); }); it('should use default base URL if not provided', () => { - const defaultHandler = new LmStudioHandler({ - lmStudioModelId: 'mistral-7b' - }); - - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: 'http://localhost:1234/v1', - apiKey: 'noop' + const handlerWithoutUrl = new LmStudioHandler({ + apiModelId: 'local-model', + lmStudioModelId: 'local-model' }); + expect(handlerWithoutUrl).toBeInstanceOf(LmStudioHandler); }); }); describe('createMessage', () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ + const systemPrompt = 'You are a helpful assistant.'; + const messages: Anthropic.Messages.MessageParam[] = [ { role: 'user', - content: 'Hello' - }, - { - role: 'assistant', - content: 'Hi there!' + content: 'Hello!' } ]; - const systemPrompt = 'You are a helpful assistant'; - - it('should handle streaming responses correctly', async () => { - const mockStream = [ - { - choices: [{ - delta: { content: 'Hello' } - }] - }, - { - choices: [{ - delta: { content: ' world!' } - }] - } - ]; - - // Setup async iterator for mock stream - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - } - }; - - const mockCreate = jest.fn().mockResolvedValue(asyncIterator); - (handler['client'].chat.completions as any).create = mockCreate; - - const stream = handler.createMessage(systemPrompt, mockMessages); - const chunks = []; - + it('should handle streaming responses', async () => { + const stream = handler.createMessage(systemPrompt, messages); + const chunks: any[] = []; for await (const chunk of stream) { chunks.push(chunk); } - expect(chunks.length).toBe(2); - expect(chunks[0]).toEqual({ - type: 'text', - text: 'Hello' - }); - expect(chunks[1]).toEqual({ - type: 'text', - text: ' world!' - }); - - expect(mockCreate).toHaveBeenCalledWith({ - model: 'mistral-7b', - messages: expect.arrayContaining([ - { - role: 'system', - content: systemPrompt - } - ]), - temperature: 0, - stream: true - }); + expect(chunks.length).toBeGreaterThan(0); + const textChunks = chunks.filter(chunk => chunk.type === 'text'); + expect(textChunks).toHaveLength(1); + expect(textChunks[0].text).toBe('Test response'); }); - it('should handle API errors with custom message', async () => { - const mockError = new Error('LM Studio API error'); - const mockCreate = jest.fn().mockRejectedValue(mockError); - (handler['client'].chat.completions as any).create = mockCreate; + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); - const stream = handler.createMessage(systemPrompt, mockMessages); + const stream = handler.createMessage(systemPrompt, messages); await expect(async () => { for await (const chunk of stream) { - // Should throw before yielding any chunks + // Should not reach here } }).rejects.toThrow('Please check the LM Studio developer logs to debug what went wrong'); }); }); + describe('completePrompt', () => { + it('should complete prompt successfully', async () => { + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockCreate).toHaveBeenCalledWith({ + model: mockOptions.lmStudioModelId, + messages: [{ role: 'user', content: 'Test prompt' }], + temperature: 0, + stream: false + }); + }); + + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('Please check the LM Studio developer logs to debug what went wrong'); + }); + + it('should handle empty response', async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: '' } }] + }); + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + }); + describe('getModel', () => { - it('should return model info with sane defaults', () => { + it('should return model info', () => { const modelInfo = handler.getModel(); - expect(modelInfo.id).toBe('mistral-7b'); + expect(modelInfo.id).toBe(mockOptions.lmStudioModelId); expect(modelInfo.info).toBeDefined(); expect(modelInfo.info.maxTokens).toBe(-1); expect(modelInfo.info.contextWindow).toBe(128_000); }); - - it('should return empty string as model ID if not provided', () => { - const noModelHandler = new LmStudioHandler({}); - const modelInfo = noModelHandler.getModel(); - expect(modelInfo.id).toBe(''); - expect(modelInfo.info).toBeDefined(); - }); }); }); \ No newline at end of file diff --git a/src/api/providers/__tests__/ollama.test.ts b/src/api/providers/__tests__/ollama.test.ts index 3d74e88..fc4c9f5 100644 --- a/src/api/providers/__tests__/ollama.test.ts +++ b/src/api/providers/__tests__/ollama.test.ts @@ -1,148 +1,160 @@ import { OllamaHandler } from '../ollama'; -import { Anthropic } from '@anthropic-ai/sdk'; +import { ApiHandlerOptions } from '../../../shared/api'; import OpenAI from 'openai'; +import { Anthropic } from '@anthropic-ai/sdk'; -// Mock OpenAI SDK -jest.mock('openai', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: jest.fn() +// Mock OpenAI client +const mockCreate = jest.fn(); +jest.mock('openai', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockCreate.mockImplementation(async (options) => { + if (!options.stream) { + return { + id: 'test-completion', + choices: [{ + message: { role: 'assistant', content: 'Test response' }, + finish_reason: 'stop', + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ + delta: { content: 'Test response' }, + index: 0 + }], + usage: null + }; + yield { + choices: [{ + delta: {}, + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + }; + }) + } } - } - })) -})); + })) + }; +}); describe('OllamaHandler', () => { let handler: OllamaHandler; + let mockOptions: ApiHandlerOptions; beforeEach(() => { - handler = new OllamaHandler({ + mockOptions = { + apiModelId: 'llama2', ollamaModelId: 'llama2', - ollamaBaseUrl: 'http://localhost:11434' - }); + ollamaBaseUrl: 'http://localhost:11434/v1' + }; + handler = new OllamaHandler(mockOptions); + mockCreate.mockClear(); }); describe('constructor', () => { - it('should initialize with provided config', () => { - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: 'http://localhost:11434/v1', - apiKey: 'ollama' - }); + it('should initialize with provided options', () => { + expect(handler).toBeInstanceOf(OllamaHandler); + expect(handler.getModel().id).toBe(mockOptions.ollamaModelId); }); it('should use default base URL if not provided', () => { - const defaultHandler = new OllamaHandler({ + const handlerWithoutUrl = new OllamaHandler({ + apiModelId: 'llama2', ollamaModelId: 'llama2' }); - - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: 'http://localhost:11434/v1', - apiKey: 'ollama' - }); + expect(handlerWithoutUrl).toBeInstanceOf(OllamaHandler); }); }); describe('createMessage', () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ + const systemPrompt = 'You are a helpful assistant.'; + const messages: Anthropic.Messages.MessageParam[] = [ { role: 'user', - content: 'Hello' - }, - { - role: 'assistant', - content: 'Hi there!' + content: 'Hello!' } ]; - const systemPrompt = 'You are a helpful assistant'; - - it('should handle streaming responses correctly', async () => { - const mockStream = [ - { - choices: [{ - delta: { content: 'Hello' } - }] - }, - { - choices: [{ - delta: { content: ' world!' } - }] - } - ]; - - // Setup async iterator for mock stream - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - } - }; - - const mockCreate = jest.fn().mockResolvedValue(asyncIterator); - (handler['client'].chat.completions as any).create = mockCreate; - - const stream = handler.createMessage(systemPrompt, mockMessages); - const chunks = []; - + it('should handle streaming responses', async () => { + const stream = handler.createMessage(systemPrompt, messages); + const chunks: any[] = []; for await (const chunk of stream) { chunks.push(chunk); } - expect(chunks.length).toBe(2); - expect(chunks[0]).toEqual({ - type: 'text', - text: 'Hello' - }); - expect(chunks[1]).toEqual({ - type: 'text', - text: ' world!' - }); + expect(chunks.length).toBeGreaterThan(0); + const textChunks = chunks.filter(chunk => chunk.type === 'text'); + expect(textChunks).toHaveLength(1); + expect(textChunks[0].text).toBe('Test response'); + }); + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); + + const stream = handler.createMessage(systemPrompt, messages); + + await expect(async () => { + for await (const chunk of stream) { + // Should not reach here + } + }).rejects.toThrow('API Error'); + }); + }); + + describe('completePrompt', () => { + it('should complete prompt successfully', async () => { + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); expect(mockCreate).toHaveBeenCalledWith({ - model: 'llama2', - messages: expect.arrayContaining([ - { - role: 'system', - content: systemPrompt - } - ]), + model: mockOptions.ollamaModelId, + messages: [{ role: 'user', content: 'Test prompt' }], temperature: 0, - stream: true + stream: false }); }); it('should handle API errors', async () => { - const mockError = new Error('Ollama API error'); - const mockCreate = jest.fn().mockRejectedValue(mockError); - (handler['client'].chat.completions as any).create = mockCreate; + mockCreate.mockRejectedValueOnce(new Error('API Error')); + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('Ollama completion error: API Error'); + }); - const stream = handler.createMessage(systemPrompt, mockMessages); - - await expect(async () => { - for await (const chunk of stream) { - // Should throw before yielding any chunks - } - }).rejects.toThrow('Ollama API error'); + it('should handle empty response', async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: '' } }] + }); + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); }); }); describe('getModel', () => { - it('should return model info with sane defaults', () => { + it('should return model info', () => { const modelInfo = handler.getModel(); - expect(modelInfo.id).toBe('llama2'); + expect(modelInfo.id).toBe(mockOptions.ollamaModelId); expect(modelInfo.info).toBeDefined(); expect(modelInfo.info.maxTokens).toBe(-1); expect(modelInfo.info.contextWindow).toBe(128_000); }); - - it('should return empty string as model ID if not provided', () => { - const noModelHandler = new OllamaHandler({}); - const modelInfo = noModelHandler.getModel(); - expect(modelInfo.id).toBe(''); - expect(modelInfo.info).toBeDefined(); - }); }); }); \ No newline at end of file diff --git a/src/api/providers/__tests__/openai-native.test.ts b/src/api/providers/__tests__/openai-native.test.ts index ece832a..fe40804 100644 --- a/src/api/providers/__tests__/openai-native.test.ts +++ b/src/api/providers/__tests__/openai-native.test.ts @@ -1,230 +1,209 @@ -import { OpenAiNativeHandler } from "../openai-native" -import OpenAI from "openai" -import { ApiHandlerOptions, openAiNativeDefaultModelId } from "../../../shared/api" -import { Anthropic } from "@anthropic-ai/sdk" +import { OpenAiNativeHandler } from '../openai-native'; +import { ApiHandlerOptions } from '../../../shared/api'; +import OpenAI from 'openai'; +import { Anthropic } from '@anthropic-ai/sdk'; -// Mock OpenAI -jest.mock("openai") - -describe("OpenAiNativeHandler", () => { - let handler: OpenAiNativeHandler - let mockOptions: ApiHandlerOptions - let mockOpenAIClient: jest.Mocked - let mockCreate: jest.Mock - - beforeEach(() => { - // Reset mocks - jest.clearAllMocks() - - // Setup mock options - mockOptions = { - openAiNativeApiKey: "test-api-key", - apiModelId: "gpt-4o", // Use the correct model ID from shared/api.ts - } - - // Setup mock create function - mockCreate = jest.fn() - - // Setup mock OpenAI client - mockOpenAIClient = { +// Mock OpenAI client +const mockCreate = jest.fn(); +jest.mock('openai', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ chat: { completions: { - create: mockCreate, - }, - }, - } as unknown as jest.Mocked + create: mockCreate.mockImplementation(async (options) => { + if (!options.stream) { + return { + id: 'test-completion', + choices: [{ + message: { role: 'assistant', content: 'Test response' }, + finish_reason: 'stop', + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ + delta: { content: 'Test response' }, + index: 0 + }], + usage: null + }; + yield { + choices: [{ + delta: {}, + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + }; + }) + } + } + })) + }; +}); - // Mock OpenAI constructor - ;(OpenAI as jest.MockedClass).mockImplementation(() => mockOpenAIClient) +describe('OpenAiNativeHandler', () => { + let handler: OpenAiNativeHandler; + let mockOptions: ApiHandlerOptions; - // Create handler instance - handler = new OpenAiNativeHandler(mockOptions) - }) + beforeEach(() => { + mockOptions = { + apiModelId: 'gpt-4o', + openAiNativeApiKey: 'test-api-key' + }; + handler = new OpenAiNativeHandler(mockOptions); + mockCreate.mockClear(); + }); - describe("constructor", () => { - it("should initialize with provided options", () => { - expect(OpenAI).toHaveBeenCalledWith({ - apiKey: mockOptions.openAiNativeApiKey, - }) - }) - }) + describe('constructor', () => { + it('should initialize with provided options', () => { + expect(handler).toBeInstanceOf(OpenAiNativeHandler); + expect(handler.getModel().id).toBe(mockOptions.apiModelId); + }); - describe("getModel", () => { - it("should return specified model when valid", () => { - const result = handler.getModel() - expect(result.id).toBe("gpt-4o") // Use the correct model ID - }) + it('should initialize with empty API key', () => { + const handlerWithoutKey = new OpenAiNativeHandler({ + apiModelId: 'gpt-4o', + openAiNativeApiKey: '' + }); + expect(handlerWithoutKey).toBeInstanceOf(OpenAiNativeHandler); + }); + }); - it("should return default model when model ID is invalid", () => { - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "invalid-model" as any, - }) - const result = handler.getModel() - expect(result.id).toBe(openAiNativeDefaultModelId) - }) - - it("should return default model when model ID is not provided", () => { - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: undefined, - }) - const result = handler.getModel() - expect(result.id).toBe(openAiNativeDefaultModelId) - }) - }) - - describe("createMessage", () => { - const systemPrompt = "You are a helpful assistant" + describe('createMessage', () => { + const systemPrompt = 'You are a helpful assistant.'; const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - ] + { + role: 'user', + content: 'Hello!' + } + ]; - describe("o1 models", () => { - beforeEach(() => { - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "o1-preview", - }) - }) + it('should handle streaming responses', async () => { + const stream = handler.createMessage(systemPrompt, messages); + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } - it("should handle non-streaming response for o1 models", async () => { - const mockResponse = { - choices: [{ message: { content: "Hello there!" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } + expect(chunks.length).toBeGreaterThan(0); + const textChunks = chunks.filter(chunk => chunk.type === 'text'); + expect(textChunks).toHaveLength(1); + expect(textChunks[0].text).toBe('Test response'); + }); - mockCreate.mockResolvedValueOnce(mockResponse) + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); - const generator = handler.createMessage(systemPrompt, messages) - const results = [] - for await (const result of generator) { - results.push(result) - } + const stream = handler.createMessage(systemPrompt, messages); - expect(results).toEqual([ - { type: "text", text: "Hello there!" }, - { type: "usage", inputTokens: 10, outputTokens: 5 }, - ]) - - expect(mockCreate).toHaveBeenCalledWith({ - model: "o1-preview", - messages: [ - { role: "user", content: systemPrompt }, - { role: "user", content: "Hello" }, - ], - }) - }) - - it("should handle missing content in response", async () => { - const mockResponse = { - choices: [{ message: { content: null } }], - usage: null, - } - - mockCreate.mockResolvedValueOnce(mockResponse) - - const generator = handler.createMessage(systemPrompt, messages) - const results = [] - for await (const result of generator) { - results.push(result) - } - - expect(results).toEqual([ - { type: "text", text: "" }, - { type: "usage", inputTokens: 0, outputTokens: 0 }, - ]) - }) - }) - - describe("streaming models", () => { - beforeEach(() => { - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-4o", - }) - }) - - it("should handle streaming response", async () => { - const mockStream = [ - { choices: [{ delta: { content: "Hello" } }], usage: null }, - { choices: [{ delta: { content: " there" } }], usage: null }, - { choices: [{ delta: { content: "!" } }], usage: { prompt_tokens: 10, completion_tokens: 5 } }, - ] - - mockCreate.mockResolvedValueOnce( - (async function* () { - for (const chunk of mockStream) { - yield chunk - } - })() - ) - - const generator = handler.createMessage(systemPrompt, messages) - const results = [] - for await (const result of generator) { - results.push(result) - } - - expect(results).toEqual([ - { type: "text", text: "Hello" }, - { type: "text", text: " there" }, - { type: "text", text: "!" }, - { type: "usage", inputTokens: 10, outputTokens: 5 }, - ]) - - expect(mockCreate).toHaveBeenCalledWith({ - model: "gpt-4o", - temperature: 0, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: "Hello" }, - ], - stream: true, - stream_options: { include_usage: true }, - }) - }) - - it("should handle empty delta content", async () => { - const mockStream = [ - { choices: [{ delta: {} }], usage: null }, - { choices: [{ delta: { content: null } }], usage: null }, - { choices: [{ delta: { content: "Hello" } }], usage: { prompt_tokens: 10, completion_tokens: 5 } }, - ] - - mockCreate.mockResolvedValueOnce( - (async function* () { - for (const chunk of mockStream) { - yield chunk - } - })() - ) - - const generator = handler.createMessage(systemPrompt, messages) - const results = [] - for await (const result of generator) { - results.push(result) - } - - expect(results).toEqual([ - { type: "text", text: "Hello" }, - { type: "usage", inputTokens: 10, outputTokens: 5 }, - ]) - }) - }) - - it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) - - const generator = handler.createMessage(systemPrompt, messages) await expect(async () => { - for await (const _ of generator) { - // consume generator + for await (const chunk of stream) { + // Should not reach here } - }).rejects.toThrow("API Error") - }) - }) -}) \ No newline at end of file + }).rejects.toThrow('API Error'); + }); + }); + + describe('completePrompt', () => { + it('should complete prompt successfully with gpt-4o model', async () => { + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockCreate).toHaveBeenCalledWith({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Test prompt' }], + temperature: 0 + }); + }); + + it('should complete prompt successfully with o1 model', async () => { + handler = new OpenAiNativeHandler({ + apiModelId: 'o1', + openAiNativeApiKey: 'test-api-key' + }); + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockCreate).toHaveBeenCalledWith({ + model: 'o1', + messages: [{ role: 'user', content: 'Test prompt' }] + }); + }); + + it('should complete prompt successfully with o1-preview model', async () => { + handler = new OpenAiNativeHandler({ + apiModelId: 'o1-preview', + openAiNativeApiKey: 'test-api-key' + }); + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockCreate).toHaveBeenCalledWith({ + model: 'o1-preview', + messages: [{ role: 'user', content: 'Test prompt' }] + }); + }); + + it('should complete prompt successfully with o1-mini model', async () => { + handler = new OpenAiNativeHandler({ + apiModelId: 'o1-mini', + openAiNativeApiKey: 'test-api-key' + }); + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockCreate).toHaveBeenCalledWith({ + model: 'o1-mini', + messages: [{ role: 'user', content: 'Test prompt' }] + }); + }); + + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('OpenAI Native completion error: API Error'); + }); + + it('should handle empty response', async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: '' } }] + }); + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + }); + + describe('getModel', () => { + it('should return model info', () => { + const modelInfo = handler.getModel(); + expect(modelInfo.id).toBe(mockOptions.apiModelId); + expect(modelInfo.info).toBeDefined(); + expect(modelInfo.info.maxTokens).toBe(4096); + expect(modelInfo.info.contextWindow).toBe(128_000); + }); + + it('should handle undefined model ID', () => { + const handlerWithoutModel = new OpenAiNativeHandler({ + openAiNativeApiKey: 'test-api-key' + }); + const modelInfo = handlerWithoutModel.getModel(); + expect(modelInfo.id).toBe('gpt-4o'); // Default model + expect(modelInfo.info).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/api/providers/__tests__/openai.test.ts b/src/api/providers/__tests__/openai.test.ts index edd6460..4a4a449 100644 --- a/src/api/providers/__tests__/openai.test.ts +++ b/src/api/providers/__tests__/openai.test.ts @@ -176,6 +176,32 @@ describe('OpenAiHandler', () => { }); }); + describe('completePrompt', () => { + it('should complete prompt successfully', async () => { + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(mockCreate).toHaveBeenCalledWith({ + model: mockOptions.openAiModelId, + messages: [{ role: 'user', content: 'Test prompt' }], + temperature: 0 + }); + }); + + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('OpenAI completion error: API Error'); + }); + + it('should handle empty response', async () => { + mockCreate.mockImplementationOnce(() => ({ + choices: [{ message: { content: '' } }] + })); + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + }); + describe('getModel', () => { it('should return model info with sane defaults', () => { const model = handler.getModel(); diff --git a/src/api/providers/__tests__/vertex.test.ts b/src/api/providers/__tests__/vertex.test.ts index 71aa810..be5899f 100644 --- a/src/api/providers/__tests__/vertex.test.ts +++ b/src/api/providers/__tests__/vertex.test.ts @@ -6,7 +6,42 @@ import { AnthropicVertex } from '@anthropic-ai/vertex-sdk'; jest.mock('@anthropic-ai/vertex-sdk', () => ({ AnthropicVertex: jest.fn().mockImplementation(() => ({ messages: { - create: jest.fn() + create: jest.fn().mockImplementation(async (options) => { + if (!options.stream) { + return { + id: 'test-completion', + content: [ + { type: 'text', text: 'Test response' } + ], + role: 'assistant', + model: options.model, + usage: { + input_tokens: 10, + output_tokens: 5 + } + } + } + return { + async *[Symbol.asyncIterator]() { + yield { + type: 'message_start', + message: { + usage: { + input_tokens: 10, + output_tokens: 5 + } + } + } + yield { + type: 'content_block_start', + content_block: { + type: 'text', + text: 'Test response' + } + } + } + } + }) } })) })); @@ -196,6 +231,49 @@ describe('VertexHandler', () => { }); }); + describe('completePrompt', () => { + it('should complete prompt successfully', async () => { + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe('Test response'); + expect(handler['client'].messages.create).toHaveBeenCalledWith({ + model: 'claude-3-5-sonnet-v2@20241022', + max_tokens: 8192, + temperature: 0, + messages: [{ role: 'user', content: 'Test prompt' }], + stream: false + }); + }); + + it('should handle API errors', async () => { + const mockError = new Error('Vertex API error'); + const mockCreate = jest.fn().mockRejectedValue(mockError); + (handler['client'].messages as any).create = mockCreate; + + await expect(handler.completePrompt('Test prompt')) + .rejects.toThrow('Vertex completion error: Vertex API error'); + }); + + it('should handle non-text content', async () => { + const mockCreate = jest.fn().mockResolvedValue({ + content: [{ type: 'image' }] + }); + (handler['client'].messages as any).create = mockCreate; + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + + it('should handle empty response', async () => { + const mockCreate = jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: '' }] + }); + (handler['client'].messages as any).create = mockCreate; + + const result = await handler.completePrompt('Test prompt'); + expect(result).toBe(''); + }); + }); + describe('getModel', () => { it('should return correct model info', () => { const modelInfo = handler.getModel(); diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index c090f17..5184281 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -7,10 +7,10 @@ import { ApiHandlerOptions, ModelInfo, } from "../../shared/api" -import { ApiHandler } from "../index" +import { ApiHandler, SingleCompletionHandler } from "../index" import { ApiStream } from "../transform/stream" -export class AnthropicHandler implements ApiHandler { +export class AnthropicHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: Anthropic @@ -173,4 +173,27 @@ export class AnthropicHandler implements ApiHandler { } return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.messages.create({ + model: this.getModel().id, + max_tokens: this.getModel().info.maxTokens || 8192, + temperature: 0, + messages: [{ role: "user", content: prompt }], + stream: false + }) + + const content = response.content[0] + if (content.type === 'text') { + return content.text + } + return '' + } catch (error) { + if (error instanceof Error) { + throw new Error(`Anthropic completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 3b691c1..3d07895 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -1,6 +1,6 @@ -import { BedrockRuntimeClient, ConverseStreamCommand, BedrockRuntimeClientConfig } from "@aws-sdk/client-bedrock-runtime" +import { BedrockRuntimeClient, ConverseStreamCommand, ConverseCommand, BedrockRuntimeClientConfig } from "@aws-sdk/client-bedrock-runtime" import { Anthropic } from "@anthropic-ai/sdk" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, BedrockModelId, ModelInfo, bedrockDefaultModelId, bedrockModels } from "../../shared/api" import { ApiStream } from "../transform/stream" import { convertToBedrockConverseMessages, convertToAnthropicMessage } from "../transform/bedrock-converse-format" @@ -38,7 +38,7 @@ export interface StreamEvent { }; } -export class AwsBedrockHandler implements ApiHandler { +export class AwsBedrockHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: BedrockRuntimeClient @@ -199,7 +199,7 @@ export class AwsBedrockHandler implements ApiHandler { if (modelId) { // For tests, allow any model ID if (process.env.NODE_ENV === 'test') { - return { + return { id: modelId, info: { maxTokens: 5000, @@ -214,9 +214,68 @@ export class AwsBedrockHandler implements ApiHandler { return { id, info: bedrockModels[id] } } } - return { - id: bedrockDefaultModelId, - info: bedrockModels[bedrockDefaultModelId] + return { + id: bedrockDefaultModelId, + info: bedrockModels[bedrockDefaultModelId] + } + } + + async completePrompt(prompt: string): Promise { + try { + const modelConfig = this.getModel() + + // Handle cross-region inference + let modelId: string + if (this.options.awsUseCrossRegionInference) { + let regionPrefix = (this.options.awsRegion || "").slice(0, 3) + switch (regionPrefix) { + case "us-": + modelId = `us.${modelConfig.id}` + break + case "eu-": + modelId = `eu.${modelConfig.id}` + break + default: + modelId = modelConfig.id + break + } + } else { + modelId = modelConfig.id + } + + const payload = { + modelId, + messages: convertToBedrockConverseMessages([{ + role: "user", + content: prompt + }]), + inferenceConfig: { + maxTokens: modelConfig.info.maxTokens || 5000, + temperature: 0.3, + topP: 0.1 + } + } + + const command = new ConverseCommand(payload) + const response = await this.client.send(command) + + if (response.output && response.output instanceof Uint8Array) { + try { + const outputStr = new TextDecoder().decode(response.output) + const output = JSON.parse(outputStr) + if (output.content) { + return output.content + } + } catch (parseError) { + console.error('Failed to parse Bedrock response:', parseError) + } + } + return '' + } catch (error) { + if (error instanceof Error) { + throw new Error(`Bedrock completion error: ${error.message}`) + } + throw error } } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index d7ac5ec..0f6392b 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -1,11 +1,11 @@ import { Anthropic } from "@anthropic-ai/sdk" import { GoogleGenerativeAI } from "@google/generative-ai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "../../shared/api" import { convertAnthropicMessageToGemini } from "../transform/gemini-format" import { ApiStream } from "../transform/stream" -export class GeminiHandler implements ApiHandler { +export class GeminiHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: GoogleGenerativeAI @@ -53,4 +53,26 @@ export class GeminiHandler implements ApiHandler { } return { id: geminiDefaultModelId, info: geminiModels[geminiDefaultModelId] } } + + async completePrompt(prompt: string): Promise { + try { + const model = this.client.getGenerativeModel({ + model: this.getModel().id, + }) + + const result = await model.generateContent({ + contents: [{ role: "user", parts: [{ text: prompt }] }], + generationConfig: { + temperature: 0, + }, + }) + + return result.response.text() + } catch (error) { + if (error instanceof Error) { + throw new Error(`Gemini completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/glama.ts b/src/api/providers/glama.ts index c17db05..7e95d0c 100644 --- a/src/api/providers/glama.ts +++ b/src/api/providers/glama.ts @@ -1,13 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import axios from "axios" import OpenAI from "openai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, glamaDefaultModelId, glamaDefaultModelInfo } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" import delay from "delay" -export class GlamaHandler implements ApiHandler { +export class GlamaHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: OpenAI @@ -129,4 +129,26 @@ export class GlamaHandler implements ApiHandler { return { id: glamaDefaultModelId, info: glamaDefaultModelInfo } } + + async completePrompt(prompt: string): Promise { + try { + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + } + + if (this.getModel().id.startsWith("anthropic/")) { + requestOptions.max_tokens = 8192 + } + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`Glama completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/lmstudio.ts b/src/api/providers/lmstudio.ts index 868ef7d..e5c6256 100644 --- a/src/api/providers/lmstudio.ts +++ b/src/api/providers/lmstudio.ts @@ -1,11 +1,11 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" -export class LmStudioHandler implements ApiHandler { +export class LmStudioHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: OpenAI @@ -53,4 +53,20 @@ export class LmStudioHandler implements ApiHandler { info: openAiModelInfoSaneDefaults, } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.chat.completions.create({ + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + stream: false + }) + return response.choices[0]?.message.content || "" + } catch (error) { + throw new Error( + "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Cline's prompts.", + ) + } + } } diff --git a/src/api/providers/ollama.ts b/src/api/providers/ollama.ts index 7668bd3..9df73d6 100644 --- a/src/api/providers/ollama.ts +++ b/src/api/providers/ollama.ts @@ -1,11 +1,11 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" -export class OllamaHandler implements ApiHandler { +export class OllamaHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: OpenAI @@ -46,4 +46,21 @@ export class OllamaHandler implements ApiHandler { info: openAiModelInfoSaneDefaults, } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.chat.completions.create({ + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + stream: false + }) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`Ollama completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 139b3a2..83644c9 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, @@ -11,7 +11,7 @@ import { import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" -export class OpenAiNativeHandler implements ApiHandler { +export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: OpenAI @@ -83,4 +83,37 @@ export class OpenAiNativeHandler implements ApiHandler { } return { id: openAiNativeDefaultModelId, info: openAiNativeModels[openAiNativeDefaultModelId] } } + + async completePrompt(prompt: string): Promise { + try { + const modelId = this.getModel().id + let requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming + + switch (modelId) { + case "o1": + case "o1-preview": + case "o1-mini": + // o1 doesn't support non-1 temp or system prompt + requestOptions = { + model: modelId, + messages: [{ role: "user", content: prompt }] + } + break + default: + requestOptions = { + model: modelId, + messages: [{ role: "user", content: prompt }], + temperature: 0 + } + } + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`OpenAI Native completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 3ec2192..0878028 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -6,11 +6,11 @@ import { ModelInfo, openAiModelInfoSaneDefaults, } from "../../shared/api" -import { ApiHandler } from "../index" +import { ApiHandler, SingleCompletionHandler } from "../index" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" -export class OpenAiHandler implements ApiHandler { +export class OpenAiHandler implements ApiHandler, SingleCompletionHandler { protected options: ApiHandlerOptions private client: OpenAI @@ -100,4 +100,22 @@ export class OpenAiHandler implements ApiHandler { info: openAiModelInfoSaneDefaults, } } + + async completePrompt(prompt: string): Promise { + try { + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + } + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`OpenAI completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 60e6967..aed704e 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -1,11 +1,11 @@ import { Anthropic } from "@anthropic-ai/sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api" import { ApiStream } from "../transform/stream" // https://docs.anthropic.com/en/api/claude-on-vertex-ai -export class VertexHandler implements ApiHandler { +export class VertexHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: AnthropicVertex @@ -83,4 +83,27 @@ export class VertexHandler implements ApiHandler { } return { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.messages.create({ + model: this.getModel().id, + max_tokens: this.getModel().info.maxTokens || 8192, + temperature: 0, + messages: [{ role: "user", content: prompt }], + stream: false + }) + + const content = response.content[0] + if (content.type === 'text') { + return content.text + } + return '' + } catch (error) { + if (error instanceof Error) { + throw new Error(`Vertex completion error: ${error.message}`) + } + throw error + } + } } From 75e308b0332113d0964b511a32634e86787aff9a Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 13 Jan 2025 03:16:10 -0500 Subject: [PATCH 04/15] Add a screen for custom prompts --- package.json | 18 +- src/core/Cline.ts | 5 +- src/core/prompts/architect.ts | 5 +- src/core/prompts/ask.ts | 5 +- src/core/prompts/code.ts | 5 +- src/core/prompts/system.ts | 9 +- src/core/webview/ClineProvider.ts | 104 +++++- .../webview/__tests__/ClineProvider.test.ts | 207 +++++++++++ src/extension.ts | 6 + src/shared/ExtensionMessage.ts | 10 +- src/shared/WebviewMessage.ts | 10 + src/shared/modes.ts | 16 +- src/utils/__tests__/enhance-prompt.test.ts | 172 +++++---- src/utils/enhance-prompt.ts | 23 +- webview-ui/src/App.tsx | 16 +- .../src/components/chat/Announcement.tsx | 109 ++---- .../src/components/chat/ChatTextArea.tsx | 40 +- .../chat/__tests__/ChatTextArea.test.tsx | 34 +- .../src/components/prompts/PromptsView.tsx | 344 ++++++++++++++++++ .../prompts/__tests__/PromptsView.test.tsx | 135 +++++++ .../src/context/ExtensionStateContext.tsx | 9 +- 21 files changed, 1044 insertions(+), 238 deletions(-) create mode 100644 webview-ui/src/components/prompts/PromptsView.tsx create mode 100644 webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx diff --git a/package.json b/package.json index f4451dd..ef43cda 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,11 @@ "title": "MCP Servers", "icon": "$(server)" }, + { + "command": "roo-cline.promptsButtonClicked", + "title": "Prompts", + "icon": "$(notebook)" + }, { "command": "roo-cline.historyButtonClicked", "title": "History", @@ -103,24 +108,29 @@ "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.mcpButtonClicked", + "command": "roo-cline.promptsButtonClicked", "group": "navigation@2", "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.historyButtonClicked", + "command": "roo-cline.mcpButtonClicked", "group": "navigation@3", "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.popoutButtonClicked", + "command": "roo-cline.historyButtonClicked", "group": "navigation@4", "when": "view == roo-cline.SidebarProvider" }, { - "command": "roo-cline.settingsButtonClicked", + "command": "roo-cline.popoutButtonClicked", "group": "navigation@5", "when": "view == roo-cline.SidebarProvider" + }, + { + "command": "roo-cline.settingsButtonClicked", + "group": "navigation@6", + "when": "view == roo-cline.SidebarProvider" } ] }, diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 9cb3a3c..acff8bf 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -780,14 +780,15 @@ export class Cline { }) } - const { browserViewportSize, preferredLanguage, mode } = await this.providerRef.deref()?.getState() ?? {} + const { browserViewportSize, preferredLanguage, mode, customPrompts } = await this.providerRef.deref()?.getState() ?? {} const systemPrompt = await SYSTEM_PROMPT( cwd, this.api.getModel().info.supportsComputerUse ?? false, mcpHub, this.diffStrategy, browserViewportSize, - mode + mode, + customPrompts ) + await addCustomInstructions(this.customInstructions ?? '', cwd, preferredLanguage) // If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request diff --git a/src/core/prompts/architect.ts b/src/core/prompts/architect.ts index 6a2c9d1..d0dc8c8 100644 --- a/src/core/prompts/architect.ts +++ b/src/core/prompts/architect.ts @@ -1,4 +1,4 @@ -import { architectMode } from "./modes" +import { architectMode, defaultPrompts } from "../../shared/modes" import { getToolDescriptionsForMode } from "./tools" import { getRulesSection, @@ -20,7 +20,8 @@ export const ARCHITECT_PROMPT = async ( mcpHub?: McpHub, diffStrategy?: DiffStrategy, browserViewportSize?: string, -) => `You are Cline, a software architecture expert specializing in analyzing codebases, identifying patterns, and providing high-level technical guidance. You excel at understanding complex systems, evaluating architectural decisions, and suggesting improvements while maintaining a read-only approach to the codebase. Make sure to help the user come up with a solid implementation plan for their project and don't rush to switch to implementing code. + customPrompt?: string, +) => `${customPrompt || defaultPrompts[architectMode]} ${getSharedToolUseSection()} diff --git a/src/core/prompts/ask.ts b/src/core/prompts/ask.ts index dce551f..2794a72 100644 --- a/src/core/prompts/ask.ts +++ b/src/core/prompts/ask.ts @@ -1,4 +1,4 @@ -import { Mode, askMode } from "./modes" +import { Mode, askMode, defaultPrompts } from "../../shared/modes" import { getToolDescriptionsForMode } from "./tools" import { getRulesSection, @@ -21,7 +21,8 @@ export const ASK_PROMPT = async ( mcpHub?: McpHub, diffStrategy?: DiffStrategy, browserViewportSize?: string, -) => `You are Cline, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. You can analyze code, explain concepts, and access external resources while maintaining a read-only approach to the codebase. Make sure to answer the user's questions and don't rush to switch to implementing code. + customPrompt?: string, +) => `${customPrompt || defaultPrompts[askMode]} ${getSharedToolUseSection()} diff --git a/src/core/prompts/code.ts b/src/core/prompts/code.ts index 918ed90..3bf8854 100644 --- a/src/core/prompts/code.ts +++ b/src/core/prompts/code.ts @@ -1,4 +1,4 @@ -import { Mode, codeMode } from "./modes" +import { Mode, codeMode, defaultPrompts } from "../../shared/modes" import { getToolDescriptionsForMode } from "./tools" import { getRulesSection, @@ -21,7 +21,8 @@ export const CODE_PROMPT = async ( mcpHub?: McpHub, diffStrategy?: DiffStrategy, browserViewportSize?: string, -) => `You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. + customPrompt?: string, +) => `${customPrompt || defaultPrompts[codeMode]} ${getSharedToolUseSection()} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index fa49436..9e61d7b 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -63,15 +63,16 @@ export const SYSTEM_PROMPT = async ( mcpHub?: McpHub, diffStrategy?: DiffStrategy, browserViewportSize?: string, - mode: Mode = codeMode, + mode: Mode = codeMode, + customPrompts?: { ask?: string; code?: string; architect?: string; enhance?: string }, ) => { switch (mode) { case architectMode: - return ARCHITECT_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize) + return ARCHITECT_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize, customPrompts?.architect) case askMode: - return ASK_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize) + return ASK_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize, customPrompts?.ask) default: - return CODE_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize) + return CODE_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize, customPrompts?.code) } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index dfd9246..4b0d9ea 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -17,6 +17,8 @@ import { findLast } from "../../shared/array" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { HistoryItem } from "../../shared/HistoryItem" import { WebviewMessage } from "../../shared/WebviewMessage" +import { defaultPrompts } from "../../shared/modes" +import { SYSTEM_PROMPT, addCustomInstructions } from "../prompts/system" import { fileExistsAtPath } from "../../utils/fs" import { Cline } from "../Cline" import { openMention } from "../mentions" @@ -28,7 +30,7 @@ import { enhancePrompt } from "../../utils/enhance-prompt" import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git" import { ConfigManager } from "../config/ConfigManager" import { Mode } from "../prompts/types" -import { codeMode } from "../prompts/system" +import { codeMode, CustomPrompts } from "../../shared/modes" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -93,6 +95,8 @@ type GlobalStateKey = | "listApiConfigMeta" | "mode" | "modeApiConfigs" + | "customPrompts" + | "enhancementApiConfigId" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -111,7 +115,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private cline?: Cline private workspaceTracker?: WorkspaceTracker mcpHub?: McpHub - private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement + private latestAnnouncementId = "jan-13-2025-custom-prompt" // update to some unique identifier when we add a new announcement configManager: ConfigManager constructor( @@ -727,6 +731,32 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.postStateToWebview() break + case "updatePrompt": + if (message.promptMode && message.customPrompt !== undefined) { + const existingPrompts = await this.getGlobalState("customPrompts") || {} + + const updatedPrompts = { + ...existingPrompts, + [message.promptMode]: message.customPrompt + } + + await this.updateGlobalState("customPrompts", updatedPrompts) + + // Get current state and explicitly include customPrompts + const currentState = await this.getState() + + const stateWithPrompts = { + ...currentState, + customPrompts: updatedPrompts + } + + // Post state with prompts + this.view?.webview.postMessage({ + type: "state", + state: stateWithPrompts + }) + } + break case "deleteMessage": { const answer = await vscode.window.showInformationMessage( "What would you like to delete?", @@ -797,16 +827,28 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("screenshotQuality", message.value) await this.postStateToWebview() break + case "enhancementApiConfigId": + await this.updateGlobalState("enhancementApiConfigId", message.text) + await this.postStateToWebview() + break case "enhancePrompt": if (message.text) { try { - const { apiConfiguration } = await this.getState() - const enhanceConfig = { - ...apiConfiguration, - apiProvider: "openrouter" as const, - openRouterModelId: "gpt-4o", + const { apiConfiguration, customPrompts, listApiConfigMeta, enhancementApiConfigId } = await this.getState() + + // Try to get enhancement config first, fall back to current config + let configToUse: ApiConfiguration = apiConfiguration + if (enhancementApiConfigId) { + const config = listApiConfigMeta?.find(c => c.id === enhancementApiConfigId) + if (config?.name) { + const loadedConfig = await this.configManager.LoadConfig(config.name) + if (loadedConfig.apiProvider) { + configToUse = loadedConfig + } + } } - const enhancedPrompt = await enhancePrompt(enhanceConfig, message.text) + + const enhancedPrompt = await enhancePrompt(configToUse, message.text, customPrompts?.enhance) await this.postMessageToWebview({ type: "enhancedPrompt", text: enhancedPrompt @@ -814,11 +856,37 @@ export class ClineProvider implements vscode.WebviewViewProvider { } catch (error) { console.error("Error enhancing prompt:", error) vscode.window.showErrorMessage("Failed to enhance prompt") + await this.postMessageToWebview({ + type: "enhancedPrompt" + }) } } break - - + case "getSystemPrompt": + try { + const { apiConfiguration, customPrompts, customInstructions, preferredLanguage, browserViewportSize, mcpEnabled } = await this.getState() + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || '' + + const fullPrompt = await SYSTEM_PROMPT( + cwd, + apiConfiguration.openRouterModelInfo?.supportsComputerUse ?? false, + mcpEnabled ? this.mcpHub : undefined, + undefined, + browserViewportSize ?? "900x600", + message.mode, + customPrompts + ) + await addCustomInstructions(customInstructions ?? '', cwd, preferredLanguage) + + await this.postMessageToWebview({ + type: "systemPrompt", + text: fullPrompt, + mode: message.mode + }) + } catch (error) { + console.error("Error getting system prompt:", error) + vscode.window.showErrorMessage("Failed to get system prompt") + } + break case "searchCommits": { const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) if (cwd) { @@ -1482,6 +1550,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { currentApiConfigName, listApiConfigMeta, mode, + customPrompts, + enhancementApiConfigId, } = await this.getState() const allowedCommands = vscode.workspace @@ -1500,11 +1570,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { uriScheme: vscode.env.uriScheme, clineMessages: this.cline?.clineMessages || [], taskHistory: (taskHistory || []) - .filter((item) => item.ts && item.task) - .sort((a, b) => b.ts - a.ts), + .filter((item: HistoryItem) => item.ts && item.task) + .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), soundEnabled: soundEnabled ?? false, diffEnabled: diffEnabled ?? true, - shouldShowAnnouncement: false, // lastShownAnnouncementId !== this.latestAnnouncementId, + shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands, soundVolume: soundVolume ?? 0.5, browserViewportSize: browserViewportSize ?? "900x600", @@ -1519,6 +1589,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], mode: mode ?? codeMode, + customPrompts: customPrompts ?? {}, + enhancementApiConfigId, } } @@ -1630,6 +1702,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { listApiConfigMeta, mode, modeApiConfigs, + customPrompts, + enhancementApiConfigId, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1686,6 +1760,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("listApiConfigMeta") as Promise, this.getGlobalState("mode") as Promise, this.getGlobalState("modeApiConfigs") as Promise | undefined>, + this.getGlobalState("customPrompts") as Promise, + this.getGlobalState("enhancementApiConfigId") as Promise, ]) let apiProvider: ApiProvider @@ -1786,6 +1862,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], modeApiConfigs: modeApiConfigs ?? {} as Record, + customPrompts: customPrompts ?? {}, + enhancementApiConfigId, } } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 4eddca2..1d8f21c 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -62,6 +62,7 @@ jest.mock('vscode', () => ({ }, window: { showInformationMessage: jest.fn(), + showErrorMessage: jest.fn(), }, workspace: { getConfiguration: jest.fn().mockReturnValue({ @@ -113,6 +114,13 @@ jest.mock('../../../api', () => ({ buildApiHandler: jest.fn() })) +// Mock system prompt +jest.mock('../../prompts/system', () => ({ + SYSTEM_PROMPT: jest.fn().mockImplementation(async () => 'mocked system prompt'), + codeMode: 'code', + addCustomInstructions: jest.fn().mockImplementation(async () => '') +})) + // Mock WorkspaceTracker jest.mock('../../../integrations/workspace/WorkspaceTracker', () => { return jest.fn().mockImplementation(() => ({ @@ -504,6 +512,106 @@ describe('ClineProvider', () => { expect(mockPostMessage).toHaveBeenCalled() }) + test('handles updatePrompt message correctly', async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Mock existing prompts + const existingPrompts = { + code: 'existing code prompt', + architect: 'existing architect prompt' + } + ;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === 'customPrompts') { + return existingPrompts + } + return undefined + }) + + // Test updating a prompt + await messageHandler({ + type: 'updatePrompt', + promptMode: 'code', + customPrompt: 'new code prompt' + }) + + // Verify state was updated correctly + expect(mockContext.globalState.update).toHaveBeenCalledWith( + 'customPrompts', + { + ...existingPrompts, + code: 'new code prompt' + } + ) + + // Verify state was posted to webview + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'state', + state: expect.objectContaining({ + customPrompts: { + ...existingPrompts, + code: 'new code prompt' + } + }) + }) + ) + }) + + test('customPrompts defaults to empty object', async () => { + // Mock globalState.get to return undefined for customPrompts + (mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === 'customPrompts') { + return undefined + } + return null + }) + + const state = await provider.getState() + expect(state.customPrompts).toEqual({}) + }) + + test('saves mode config when updating API configuration', async () => { + // Setup mock context with mode and config name + mockContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + get: jest.fn((key: string) => { + if (key === 'mode') { + return 'code' + } else if (key === 'currentApiConfigName') { + return 'test-config' + } + return undefined + }), + update: jest.fn(), + keys: jest.fn().mockReturnValue([]), + } + } as unknown as vscode.ExtensionContext + + // Create new provider with updated mock context + provider = new ClineProvider(mockContext, mockOutputChannel) + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + provider.configManager = { + ListConfig: jest.fn().mockResolvedValue([ + { name: 'test-config', id: 'test-id', apiProvider: 'anthropic' } + ]), + SetModeConfig: jest.fn() + } as any + + // Update API configuration + await messageHandler({ + type: 'apiConfiguration', + apiConfiguration: { apiProvider: 'anthropic' } + }) + + // Should save config as default for current mode + expect(provider.configManager.SetModeConfig).toHaveBeenCalledWith('code', 'test-id') + }) + test('file content includes line numbers', async () => { const { extractTextFromFile } = require('../../../integrations/misc/extract-text') const result = await extractTextFromFile('test.js') @@ -654,4 +762,103 @@ describe('ClineProvider', () => { expect(mockCline.overwriteApiConversationHistory).not.toHaveBeenCalled() }) }) + + describe('getSystemPrompt', () => { + beforeEach(() => { + mockPostMessage.mockClear(); + provider.resolveWebviewView(mockWebviewView); + }); + + const getMessageHandler = () => { + const mockCalls = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls; + expect(mockCalls.length).toBeGreaterThan(0); + return mockCalls[0][0]; + }; + + test('handles mcpEnabled setting correctly', async () => { + // Mock getState to return mcpEnabled: true + jest.spyOn(provider, 'getState').mockResolvedValue({ + apiConfiguration: { + apiProvider: 'openrouter' as const, + openRouterModelInfo: { + supportsComputerUse: true, + supportsPromptCache: false, + maxTokens: 4096, + contextWindow: 8192, + supportsImages: false, + inputPrice: 0.0, + outputPrice: 0.0, + description: undefined + } + }, + mcpEnabled: true, + mode: 'code' as const + } as any); + + const handler1 = getMessageHandler(); + expect(typeof handler1).toBe('function'); + await handler1({ type: 'getSystemPrompt', mode: 'code' }); + + // Verify mcpHub is passed when mcpEnabled is true + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'systemPrompt', + text: expect.any(String) + }) + ); + + // Mock getState to return mcpEnabled: false + jest.spyOn(provider, 'getState').mockResolvedValue({ + apiConfiguration: { + apiProvider: 'openrouter' as const, + openRouterModelInfo: { + supportsComputerUse: true, + supportsPromptCache: false, + maxTokens: 4096, + contextWindow: 8192, + supportsImages: false, + inputPrice: 0.0, + outputPrice: 0.0, + description: undefined + } + }, + mcpEnabled: false, + mode: 'code' as const + } as any); + + const handler2 = getMessageHandler(); + await handler2({ type: 'getSystemPrompt', mode: 'code' }); + + // Verify mcpHub is not passed when mcpEnabled is false + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'systemPrompt', + text: expect.any(String) + }) + ); + }); + + test('returns empty prompt for enhance mode', async () => { + const enhanceHandler = getMessageHandler(); + await enhanceHandler({ type: 'getSystemPrompt', mode: 'enhance' }) + + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'systemPrompt', + text: '' + }) + ) + }) + + test('handles errors gracefully', async () => { + // Mock SYSTEM_PROMPT to throw an error + const systemPrompt = require('../../prompts/system') + jest.spyOn(systemPrompt, 'SYSTEM_PROMPT').mockRejectedValueOnce(new Error('Test error')) + + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + await messageHandler({ type: 'getSystemPrompt', mode: 'code' }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Failed to get system prompt') + }) + }) }) diff --git a/src/extension.ts b/src/extension.ts index 5b94fad..c6dd9c2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -59,6 +59,12 @@ export function activate(context: vscode.ExtensionContext) { }), ) + context.subscriptions.push( + vscode.commands.registerCommand("roo-cline.promptsButtonClicked", () => { + sidebarProvider.postMessageToWebview({ type: "action", action: "promptsButtonClicked" }) + }), + ) + const openClineInNewTab = async () => { outputChannel.appendLine("Opening Cline in new tab") // (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 0a0ed86..4e5624c 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -4,7 +4,7 @@ import { ApiConfiguration, ApiProvider, ModelInfo } from "./api" import { HistoryItem } from "./HistoryItem" import { McpServer } from "./mcp" import { GitCommit } from "../utils/git" -import { Mode } from "../core/prompts/types" +import { Mode, CustomPrompts } from "./modes" // webview will hold state export interface ExtensionMessage { @@ -25,12 +25,15 @@ export interface ExtensionMessage { | "enhancedPrompt" | "commitSearchResults" | "listApiConfig" + | "updatePrompt" + | "systemPrompt" text?: string action?: | "chatButtonClicked" | "mcpButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" + | "promptsButtonClicked" | "didBecomeVisible" invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" state?: ExtensionState @@ -45,6 +48,7 @@ export interface ExtensionMessage { mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ApiConfigMeta[] + mode?: Mode | 'enhance' } export interface ApiConfigMeta { @@ -62,6 +66,7 @@ export interface ExtensionState { currentApiConfigName?: string listApiConfigMeta?: ApiConfigMeta[] customInstructions?: string + customPrompts?: CustomPrompts alwaysAllowReadOnly?: boolean alwaysAllowWrite?: boolean alwaysAllowExecute?: boolean @@ -82,7 +87,8 @@ export interface ExtensionState { terminalOutputLineLimit?: number mcpEnabled: boolean mode: Mode - modeApiConfigs?: Record; + modeApiConfigs?: Record + enhancementApiConfigId?: string } export interface ClineMessage { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 747f140..58c181b 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,4 +1,7 @@ import { ApiConfiguration, ApiProvider } from "./api" +import { Mode } from "./modes" + +export type PromptMode = Mode | 'enhance' export type AudioType = "notification" | "celebration" | "progress_loop" @@ -62,6 +65,10 @@ export interface WebviewMessage { | "requestDelaySeconds" | "setApiConfigPassword" | "mode" + | "updatePrompt" + | "getSystemPrompt" + | "systemPrompt" + | "enhancementApiConfigId" text?: string disabled?: boolean askResponse?: ClineAskResponse @@ -74,6 +81,9 @@ export interface WebviewMessage { serverName?: string toolName?: string alwaysAllow?: boolean + mode?: Mode + promptMode?: PromptMode + customPrompt?: string dataUrls?: string[] values?: Record query?: string diff --git a/src/shared/modes.ts b/src/shared/modes.ts index 89e0756..c7dc7a7 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -2,4 +2,18 @@ export const codeMode = 'code' as const; export const architectMode = 'architect' as const; export const askMode = 'ask' as const; -export type Mode = typeof codeMode | typeof architectMode | typeof askMode; \ No newline at end of file +export type Mode = typeof codeMode | typeof architectMode | typeof askMode; + +export type CustomPrompts = { + ask?: string; + code?: string; + architect?: string; + enhance?: string; +} + +export const defaultPrompts = { + [askMode]: "You are Cline, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. You can analyze code, explain concepts, and access external resources while maintaining a read-only approach to the codebase. Make sure to answer the user's questions and don't rush to switch to implementing code.", + [codeMode]: "You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.", + [architectMode]: "You are Cline, a software architecture expert specializing in analyzing codebases, identifying patterns, and providing high-level technical guidance. You excel at understanding complex systems, evaluating architectural decisions, and suggesting improvements while maintaining a read-only approach to the codebase. Make sure to help the user come up with a solid implementation plan for their project and don't rush to switch to implementing code.", + enhance: "Generate an enhanced version of this prompt (reply with only the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes):" +} as const; \ No newline at end of file diff --git a/src/utils/__tests__/enhance-prompt.test.ts b/src/utils/__tests__/enhance-prompt.test.ts index 588e3f4..61b89c1 100644 --- a/src/utils/__tests__/enhance-prompt.test.ts +++ b/src/utils/__tests__/enhance-prompt.test.ts @@ -1,80 +1,126 @@ import { enhancePrompt } from '../enhance-prompt' -import { buildApiHandler } from '../../api' import { ApiConfiguration } from '../../shared/api' -import { OpenRouterHandler } from '../../api/providers/openrouter' +import { buildApiHandler, SingleCompletionHandler } from '../../api' +import { defaultPrompts } from '../../shared/modes' -// Mock the buildApiHandler function +// Mock the API handler jest.mock('../../api', () => ({ - buildApiHandler: jest.fn() + buildApiHandler: jest.fn() })) describe('enhancePrompt', () => { - const mockApiConfig: ApiConfiguration = { - apiProvider: 'openrouter', - apiKey: 'test-key', - openRouterApiKey: 'test-key', - openRouterModelId: 'test-model' - } + const mockApiConfig: ApiConfiguration = { + apiProvider: 'openai', + openAiApiKey: 'test-key', + openAiBaseUrl: 'https://api.openai.com/v1' + } - // Create a mock handler that looks like OpenRouterHandler - const mockHandler = { - completePrompt: jest.fn(), - createMessage: jest.fn(), - getModel: jest.fn() - } - - // Make instanceof check work - Object.setPrototypeOf(mockHandler, OpenRouterHandler.prototype) - - beforeEach(() => { - jest.clearAllMocks() - ;(buildApiHandler as jest.Mock).mockReturnValue(mockHandler) - }) - - it('should throw error for non-OpenRouter providers', async () => { - const nonOpenRouterConfig: ApiConfiguration = { - apiProvider: 'anthropic', - apiKey: 'test-key', - apiModelId: 'claude-3' + beforeEach(() => { + jest.clearAllMocks() + + // Mock the API handler with a completePrompt method + ;(buildApiHandler as jest.Mock).mockReturnValue({ + completePrompt: jest.fn().mockResolvedValue('Enhanced prompt'), + createMessage: jest.fn(), + getModel: jest.fn().mockReturnValue({ + id: 'test-model', + info: { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false } - await expect(enhancePrompt(nonOpenRouterConfig, 'test')).rejects.toThrow('Prompt enhancement is only available with OpenRouter') + }) + } as unknown as SingleCompletionHandler) + }) + + it('enhances prompt using default enhancement prompt when no custom prompt provided', async () => { + const result = await enhancePrompt(mockApiConfig, 'Test prompt') + + expect(result).toBe('Enhanced prompt') + const handler = buildApiHandler(mockApiConfig) + expect((handler as any).completePrompt).toHaveBeenCalledWith( + `${defaultPrompts.enhance}\n\nTest prompt` + ) + }) + + it('enhances prompt using custom enhancement prompt when provided', async () => { + const customEnhancePrompt = 'You are a custom prompt enhancer' + + const result = await enhancePrompt(mockApiConfig, 'Test prompt', customEnhancePrompt) + + expect(result).toBe('Enhanced prompt') + const handler = buildApiHandler(mockApiConfig) + expect((handler as any).completePrompt).toHaveBeenCalledWith( + `${customEnhancePrompt}\n\nTest prompt` + ) + }) + + it('throws error for empty prompt input', async () => { + await expect(enhancePrompt(mockApiConfig, '')).rejects.toThrow('No prompt text provided') + }) + + it('throws error for missing API configuration', async () => { + await expect(enhancePrompt({} as ApiConfiguration, 'Test prompt')).rejects.toThrow('No valid API configuration provided') + }) + + it('throws error for API provider that does not support prompt enhancement', async () => { + (buildApiHandler as jest.Mock).mockReturnValue({ + // No completePrompt method + createMessage: jest.fn(), + getModel: jest.fn().mockReturnValue({ + id: 'test-model', + info: { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false + } + }) }) - it('should enhance a valid prompt', async () => { - const inputPrompt = 'Write a function to sort an array' - const enhancedPrompt = 'Write a TypeScript function that implements an efficient sorting algorithm for a generic array, including error handling and type safety' - - mockHandler.completePrompt.mockResolvedValue(enhancedPrompt) + await expect(enhancePrompt(mockApiConfig, 'Test prompt')).rejects.toThrow('The selected API provider does not support prompt enhancement') + }) - const result = await enhancePrompt(mockApiConfig, inputPrompt) + it('uses appropriate model based on provider', async () => { + const openRouterConfig: ApiConfiguration = { + apiProvider: 'openrouter', + openRouterApiKey: 'test-key', + openRouterModelId: 'test-model' + } - expect(result).toBe(enhancedPrompt) - expect(buildApiHandler).toHaveBeenCalledWith(mockApiConfig) - expect(mockHandler.completePrompt).toHaveBeenCalledWith( - expect.stringContaining(inputPrompt) - ) - }) + // Mock successful enhancement + ;(buildApiHandler as jest.Mock).mockReturnValue({ + completePrompt: jest.fn().mockResolvedValue('Enhanced prompt'), + createMessage: jest.fn(), + getModel: jest.fn().mockReturnValue({ + id: 'test-model', + info: { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false + } + }) + } as unknown as SingleCompletionHandler) - it('should throw error when no prompt text is provided', async () => { - await expect(enhancePrompt(mockApiConfig, '')).rejects.toThrow('No prompt text provided') - expect(mockHandler.completePrompt).not.toHaveBeenCalled() - }) + const result = await enhancePrompt(openRouterConfig, 'Test prompt') + + expect(buildApiHandler).toHaveBeenCalledWith(openRouterConfig) + expect(result).toBe('Enhanced prompt') + }) - it('should pass through API errors', async () => { - const inputPrompt = 'Test prompt' - mockHandler.completePrompt.mockRejectedValue('API error') + it('propagates API errors', async () => { + (buildApiHandler as jest.Mock).mockReturnValue({ + completePrompt: jest.fn().mockRejectedValue(new Error('API Error')), + createMessage: jest.fn(), + getModel: jest.fn().mockReturnValue({ + id: 'test-model', + info: { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false + } + }) + } as unknown as SingleCompletionHandler) - await expect(enhancePrompt(mockApiConfig, inputPrompt)).rejects.toBe('API error') - }) - - it('should pass the correct prompt format to the API', async () => { - const inputPrompt = 'Test prompt' - mockHandler.completePrompt.mockResolvedValue('Enhanced test prompt') - - await enhancePrompt(mockApiConfig, inputPrompt) - - expect(mockHandler.completePrompt).toHaveBeenCalledWith( - 'Generate an enhanced version of this prompt (reply with only the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes):\n\nTest prompt' - ) - }) + await expect(enhancePrompt(mockApiConfig, 'Test prompt')).rejects.toThrow('API Error') + }) }) \ No newline at end of file diff --git a/src/utils/enhance-prompt.ts b/src/utils/enhance-prompt.ts index d543e53..d7c7440 100644 --- a/src/utils/enhance-prompt.ts +++ b/src/utils/enhance-prompt.ts @@ -1,26 +1,27 @@ import { ApiConfiguration } from "../shared/api" -import { buildApiHandler } from "../api" -import { OpenRouterHandler } from "../api/providers/openrouter" +import { buildApiHandler, SingleCompletionHandler } from "../api" +import { defaultPrompts } from "../shared/modes" /** - * Enhances a prompt using the OpenRouter API without creating a full Cline instance or task history. + * Enhances a prompt using the configured API without creating a full Cline instance or task history. * This is a lightweight alternative that only uses the API's completion functionality. */ -export async function enhancePrompt(apiConfiguration: ApiConfiguration, promptText: string): Promise { +export async function enhancePrompt(apiConfiguration: ApiConfiguration, promptText: string, enhancePrompt?: string): Promise { if (!promptText) { throw new Error("No prompt text provided") } - if (apiConfiguration.apiProvider !== "openrouter") { - throw new Error("Prompt enhancement is only available with OpenRouter") + if (!apiConfiguration || !apiConfiguration.apiProvider) { + throw new Error("No valid API configuration provided") } const handler = buildApiHandler(apiConfiguration) - // Type guard to check if handler is OpenRouterHandler - if (!(handler instanceof OpenRouterHandler)) { - throw new Error("Expected OpenRouter handler") + // Check if handler supports single completions + if (!('completePrompt' in handler)) { + throw new Error("The selected API provider does not support prompt enhancement") } - const prompt = `Generate an enhanced version of this prompt (reply with only the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes):\n\n${promptText}` - return handler.completePrompt(prompt) + const enhancePromptText = enhancePrompt ?? defaultPrompts.enhance + const prompt = `${enhancePromptText}\n\n${promptText}` + return (handler as SingleCompletionHandler).completePrompt(prompt) } \ No newline at end of file diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index f06453a..ca2aaad 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -8,12 +8,14 @@ import WelcomeView from "./components/welcome/WelcomeView" import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext" import { vscode } from "./utils/vscode" import McpView from "./components/mcp/McpView" +import PromptsView from "./components/prompts/PromptsView" const AppContent = () => { const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState() const [showSettings, setShowSettings] = useState(false) const [showHistory, setShowHistory] = useState(false) const [showMcp, setShowMcp] = useState(false) + const [showPrompts, setShowPrompts] = useState(false) const [showAnnouncement, setShowAnnouncement] = useState(false) const handleMessage = useCallback((e: MessageEvent) => { @@ -25,21 +27,31 @@ const AppContent = () => { setShowSettings(true) setShowHistory(false) setShowMcp(false) + setShowPrompts(false) break case "historyButtonClicked": setShowSettings(false) setShowHistory(true) setShowMcp(false) + setShowPrompts(false) break case "mcpButtonClicked": setShowSettings(false) setShowHistory(false) setShowMcp(true) + setShowPrompts(false) + break + case "promptsButtonClicked": + setShowSettings(false) + setShowHistory(false) + setShowMcp(false) + setShowPrompts(true) break case "chatButtonClicked": setShowSettings(false) setShowHistory(false) setShowMcp(false) + setShowPrompts(false) break } break @@ -68,14 +80,16 @@ const AppContent = () => { {showSettings && setShowSettings(false)} />} {showHistory && setShowHistory(false)} />} {showMcp && setShowMcp(false)} />} + {showPrompts && setShowPrompts(false)} />} {/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */} { setShowSettings(false) setShowMcp(false) + setShowPrompts(false) setShowHistory(true) }} - isHidden={showSettings || showHistory || showMcp} + isHidden={showSettings || showHistory || showMcp || showPrompts} showAnnouncement={showAnnouncement} hideAnnouncement={() => { setShowAnnouncement(false) diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 5501037..04b4974 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -29,100 +29,39 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => { style={{ position: "absolute", top: "8px", right: "8px" }}> +

+ 🎉{" "}Introducing Roo Cline v{minorVersion} +

+

- 🎉{" "}New in Cline v{minorVersion} + Agent Modes Customization

-

Add custom tools to Cline using MCP!

- The Model Context Protocol allows agents like Cline to plug and play custom tools,{" "} - - e.g. a web-search tool or GitHub tool. - -

-

- You can add and configure MCP servers by clicking the new{" "} - icon in the menu bar. -

-

- To take things a step further, Cline also has the ability to create custom tools for himself. Just say - "add a tool that..." and watch as he builds and installs new capabilities specific to{" "} - your workflow. For example: + Click the new icon in the menu bar to open the Prompts Settings and customize Agent Modes for new levels of productivity.

    -
  • "...fetches Jira tickets": Get ticket ACs and put Cline to work
  • -
  • "...manages AWS EC2s": Check server metrics and scale up or down
  • -
  • "...pulls PagerDuty incidents": Pulls details to help Cline fix bugs
  • +
  • Tailor how Roo Cline behaves in different modes: Code, Architect, and Ask.
  • +
  • Preview and verify your changes using the Preview System Prompt button.
- Cline handles everything from creating the MCP server to installing it in the extension, ready to use in - future tasks. The servers are saved to ~/Documents/Cline/MCP so you can easily share them - with others too.{" "}

+ +

+ Prompt Enhancement Configuration +

- Try it yourself by asking Cline to "add a tool that gets the latest npm docs", or - - see a demo of MCP in action here. - + Now available for all providers! Access it directly in the chat box by clicking the sparkle icon next to the input field. From there, you can customize the enhancement logic and provider to best suit your workflow. +

    +
  • Customize how prompts are enhanced for better results in your workflow.
  • +
  • Use the sparkle icon in the chat box to select a API configuration and provider (e.g., GPT-4) and configure your own enhancement logic.
  • +
  • Test your changes instantly with the Preview Prompt Enhancement tool.
  • +

- {/*
    -
  • - OpenRouter now supports prompt caching! They also have much higher rate limits than other providers, - so I recommend trying them out. -
    - {!apiConfiguration?.openRouterApiKey && ( - - Get OpenRouter API Key - - )} - {apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && ( - { - vscode.postMessage({ - type: "apiConfiguration", - apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" }, - }) - }} - style={{ - transform: "scale(0.85)", - transformOrigin: "left center", - margin: "4px -30px 2px 0", - }}> - Switch to OpenRouter - - )} -
  • -
  • - Edit Cline's changes before accepting! When he creates or edits a file, you can modify his - changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in - the center to undo "{"// rest of code here"}" shenanigans) -
  • -
  • - New search_files tool that lets Cline perform regex searches in your project, letting - him refactor code, address TODOs and FIXMEs, remove dead code, and more! -
  • -
  • - When Cline runs commands, you can now type directly in the terminal (+ support for Python - environments) -
  • -
*/} -
-

- Join - - discord.gg/cline + +

+ We're very excited to see what you build with this new feature! Join us at + + reddit.com/r/roocline - for more updates! + to discuss and share feedback.

) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index b612b97..c7cddb5 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -49,7 +49,7 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState() + const { filePaths, currentApiConfigName, listApiConfigMeta } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) @@ -69,8 +69,10 @@ const ChatTextArea = forwardRef( useEffect(() => { const messageHandler = (event: MessageEvent) => { const message = event.data - if (message.type === 'enhancedPrompt' && message.text) { - setInputValue(message.text) + if (message.type === 'enhancedPrompt') { + if (message.text) { + setInputValue(message.text) + } setIsEnhancingPrompt(false) } else if (message.type === 'commitSearchResults') { const commits = message.commits.map((commit: any) => ({ @@ -767,19 +769,25 @@ const ChatTextArea = forwardRef(
- {apiConfiguration?.apiProvider === "openrouter" && ( -
- {isEnhancingPrompt && Enhancing prompt...} - !textAreaDisabled && handleEnhancePrompt()} - style={{ fontSize: 16.5 }} - /> -
- )} +
+ {isEnhancingPrompt ? ( + + ) : ( + !textAreaDisabled && handleEnhancePrompt()} + style={{ fontSize: 16.5 }} + /> + )} +
!shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} /> !textAreaDisabled && onSend()} style={{ fontSize: 15 }} /> diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx index 5bf2590..cb96e9d 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom'; import ChatTextArea from '../ChatTextArea'; import { useExtensionState } from '../../../context/ExtensionStateContext'; import { vscode } from '../../../utils/vscode'; +import { codeMode } from '../../../../../src/shared/modes'; // Mock modules jest.mock('../../../utils/vscode', () => ({ @@ -32,6 +33,8 @@ describe('ChatTextArea', () => { selectedImages: [], setSelectedImages: jest.fn(), onHeightChange: jest.fn(), + mode: codeMode, + setMode: jest.fn(), }; beforeEach(() => { @@ -46,37 +49,9 @@ describe('ChatTextArea', () => { }); describe('enhance prompt button', () => { - it('should show enhance prompt button only when apiProvider is openrouter', () => { - // Test with non-openrouter provider - (useExtensionState as jest.Mock).mockReturnValue({ - filePaths: [], - apiConfiguration: { - apiProvider: 'anthropic', - }, - }); - - const { rerender } = render(); - expect(screen.queryByTestId('enhance-prompt-button')).not.toBeInTheDocument(); - - // Test with openrouter provider - (useExtensionState as jest.Mock).mockReturnValue({ - filePaths: [], - apiConfiguration: { - apiProvider: 'openrouter', - }, - }); - - rerender(); - const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i }); - expect(enhanceButton).toBeInTheDocument(); - }); - it('should be disabled when textAreaDisabled is true', () => { (useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], - apiConfiguration: { - apiProvider: 'openrouter', - }, }); render(); @@ -137,7 +112,8 @@ describe('ChatTextArea', () => { const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i }); fireEvent.click(enhanceButton); - expect(screen.getByText('Enhancing prompt...')).toBeInTheDocument(); + const loadingSpinner = screen.getByText('', { selector: '.codicon-loading' }); + expect(loadingSpinner).toBeInTheDocument(); }); }); diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx new file mode 100644 index 0000000..af4e199 --- /dev/null +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -0,0 +1,344 @@ +import { VSCodeButton, VSCodeTextArea, VSCodeDropdown, VSCodeOption, VSCodeDivider } from "@vscode/webview-ui-toolkit/react" +import { useExtensionState } from "../../context/ExtensionStateContext" +import { defaultPrompts, askMode, codeMode, architectMode, Mode } from "../../../../src/shared/modes" +import { vscode } from "../../utils/vscode" +import React, { useState, useEffect } from "react" + +type PromptsViewProps = { + onDone: () => void +} + +const PromptsView = ({ onDone }: PromptsViewProps) => { + const { customPrompts, listApiConfigMeta, enhancementApiConfigId, setEnhancementApiConfigId, mode } = useExtensionState() + const [testPrompt, setTestPrompt] = useState('') + const [isEnhancing, setIsEnhancing] = useState(false) + const [activeTab, setActiveTab] = useState(mode) + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [selectedPromptContent, setSelectedPromptContent] = useState('') + const [selectedPromptTitle, setSelectedPromptTitle] = useState('') + + useEffect(() => { + const handler = (event: MessageEvent) => { + const message = event.data + if (message.type === 'enhancedPrompt') { + if (message.text) { + setTestPrompt(message.text) + } + setIsEnhancing(false) + } else if (message.type === 'systemPrompt') { + if (message.text) { + setSelectedPromptContent(message.text) + setSelectedPromptTitle(`System Prompt (${message.mode} mode)`) + setIsDialogOpen(true) + } + } + } + + window.addEventListener('message', handler) + return () => window.removeEventListener('message', handler) + }, []) + + type PromptMode = keyof typeof defaultPrompts + + const updatePromptValue = (promptMode: PromptMode, value: string) => { + vscode.postMessage({ + type: "updatePrompt", + promptMode, + customPrompt: value + }) + } + + const handlePromptChange = (mode: PromptMode, e: Event | React.FormEvent) => { + const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value + updatePromptValue(mode, value) + } + + const handleReset = (mode: PromptMode) => { + const defaultValue = defaultPrompts[mode] + updatePromptValue(mode, defaultValue) + } + + const getPromptValue = (mode: PromptMode): string => { + if (mode === 'enhance') { + return customPrompts?.enhance ?? defaultPrompts.enhance + } + return customPrompts?.[mode] ?? defaultPrompts[mode] + } + + const handleTestEnhancement = () => { + if (!testPrompt.trim()) return + + setIsEnhancing(true) + vscode.postMessage({ + type: "enhancePrompt", + text: testPrompt + }) + } + + return ( +
+
+

Prompts

+ Done +
+ +
+

Agent Modes

+ +
+ Customize Cline's prompt in each mode. The rest of the system prompt will be automatically appended. Click the button to preview the full prompt. Leave empty or click the reset button to use the default. +
+ +
+
+ {[ + { id: codeMode, label: 'Code' }, + { id: architectMode, label: 'Architect' }, + { id: askMode, label: 'Ask' }, + ].map((tab, index) => ( + + + {index < 2 && ( + | + )} + + ))} +
+ handleReset(activeTab as any)} + data-testid="reset-prompt-button" + title="Revert to default" + > + + +
+ +
+ {activeTab === codeMode && ( + handlePromptChange(codeMode, e)} + rows={4} + resize="vertical" + style={{ width: "100%" }} + data-testid="code-prompt-textarea" + /> + )} + {activeTab === architectMode && ( + handlePromptChange(architectMode, e)} + rows={4} + resize="vertical" + style={{ width: "100%" }} + data-testid="architect-prompt-textarea" + /> + )} + {activeTab === askMode && ( + handlePromptChange(askMode, e)} + rows={4} + resize="vertical" + style={{ width: "100%" }} + data-testid="ask-prompt-textarea" + /> + )} +
+
+ { + vscode.postMessage({ + type: "getSystemPrompt", + mode: activeTab + }) + }} + data-testid="preview-prompt-button" + > + Preview System Prompt + +
+ +

Prompt Enhancement

+ +
+
+
+
+
API Configuration
+
+ You can select an API configuration to always use for enhancing prompts, or just use whatever is currently selected +
+
+ { + const value = e.detail?.target?.value || e.target?.value + setEnhancementApiConfigId(value) + vscode.postMessage({ + type: "enhancementApiConfigId", + text: value + }) + }} + style={{ width: "300px" }} + > + Use currently selected API configuration + {(listApiConfigMeta || []).map((config) => ( + + {config.name} + + ))} + +
+ +
+
Enhancement Prompt
+
+ handleReset('enhance')} title="Revert to default"> + + +
+
+ handlePromptChange('enhance', e)} + rows={4} + resize="vertical" + style={{ width: "100%" }} + /> + +
+ setTestPrompt((e.target as HTMLTextAreaElement).value)} + placeholder="Enter a prompt to test the enhancement" + rows={3} + resize="vertical" + style={{ width: "100%" }} + data-testid="test-prompt-textarea" + /> +
+ + Preview Prompt Enhancement + +
+
+
+
+ + {/* Bottom padding */} +
+
+ + {isDialogOpen && ( +
+
+
+

{selectedPromptTitle}

+ setIsDialogOpen(false)}> + + +
+ +
+							{selectedPromptContent}
+						
+
+
+ )} +
+ ) +} + +export default PromptsView \ No newline at end of file diff --git a/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx b/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx new file mode 100644 index 0000000..530de12 --- /dev/null +++ b/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx @@ -0,0 +1,135 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import PromptsView from '../PromptsView' +import { ExtensionStateContext } from '../../../context/ExtensionStateContext' +import { vscode } from '../../../utils/vscode' +import { defaultPrompts } from '../../../../../src/shared/modes' + +// Mock vscode API +jest.mock('../../../utils/vscode', () => ({ + vscode: { + postMessage: jest.fn() + } +})) + +const mockExtensionState = { + customPrompts: {}, + listApiConfigMeta: [ + { id: 'config1', name: 'Config 1' }, + { id: 'config2', name: 'Config 2' } + ], + enhancementApiConfigId: '', + setEnhancementApiConfigId: jest.fn(), + mode: 'code' +} + +const renderPromptsView = (props = {}) => { + const mockOnDone = jest.fn() + return render( + + + + ) +} + +describe('PromptsView', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders all mode tabs', () => { + renderPromptsView() + expect(screen.getByTestId('code-tab')).toBeInTheDocument() + expect(screen.getByTestId('ask-tab')).toBeInTheDocument() + expect(screen.getByTestId('architect-tab')).toBeInTheDocument() + }) + + it('defaults to current mode as active tab', () => { + renderPromptsView({ mode: 'ask' }) + + const codeTab = screen.getByTestId('code-tab') + const askTab = screen.getByTestId('ask-tab') + const architectTab = screen.getByTestId('architect-tab') + + expect(askTab).toHaveAttribute('data-active', 'true') + expect(codeTab).toHaveAttribute('data-active', 'false') + expect(architectTab).toHaveAttribute('data-active', 'false') + }) + + it('switches between tabs correctly', () => { + renderPromptsView({ mode: 'code' }) + + const codeTab = screen.getByTestId('code-tab') + const askTab = screen.getByTestId('ask-tab') + const architectTab = screen.getByTestId('architect-tab') + + // Initial state matches current mode (code) + expect(codeTab).toHaveAttribute('data-active', 'true') + expect(askTab).toHaveAttribute('data-active', 'false') + expect(architectTab).toHaveAttribute('data-active', 'false') + expect(architectTab).toHaveAttribute('data-active', 'false') + + // Click Ask tab + fireEvent.click(askTab) + expect(askTab).toHaveAttribute('data-active', 'true') + expect(codeTab).toHaveAttribute('data-active', 'false') + expect(architectTab).toHaveAttribute('data-active', 'false') + + // Click Architect tab + fireEvent.click(architectTab) + expect(architectTab).toHaveAttribute('data-active', 'true') + expect(askTab).toHaveAttribute('data-active', 'false') + expect(codeTab).toHaveAttribute('data-active', 'false') + }) + + it('handles prompt changes correctly', () => { + renderPromptsView() + + const textarea = screen.getByTestId('code-prompt-textarea') + fireEvent(textarea, new CustomEvent('change', { + detail: { + target: { + value: 'New prompt value' + } + } + })) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'updatePrompt', + promptMode: 'code', + customPrompt: 'New prompt value' + }) + }) + + it('resets prompt to default value', () => { + renderPromptsView() + + const resetButton = screen.getByTestId('reset-prompt-button') + fireEvent.click(resetButton) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'updatePrompt', + promptMode: 'code', + customPrompt: defaultPrompts.code + }) + }) + + it('handles API configuration selection', () => { + renderPromptsView() + + const dropdown = screen.getByTestId('api-config-dropdown') + fireEvent(dropdown, new CustomEvent('change', { + detail: { + target: { + value: 'config1' + } + } + })) + + expect(mockExtensionState.setEnhancementApiConfigId).toHaveBeenCalledWith('config1') + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'enhancementApiConfigId', + text: 'config1' + }) + }) +}) \ No newline at end of file diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ddfe068..24596ca 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -17,7 +17,7 @@ import { checkExistKey } from "../../../src/shared/checkExistApiConfig" import { Mode } from "../../../src/core/prompts/types" -import { codeMode } from "../../../src/shared/modes" +import { codeMode, CustomPrompts } from "../../../src/shared/modes" export interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean @@ -60,6 +60,9 @@ export interface ExtensionStateContextType extends ExtensionState { onUpdateApiConfig: (apiConfig: ApiConfiguration) => void mode: Mode setMode: (value: Mode) => void + setCustomPrompts: (value: CustomPrompts) => void + enhancementApiConfigId?: string + setEnhancementApiConfigId: (value: string) => void } export const ExtensionStateContext = createContext(undefined) @@ -86,6 +89,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode currentApiConfigName: 'default', listApiConfigMeta: [], mode: codeMode, + customPrompts: {}, + enhancementApiConfigId: '', }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -230,6 +235,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setListApiConfigMeta, onUpdateApiConfig, setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })), + setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })), + setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })), } return {children} From 092a121a37072ce9be97aa17a67e620f66f60d4a Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 14 Jan 2025 07:55:16 -0500 Subject: [PATCH 05/15] Refactor to support more sections in the future --- src/core/prompts/architect.ts | 6 +- src/core/prompts/ask.ts | 6 +- src/core/prompts/code.ts | 6 +- src/core/prompts/system.ts | 3 +- src/core/webview/ClineProvider.ts | 30 ++++- .../webview/__tests__/ClineProvider.test.ts | 12 -- src/shared/ExtensionMessage.ts | 2 +- src/shared/WebviewMessage.ts | 5 +- src/shared/modes.ts | 22 +++- .../src/components/prompts/PromptsView.tsx | 110 +++++++++--------- .../prompts/__tests__/PromptsView.test.tsx | 5 +- .../src/context/ExtensionStateContext.tsx | 4 +- 12 files changed, 116 insertions(+), 95 deletions(-) diff --git a/src/core/prompts/architect.ts b/src/core/prompts/architect.ts index d0dc8c8..8bfb931 100644 --- a/src/core/prompts/architect.ts +++ b/src/core/prompts/architect.ts @@ -1,4 +1,4 @@ -import { architectMode, defaultPrompts } from "../../shared/modes" +import { architectMode, defaultPrompts, PromptComponent } from "../../shared/modes" import { getToolDescriptionsForMode } from "./tools" import { getRulesSection, @@ -20,8 +20,8 @@ export const ARCHITECT_PROMPT = async ( mcpHub?: McpHub, diffStrategy?: DiffStrategy, browserViewportSize?: string, - customPrompt?: string, -) => `${customPrompt || defaultPrompts[architectMode]} + customPrompt?: PromptComponent, +) => `${customPrompt?.roleDefinition || defaultPrompts[architectMode].roleDefinition} ${getSharedToolUseSection()} diff --git a/src/core/prompts/ask.ts b/src/core/prompts/ask.ts index 2794a72..bc86d3e 100644 --- a/src/core/prompts/ask.ts +++ b/src/core/prompts/ask.ts @@ -1,4 +1,4 @@ -import { Mode, askMode, defaultPrompts } from "../../shared/modes" +import { Mode, askMode, defaultPrompts, PromptComponent } from "../../shared/modes" import { getToolDescriptionsForMode } from "./tools" import { getRulesSection, @@ -21,8 +21,8 @@ export const ASK_PROMPT = async ( mcpHub?: McpHub, diffStrategy?: DiffStrategy, browserViewportSize?: string, - customPrompt?: string, -) => `${customPrompt || defaultPrompts[askMode]} + customPrompt?: PromptComponent, +) => `${customPrompt?.roleDefinition || defaultPrompts[askMode].roleDefinition} ${getSharedToolUseSection()} diff --git a/src/core/prompts/code.ts b/src/core/prompts/code.ts index 3bf8854..7d2a7f3 100644 --- a/src/core/prompts/code.ts +++ b/src/core/prompts/code.ts @@ -1,4 +1,4 @@ -import { Mode, codeMode, defaultPrompts } from "../../shared/modes" +import { Mode, codeMode, defaultPrompts, PromptComponent } from "../../shared/modes" import { getToolDescriptionsForMode } from "./tools" import { getRulesSection, @@ -21,8 +21,8 @@ export const CODE_PROMPT = async ( mcpHub?: McpHub, diffStrategy?: DiffStrategy, browserViewportSize?: string, - customPrompt?: string, -) => `${customPrompt || defaultPrompts[codeMode]} + customPrompt?: PromptComponent, +) => `${customPrompt?.roleDefinition || defaultPrompts[codeMode].roleDefinition} ${getSharedToolUseSection()} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 9e61d7b..d4c2c8e 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -4,6 +4,7 @@ import { CODE_PROMPT } from "./code" import { ARCHITECT_PROMPT } from "./architect" import { ASK_PROMPT } from "./ask" import { Mode, codeMode, architectMode, askMode } from "./modes" +import { CustomPrompts } from "../../shared/modes" import fs from 'fs/promises' import path from 'path' @@ -64,7 +65,7 @@ export const SYSTEM_PROMPT = async ( diffStrategy?: DiffStrategy, browserViewportSize?: string, mode: Mode = codeMode, - customPrompts?: { ask?: string; code?: string; architect?: string; enhance?: string }, + customPrompts?: CustomPrompts, ) => { switch (mode) { case architectMode: diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4b0d9ea..069301b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -16,7 +16,7 @@ import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api" import { findLast } from "../../shared/array" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { HistoryItem } from "../../shared/HistoryItem" -import { WebviewMessage } from "../../shared/WebviewMessage" +import { WebviewMessage, PromptMode } from "../../shared/WebviewMessage" import { defaultPrompts } from "../../shared/modes" import { SYSTEM_PROMPT, addCustomInstructions } from "../prompts/system" import { fileExistsAtPath } from "../../utils/fs" @@ -731,6 +731,32 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.postStateToWebview() break + case "updateEnhancedPrompt": + if (message.text !== undefined) { + const existingPrompts = await this.getGlobalState("customPrompts") || {} + + const updatedPrompts = { + ...existingPrompts, + enhance: message.text + } + + await this.updateGlobalState("customPrompts", updatedPrompts) + + // Get current state and explicitly include customPrompts + const currentState = await this.getState() + + const stateWithPrompts = { + ...currentState, + customPrompts: updatedPrompts + } + + // Post state with prompts + this.view?.webview.postMessage({ + type: "state", + state: stateWithPrompts + }) + } + break case "updatePrompt": if (message.promptMode && message.customPrompt !== undefined) { const existingPrompts = await this.getGlobalState("customPrompts") || {} @@ -866,7 +892,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { try { const { apiConfiguration, customPrompts, customInstructions, preferredLanguage, browserViewportSize, mcpEnabled } = await this.getState() const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || '' - + const fullPrompt = await SYSTEM_PROMPT( cwd, apiConfiguration.openRouterModelInfo?.supportsComputerUse ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 1d8f21c..4a89eb3 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -838,18 +838,6 @@ describe('ClineProvider', () => { ); }); - test('returns empty prompt for enhance mode', async () => { - const enhanceHandler = getMessageHandler(); - await enhanceHandler({ type: 'getSystemPrompt', mode: 'enhance' }) - - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'systemPrompt', - text: '' - }) - ) - }) - test('handles errors gracefully', async () => { // Mock SYSTEM_PROMPT to throw an error const systemPrompt = require('../../prompts/system') diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 4e5624c..e09a1cc 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -48,7 +48,7 @@ export interface ExtensionMessage { mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ApiConfigMeta[] - mode?: Mode | 'enhance' + mode?: Mode } export interface ApiConfigMeta { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 58c181b..5960512 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,5 +1,5 @@ import { ApiConfiguration, ApiProvider } from "./api" -import { Mode } from "./modes" +import { Mode, PromptComponent } from "./modes" export type PromptMode = Mode | 'enhance' @@ -66,6 +66,7 @@ export interface WebviewMessage { | "setApiConfigPassword" | "mode" | "updatePrompt" + | "updateEnhancedPrompt" | "getSystemPrompt" | "systemPrompt" | "enhancementApiConfigId" @@ -83,7 +84,7 @@ export interface WebviewMessage { alwaysAllow?: boolean mode?: Mode promptMode?: PromptMode - customPrompt?: string + customPrompt?: PromptComponent dataUrls?: string[] values?: Record query?: string diff --git a/src/shared/modes.ts b/src/shared/modes.ts index c7dc7a7..ad0d462 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -4,16 +4,26 @@ export const askMode = 'ask' as const; export type Mode = typeof codeMode | typeof architectMode | typeof askMode; +export type PromptComponent = { + roleDefinition?: string; +} + export type CustomPrompts = { - ask?: string; - code?: string; - architect?: string; + ask?: PromptComponent; + code?: PromptComponent; + architect?: PromptComponent; enhance?: string; } export const defaultPrompts = { - [askMode]: "You are Cline, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. You can analyze code, explain concepts, and access external resources while maintaining a read-only approach to the codebase. Make sure to answer the user's questions and don't rush to switch to implementing code.", - [codeMode]: "You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.", - [architectMode]: "You are Cline, a software architecture expert specializing in analyzing codebases, identifying patterns, and providing high-level technical guidance. You excel at understanding complex systems, evaluating architectural decisions, and suggesting improvements while maintaining a read-only approach to the codebase. Make sure to help the user come up with a solid implementation plan for their project and don't rush to switch to implementing code.", + [askMode]: { + roleDefinition: "You are Cline, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. You can analyze code, explain concepts, and access external resources while maintaining a read-only approach to the codebase. Make sure to answer the user's questions and don't rush to switch to implementing code.", + }, + [codeMode]: { + roleDefinition: "You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.", + }, + [architectMode]: { + roleDefinition: "You are Cline, a software architecture expert specializing in analyzing codebases, identifying patterns, and providing high-level technical guidance. You excel at understanding complex systems, evaluating architectural decisions, and suggesting improvements while maintaining a read-only approach to the codebase. Make sure to help the user come up with a solid implementation plan for their project and don't rush to switch to implementing code.", + }, enhance: "Generate an enhanced version of this prompt (reply with only the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes):" } as const; \ No newline at end of file diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index af4e199..6b7bdb4 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -1,6 +1,6 @@ import { VSCodeButton, VSCodeTextArea, VSCodeDropdown, VSCodeOption, VSCodeDivider } from "@vscode/webview-ui-toolkit/react" import { useExtensionState } from "../../context/ExtensionStateContext" -import { defaultPrompts, askMode, codeMode, architectMode, Mode } from "../../../../src/shared/modes" +import { defaultPrompts, askMode, codeMode, architectMode, Mode, PromptComponent } from "../../../../src/shared/modes" import { vscode } from "../../utils/vscode" import React, { useState, useEffect } from "react" @@ -8,6 +8,12 @@ type PromptsViewProps = { onDone: () => void } +const AGENT_MODES = [ + { id: codeMode, label: 'Code' }, + { id: architectMode, label: 'Architect' }, + { id: askMode, label: 'Ask' }, +] as const + const PromptsView = ({ onDone }: PromptsViewProps) => { const { customPrompts, listApiConfigMeta, enhancementApiConfigId, setEnhancementApiConfigId, mode } = useExtensionState() const [testPrompt, setTestPrompt] = useState('') @@ -38,31 +44,47 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { return () => window.removeEventListener('message', handler) }, []) - type PromptMode = keyof typeof defaultPrompts + type AgentMode = typeof codeMode | typeof architectMode | typeof askMode - const updatePromptValue = (promptMode: PromptMode, value: string) => { + const updateAgentPrompt = (mode: AgentMode, promptData: PromptComponent) => { vscode.postMessage({ type: "updatePrompt", - promptMode, - customPrompt: value + promptMode: mode, + customPrompt: promptData }) } - const handlePromptChange = (mode: PromptMode, e: Event | React.FormEvent) => { + const updateEnhancePrompt = (value: string | undefined) => { + vscode.postMessage({ + type: "updateEnhancedPrompt", + text: value + }) + } + + const handleAgentPromptChange = (mode: AgentMode, e: Event | React.FormEvent) => { const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value - updatePromptValue(mode, value) + updateAgentPrompt(mode, { roleDefinition: value.trim() || undefined }) } - const handleReset = (mode: PromptMode) => { - const defaultValue = defaultPrompts[mode] - updatePromptValue(mode, defaultValue) + const handleEnhancePromptChange = (e: Event | React.FormEvent) => { + const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value + updateEnhancePrompt(value.trim() || undefined) } - const getPromptValue = (mode: PromptMode): string => { - if (mode === 'enhance') { - return customPrompts?.enhance ?? defaultPrompts.enhance - } - return customPrompts?.[mode] ?? defaultPrompts[mode] + const handleAgentReset = (mode: AgentMode) => { + updateAgentPrompt(mode, { roleDefinition: undefined }) + } + + const handleEnhanceReset = () => { + updateEnhancePrompt(undefined) + } + + const getAgentPromptValue = (mode: AgentMode): string => { + return customPrompts?.[mode]?.roleDefinition ?? defaultPrompts[mode].roleDefinition + } + + const getEnhancePromptValue = (): string => { + return customPrompts?.enhance ?? defaultPrompts.enhance } const handleTestEnhancement = () => { @@ -117,11 +139,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { marginBottom: '12px' }}>
- {[ - { id: codeMode, label: 'Code' }, - { id: architectMode, label: 'Architect' }, - { id: askMode, label: 'Ask' }, - ].map((tab, index) => ( + {AGENT_MODES.map((tab, index) => ( - {index < 2 && ( + {index < AGENT_MODES.length - 1 && ( | )} @@ -150,7 +168,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
handleReset(activeTab as any)} + onClick={() => handleAgentReset(activeTab)} data-testid="reset-prompt-button" title="Revert to default" > @@ -159,38 +177,16 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
- {activeTab === codeMode && ( - handlePromptChange(codeMode, e)} - rows={4} - resize="vertical" - style={{ width: "100%" }} - data-testid="code-prompt-textarea" - /> - )} - {activeTab === architectMode && ( - handlePromptChange(architectMode, e)} - rows={4} - resize="vertical" - style={{ width: "100%" }} - data-testid="architect-prompt-textarea" - /> - )} - {activeTab === askMode && ( - handlePromptChange(askMode, e)} - rows={4} - resize="vertical" - style={{ width: "100%" }} - data-testid="ask-prompt-textarea" - /> - )} + handleAgentPromptChange(activeTab, e)} + rows={4} + resize="vertical" + style={{ width: "100%" }} + data-testid={`${activeTab}-prompt-textarea`} + />
-
+
{ @@ -241,14 +237,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
Enhancement Prompt
- handleReset('enhance')} title="Revert to default"> +
handlePromptChange('enhance', e)} + value={getEnhancePromptValue()} + onChange={handleEnhancePromptChange} rows={4} resize="vertical" style={{ width: "100%" }} @@ -267,7 +263,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
diff --git a/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx b/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx index 530de12..311702f 100644 --- a/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx +++ b/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx @@ -3,7 +3,6 @@ import '@testing-library/jest-dom' import PromptsView from '../PromptsView' import { ExtensionStateContext } from '../../../context/ExtensionStateContext' import { vscode } from '../../../utils/vscode' -import { defaultPrompts } from '../../../../../src/shared/modes' // Mock vscode API jest.mock('../../../utils/vscode', () => ({ @@ -97,7 +96,7 @@ describe('PromptsView', () => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'updatePrompt', promptMode: 'code', - customPrompt: 'New prompt value' + customPrompt: { roleDefinition: 'New prompt value' } }) }) @@ -110,7 +109,7 @@ describe('PromptsView', () => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: 'updatePrompt', promptMode: 'code', - customPrompt: defaultPrompts.code + customPrompt: { roleDefinition: undefined } }) }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 24596ca..8ecc488 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -17,7 +17,7 @@ import { checkExistKey } from "../../../src/shared/checkExistApiConfig" import { Mode } from "../../../src/core/prompts/types" -import { codeMode, CustomPrompts } from "../../../src/shared/modes" +import { codeMode, CustomPrompts, defaultPrompts } from "../../../src/shared/modes" export interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean @@ -89,7 +89,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode currentApiConfigName: 'default', listApiConfigMeta: [], mode: codeMode, - customPrompts: {}, + customPrompts: defaultPrompts, enhancementApiConfigId: '', }) const [didHydrateState, setDidHydrateState] = useState(false) From 365f4acf632352f38b63f9e68ddaa5f321b2fbcb Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 14 Jan 2025 10:51:59 -0500 Subject: [PATCH 06/15] Add mode-specific custom instructions --- src/core/Cline.ts | 10 +- src/core/mentions/__tests__/index.test.ts | 2 +- .../__snapshots__/system.test.ts.snap | 136 +++++++- src/core/prompts/__tests__/system.test.ts | 161 +++++++-- src/core/prompts/ask.ts | 1 - src/core/prompts/code.ts | 1 - src/core/prompts/system.ts | 59 +++- src/core/webview/ClineProvider.ts | 87 +++-- .../webview/__tests__/ClineProvider.test.ts | 182 +++++++++- src/integrations/misc/open-file.ts | 44 ++- src/shared/modes.ts | 1 + .../src/components/prompts/PromptsView.tsx | 325 +++++++++++++----- .../src/components/settings/SettingsView.tsx | 36 +- 13 files changed, 841 insertions(+), 204 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index acff8bf..c0c3683 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -789,7 +789,15 @@ export class Cline { browserViewportSize, mode, customPrompts - ) + await addCustomInstructions(this.customInstructions ?? '', cwd, preferredLanguage) + ) + await addCustomInstructions( + { + customInstructions: this.customInstructions, + customPrompts, + preferredLanguage + }, + cwd, + mode + ) // If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request if (previousApiReqIndex >= 0) { diff --git a/src/core/mentions/__tests__/index.test.ts b/src/core/mentions/__tests__/index.test.ts index e816726..609f0cf 100644 --- a/src/core/mentions/__tests__/index.test.ts +++ b/src/core/mentions/__tests__/index.test.ts @@ -131,7 +131,7 @@ Detailed commit message with multiple lines await openMention("/path/to/file") expect(mockExecuteCommand).not.toHaveBeenCalled() expect(mockOpenExternal).not.toHaveBeenCalled() - expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file!") + expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist") await openMention("problems") expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems") diff --git a/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap b/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap index c32e2e9..811c46d 100644 --- a/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap +++ b/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap @@ -2185,6 +2185,66 @@ Custom test instructions 2. Second rule" `; +exports[`addCustomInstructions should combine global and mode-specific instructions 1`] = ` +" +==== + +USER'S CUSTOM INSTRUCTIONS + +The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. + +Global instructions + +Mode-specific instructions + +# Rules from .clinerules: +# Test Rules +1. First rule +2. Second rule" +`; + +exports[`addCustomInstructions should fall back to generic rules when mode-specific rules not found 1`] = ` +" +==== + +USER'S CUSTOM INSTRUCTIONS + +The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. + +# Rules from .clinerules: +# Test Rules +1. First rule +2. Second rule" +`; + +exports[`addCustomInstructions should handle empty mode-specific instructions 1`] = ` +" +==== + +USER'S CUSTOM INSTRUCTIONS + +The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. + +# Rules from .clinerules: +# Test Rules +1. First rule +2. Second rule" +`; + +exports[`addCustomInstructions should handle undefined mode-specific instructions 1`] = ` +" +==== + +USER'S CUSTOM INSTRUCTIONS + +The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. + +# Rules from .clinerules: +# Test Rules +1. First rule +2. Second rule" +`; + exports[`addCustomInstructions should include custom instructions when provided 1`] = ` " ==== @@ -2217,7 +2277,7 @@ You should always speak and think in the Spanish language. 2. Second rule" `; -exports[`addCustomInstructions should include rules from .clinerules 1`] = ` +exports[`addCustomInstructions should prioritize mode-specific instructions after global ones 1`] = ` " ==== @@ -2225,6 +2285,80 @@ USER'S CUSTOM INSTRUCTIONS The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. +First instruction + +Second instruction + +# Rules from .clinerules: +# Test Rules +1. First rule +2. Second rule" +`; + +exports[`addCustomInstructions should prioritize mode-specific rules for architect mode 1`] = ` +" +==== + +USER'S CUSTOM INSTRUCTIONS + +The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. + +# Rules from .clinerules-architect: +# Architect Mode Rules +1. Architect specific rule + +# Rules from .clinerules: +# Test Rules +1. First rule +2. Second rule" +`; + +exports[`addCustomInstructions should prioritize mode-specific rules for ask mode 1`] = ` +" +==== + +USER'S CUSTOM INSTRUCTIONS + +The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. + +# Rules from .clinerules-ask: +# Ask Mode Rules +1. Ask specific rule + +# Rules from .clinerules: +# Test Rules +1. First rule +2. Second rule" +`; + +exports[`addCustomInstructions should prioritize mode-specific rules for code mode 1`] = ` +" +==== + +USER'S CUSTOM INSTRUCTIONS + +The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. + +# Rules from .clinerules-code: +# Code Mode Rules +1. Code specific rule + +# Rules from .clinerules: +# Test Rules +1. First rule +2. Second rule" +`; + +exports[`addCustomInstructions should trim mode-specific instructions 1`] = ` +" +==== + +USER'S CUSTOM INSTRUCTIONS + +The following additional instructions are provided by the user, and should be followed to the best of your ability without interfering with the TOOL USE guidelines. + +Custom mode instructions + # Rules from .clinerules: # Test Rules 1. First rule diff --git a/src/core/prompts/__tests__/system.test.ts b/src/core/prompts/__tests__/system.test.ts index b7d0b23..178fcfd 100644 --- a/src/core/prompts/__tests__/system.test.ts +++ b/src/core/prompts/__tests__/system.test.ts @@ -5,6 +5,7 @@ import { ClineProvider } from '../../../core/webview/ClineProvider' import { SearchReplaceDiffStrategy } from '../../../core/diff/strategies/search-replace' import fs from 'fs/promises' import os from 'os' +import { codeMode, askMode, architectMode } from '../modes' // Import path utils to get access to toPosix string extension import '../../../utils/path' @@ -18,13 +19,22 @@ jest.mock('default-shell', () => '/bin/bash') jest.mock('os-name', () => () => 'Linux') -// Mock fs.readFile to return empty mcpServers config and mock .clinerules +// Mock fs.readFile to return empty mcpServers config and mock rules files jest.mock('fs/promises', () => ({ ...jest.requireActual('fs/promises'), readFile: jest.fn().mockImplementation(async (path: string) => { if (path.endsWith('mcpSettings.json')) { return '{"mcpServers": {}}' } + if (path.endsWith('.clinerules-code')) { + return '# Code Mode Rules\n1. Code specific rule' + } + if (path.endsWith('.clinerules-ask')) { + return '# Ask Mode Rules\n1. Ask specific rule' + } + if (path.endsWith('.clinerules-architect')) { + return '# Architect Mode Rules\n1. Architect specific rule' + } if (path.endsWith('.clinerules')) { return '# Test Rules\n1. First rule\n2. Second rule' } @@ -159,42 +169,149 @@ describe('addCustomInstructions', () => { jest.clearAllMocks() }) - it('should include preferred language when provided', async () => { - const result = await addCustomInstructions( - '', + it('should prioritize mode-specific rules for code mode', async () => { + const instructions = await addCustomInstructions( + {}, '/test/path', - 'Spanish' + codeMode + ) + expect(instructions).toMatchSnapshot() + }) + + it('should prioritize mode-specific rules for ask mode', async () => { + const instructions = await addCustomInstructions( + {}, + '/test/path', + askMode + ) + expect(instructions).toMatchSnapshot() + }) + + it('should prioritize mode-specific rules for architect mode', async () => { + const instructions = await addCustomInstructions( + {}, + '/test/path', + architectMode ) - expect(result).toMatchSnapshot() + expect(instructions).toMatchSnapshot() + }) + + it('should fall back to generic rules when mode-specific rules not found', async () => { + // Mock readFile to return ENOENT for mode-specific file + const mockReadFile = jest.fn().mockImplementation(async (path: string) => { + if (path.endsWith('.clinerules-code')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException + error.code = 'ENOENT' + throw error + } + if (path.endsWith('.clinerules')) { + return '# Test Rules\n1. First rule\n2. Second rule' + } + return '' + }) + jest.spyOn(fs, 'readFile').mockImplementation(mockReadFile) + + const instructions = await addCustomInstructions( + {}, + '/test/path', + codeMode + ) + + expect(instructions).toMatchSnapshot() + }) + + it('should include preferred language when provided', async () => { + const instructions = await addCustomInstructions( + { preferredLanguage: 'Spanish' }, + '/test/path', + codeMode + ) + + expect(instructions).toMatchSnapshot() }) it('should include custom instructions when provided', async () => { - const result = await addCustomInstructions( - 'Custom test instructions', + const instructions = await addCustomInstructions( + { customInstructions: 'Custom test instructions' }, '/test/path' ) - expect(result).toMatchSnapshot() - }) - - it('should include rules from .clinerules', async () => { - const result = await addCustomInstructions( - '', - '/test/path' - ) - - expect(result).toMatchSnapshot() + expect(instructions).toMatchSnapshot() }) it('should combine all custom instructions', async () => { - const result = await addCustomInstructions( - 'Custom test instructions', + const instructions = await addCustomInstructions( + { + customInstructions: 'Custom test instructions', + preferredLanguage: 'French' + }, '/test/path', - 'French' + codeMode + ) + expect(instructions).toMatchSnapshot() + }) + + it('should handle undefined mode-specific instructions', async () => { + const instructions = await addCustomInstructions( + {}, + '/test/path' ) - expect(result).toMatchSnapshot() + expect(instructions).toMatchSnapshot() + }) + + it('should trim mode-specific instructions', async () => { + const instructions = await addCustomInstructions( + { customInstructions: ' Custom mode instructions ' }, + '/test/path' + ) + + expect(instructions).toMatchSnapshot() + }) + + it('should handle empty mode-specific instructions', async () => { + const instructions = await addCustomInstructions( + { customInstructions: '' }, + '/test/path' + ) + + expect(instructions).toMatchSnapshot() + }) + + it('should combine global and mode-specific instructions', async () => { + const instructions = await addCustomInstructions( + { + customInstructions: 'Global instructions', + customPrompts: { + code: { customInstructions: 'Mode-specific instructions' } + } + }, + '/test/path', + codeMode + ) + + expect(instructions).toMatchSnapshot() + }) + + it('should prioritize mode-specific instructions after global ones', async () => { + const instructions = await addCustomInstructions( + { + customInstructions: 'First instruction', + customPrompts: { + code: { customInstructions: 'Second instruction' } + } + }, + '/test/path', + codeMode + ) + + const instructionParts = instructions.split('\n\n') + const globalIndex = instructionParts.findIndex(part => part.includes('First instruction')) + const modeSpecificIndex = instructionParts.findIndex(part => part.includes('Second instruction')) + + expect(globalIndex).toBeLessThan(modeSpecificIndex) + expect(instructions).toMatchSnapshot() }) afterAll(() => { diff --git a/src/core/prompts/ask.ts b/src/core/prompts/ask.ts index bc86d3e..59c9b29 100644 --- a/src/core/prompts/ask.ts +++ b/src/core/prompts/ask.ts @@ -4,7 +4,6 @@ import { getRulesSection, getSystemInfoSection, getObjectiveSection, - addCustomInstructions, getSharedToolUseSection, getMcpServersSection, getToolUseGuidelinesSection, diff --git a/src/core/prompts/code.ts b/src/core/prompts/code.ts index 7d2a7f3..33efa5a 100644 --- a/src/core/prompts/code.ts +++ b/src/core/prompts/code.ts @@ -4,7 +4,6 @@ import { getRulesSection, getSystemInfoSection, getObjectiveSection, - addCustomInstructions, getSharedToolUseSection, getMcpServersSection, getToolUseGuidelinesSection, diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index d4c2c8e..09063b7 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -8,11 +8,26 @@ import { CustomPrompts } from "../../shared/modes" import fs from 'fs/promises' import path from 'path' -async function loadRuleFiles(cwd: string): Promise { - const ruleFiles = ['.clinerules', '.cursorrules', '.windsurfrules'] +async function loadRuleFiles(cwd: string, mode: Mode): Promise { let combinedRules = '' - for (const file of ruleFiles) { + // First try mode-specific rules + const modeSpecificFile = `.clinerules-${mode}` + try { + const content = await fs.readFile(path.join(cwd, modeSpecificFile), 'utf-8') + if (content.trim()) { + combinedRules += `\n# Rules from ${modeSpecificFile}:\n${content.trim()}\n` + } + } catch (err) { + // Silently skip if file doesn't exist + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err + } + } + + // Then try generic rules files + const genericRuleFiles = ['.clinerules'] + for (const file of genericRuleFiles) { try { const content = await fs.readFile(path.join(cwd, file), 'utf-8') if (content.trim()) { @@ -29,16 +44,30 @@ async function loadRuleFiles(cwd: string): Promise { return combinedRules } -export async function addCustomInstructions(customInstructions: string, cwd: string, preferredLanguage?: string): Promise { - const ruleFileContent = await loadRuleFiles(cwd) +interface State { + customInstructions?: string; + customPrompts?: CustomPrompts; + preferredLanguage?: string; +} + +export async function addCustomInstructions( + state: State, + cwd: string, + mode: Mode = codeMode +): Promise { + const ruleFileContent = await loadRuleFiles(cwd, mode) const allInstructions = [] - if (preferredLanguage) { - allInstructions.push(`You should always speak and think in the ${preferredLanguage} language.`) + if (state.preferredLanguage) { + allInstructions.push(`You should always speak and think in the ${state.preferredLanguage} language.`) } - - if (customInstructions.trim()) { - allInstructions.push(customInstructions.trim()) + + if (state.customInstructions?.trim()) { + allInstructions.push(state.customInstructions.trim()) + } + + if (state.customPrompts?.[mode]?.customInstructions?.trim()) { + allInstructions.push(state.customPrompts[mode].customInstructions.trim()) } if (ruleFileContent && ruleFileContent.trim()) { @@ -59,11 +88,11 @@ ${joinedInstructions}` } export const SYSTEM_PROMPT = async ( - cwd: string, - supportsComputerUse: boolean, - mcpHub?: McpHub, - diffStrategy?: DiffStrategy, - browserViewportSize?: string, + cwd: string, + supportsComputerUse: boolean, + mcpHub?: McpHub, + diffStrategy?: DiffStrategy, + browserViewportSize?: string, mode: Mode = codeMode, customPrompts?: CustomPrompts, ) => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 069301b..4d8706b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -246,15 +246,22 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.clearTask() const { apiConfiguration, - customInstructions, + customPrompts, diffEnabled, - fuzzyMatchThreshold + fuzzyMatchThreshold, + mode, + customInstructions: globalInstructions, } = await this.getState() + const modeInstructions = customPrompts?.[mode]?.customInstructions + const effectiveInstructions = [globalInstructions, modeInstructions] + .filter(Boolean) + .join('\n\n') + this.cline = new Cline( this, apiConfiguration, - customInstructions, + effectiveInstructions, diffEnabled, fuzzyMatchThreshold, task, @@ -266,15 +273,22 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.clearTask() const { apiConfiguration, - customInstructions, + customPrompts, diffEnabled, - fuzzyMatchThreshold + fuzzyMatchThreshold, + mode, + customInstructions: globalInstructions, } = await this.getState() + const modeInstructions = customPrompts?.[mode]?.customInstructions + const effectiveInstructions = [globalInstructions, modeInstructions] + .filter(Boolean) + .join('\n\n') + this.cline = new Cline( this, apiConfiguration, - customInstructions, + effectiveInstructions, diffEnabled, fuzzyMatchThreshold, undefined, @@ -379,6 +393,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { async (message: WebviewMessage) => { switch (message.type) { case "webviewDidLaunch": + this.postStateToWebview() this.workspaceTracker?.initializeFilePaths() // don't await getTheme().then((theme) => @@ -572,7 +587,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { openImage(message.text!) break case "openFile": - openFile(message.text!) + openFile(message.text!, message.values as { create?: boolean; content?: string }) break case "openMention": openMention(message.text) @@ -732,30 +747,28 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.postStateToWebview() break case "updateEnhancedPrompt": - if (message.text !== undefined) { - const existingPrompts = await this.getGlobalState("customPrompts") || {} - - const updatedPrompts = { - ...existingPrompts, - enhance: message.text - } - - await this.updateGlobalState("customPrompts", updatedPrompts) - - // Get current state and explicitly include customPrompts - const currentState = await this.getState() - - const stateWithPrompts = { - ...currentState, - customPrompts: updatedPrompts - } - - // Post state with prompts - this.view?.webview.postMessage({ - type: "state", - state: stateWithPrompts - }) + const existingPrompts = await this.getGlobalState("customPrompts") || {} + + const updatedPrompts = { + ...existingPrompts, + enhance: message.text } + + await this.updateGlobalState("customPrompts", updatedPrompts) + + // Get current state and explicitly include customPrompts + const currentState = await this.getState() + + const stateWithPrompts = { + ...currentState, + customPrompts: updatedPrompts + } + + // Post state with prompts + this.view?.webview.postMessage({ + type: "state", + state: stateWithPrompts + }) break case "updatePrompt": if (message.promptMode && message.customPrompt !== undefined) { @@ -893,15 +906,23 @@ export class ClineProvider implements vscode.WebviewViewProvider { const { apiConfiguration, customPrompts, customInstructions, preferredLanguage, browserViewportSize, mcpEnabled } = await this.getState() const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || '' - const fullPrompt = await SYSTEM_PROMPT( + const mode = message.mode ?? codeMode + const instructions = await addCustomInstructions( + { customInstructions, customPrompts, preferredLanguage }, + cwd, + mode + ) + + const systemPrompt = await SYSTEM_PROMPT( cwd, apiConfiguration.openRouterModelInfo?.supportsComputerUse ?? false, mcpEnabled ? this.mcpHub : undefined, undefined, browserViewportSize ?? "900x600", - message.mode, + mode, customPrompts - ) + await addCustomInstructions(customInstructions ?? '', cwd, preferredLanguage) + ) + const fullPrompt = instructions ? `${systemPrompt}${instructions}` : systemPrompt await this.postMessageToWebview({ type: "systemPrompt", diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 4a89eb3..ddcd7e1 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -130,19 +130,25 @@ jest.mock('../../../integrations/workspace/WorkspaceTracker', () => { }) // Mock Cline -jest.mock('../../Cline', () => { - return { - Cline: jest.fn().mockImplementation(() => ({ - abortTask: jest.fn(), - handleWebviewAskResponse: jest.fn(), - clineMessages: [], - apiConversationHistory: [], - overwriteClineMessages: jest.fn(), - overwriteApiConversationHistory: jest.fn(), - taskId: 'test-task-id' - })) - } -}) +jest.mock('../../Cline', () => ({ + Cline: jest.fn().mockImplementation(( + provider, + apiConfiguration, + customInstructions, + diffEnabled, + fuzzyMatchThreshold, + task, + taskId + ) => ({ + abortTask: jest.fn(), + handleWebviewAskResponse: jest.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: jest.fn(), + overwriteApiConversationHistory: jest.fn(), + taskId: taskId || 'test-task-id' + })) +})) // Mock extract-text jest.mock('../../../integrations/misc/extract-text', () => ({ @@ -571,6 +577,82 @@ describe('ClineProvider', () => { expect(state.customPrompts).toEqual({}) }) + test('uses mode-specific custom instructions in Cline initialization', async () => { + // Setup mock state + const modeCustomInstructions = 'Code mode instructions'; + const mockApiConfig = { + apiProvider: 'openrouter', + openRouterModelInfo: { supportsComputerUse: true } + }; + + jest.spyOn(provider, 'getState').mockResolvedValue({ + apiConfiguration: mockApiConfig, + customPrompts: { + code: { customInstructions: modeCustomInstructions } + }, + mode: 'code', + diffEnabled: true, + fuzzyMatchThreshold: 1.0 + } as any); + + // Reset Cline mock + const { Cline } = require('../../Cline'); + (Cline as jest.Mock).mockClear(); + + // Initialize Cline with a task + await provider.initClineWithTask('Test task'); + + // Verify Cline was initialized with mode-specific instructions + expect(Cline).toHaveBeenCalledWith( + provider, + mockApiConfig, + modeCustomInstructions, + true, + 1.0, + 'Test task', + undefined + ); + }); + test('handles mode-specific custom instructions updates', async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Mock existing prompts + const existingPrompts = { + code: { + roleDefinition: 'Code role', + customInstructions: 'Old instructions' + } + } + mockContext.globalState.get = jest.fn((key: string) => { + if (key === 'customPrompts') { + return existingPrompts + } + return undefined + }) + + // Update custom instructions for code mode + await messageHandler({ + type: 'updatePrompt', + promptMode: 'code', + customPrompt: { + roleDefinition: 'Code role', + customInstructions: 'New instructions' + } + }) + + // Verify state was updated correctly + expect(mockContext.globalState.update).toHaveBeenCalledWith( + 'customPrompts', + { + code: { + roleDefinition: 'Code role', + customInstructions: 'New instructions' + } + } + ) + }) + test('saves mode config when updating API configuration', async () => { // Setup mock context with mode and config name mockContext = { @@ -848,5 +930,79 @@ describe('ClineProvider', () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Failed to get system prompt') }) + + test('uses mode-specific custom instructions in system prompt', async () => { + const systemPrompt = require('../../prompts/system') + const { addCustomInstructions } = systemPrompt + + // Mock getState to return mode-specific custom instructions + jest.spyOn(provider, 'getState').mockResolvedValue({ + apiConfiguration: { + apiProvider: 'openrouter', + openRouterModelInfo: { supportsComputerUse: true } + }, + customPrompts: { + code: { customInstructions: 'Code mode specific instructions' } + }, + mode: 'code', + mcpEnabled: false, + browserViewportSize: '900x600' + } as any) + + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + await messageHandler({ type: 'getSystemPrompt', mode: 'code' }) + + // Verify addCustomInstructions was called with mode-specific instructions + expect(addCustomInstructions).toHaveBeenCalledWith( + { + customInstructions: undefined, + customPrompts: { + code: { customInstructions: 'Code mode specific instructions' } + }, + preferredLanguage: undefined + }, + expect.any(String), + 'code' + ) + }) + + test('uses correct mode-specific instructions when mode is specified', async () => { + const systemPrompt = require('../../prompts/system') + const { addCustomInstructions } = systemPrompt + + // Mock getState to return instructions for multiple modes + jest.spyOn(provider, 'getState').mockResolvedValue({ + apiConfiguration: { + apiProvider: 'openrouter', + openRouterModelInfo: { supportsComputerUse: true } + }, + customPrompts: { + code: { customInstructions: 'Code mode instructions' }, + architect: { customInstructions: 'Architect mode instructions' } + }, + mode: 'code', + mcpEnabled: false, + browserViewportSize: '900x600' + } as any) + + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Request architect mode prompt + await messageHandler({ type: 'getSystemPrompt', mode: 'architect' }) + + // Verify architect mode instructions were used + expect(addCustomInstructions).toHaveBeenCalledWith( + { + customInstructions: undefined, + customPrompts: { + code: { customInstructions: 'Code mode instructions' }, + architect: { customInstructions: 'Architect mode instructions' } + }, + preferredLanguage: undefined + }, + expect.any(String), + 'architect' + ) + }) }) }) diff --git a/src/integrations/misc/open-file.ts b/src/integrations/misc/open-file.ts index 8dc3029..08a3ce1 100644 --- a/src/integrations/misc/open-file.ts +++ b/src/integrations/misc/open-file.ts @@ -20,11 +20,41 @@ export async function openImage(dataUri: string) { } } -export async function openFile(absolutePath: string) { - try { - const uri = vscode.Uri.file(absolutePath) +interface OpenFileOptions { + create?: boolean; + content?: string; +} - // Check if the document is already open in a tab group that's not in the active editor's column. If it is, then close it (if not dirty) so that we don't duplicate tabs +export async function openFile(filePath: string, options: OpenFileOptions = {}) { + try { + // Get workspace root + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + throw new Error('No workspace root found') + } + + // If path starts with ./, resolve it relative to workspace root + const fullPath = filePath.startsWith('./') ? + path.join(workspaceRoot, filePath.slice(2)) : + filePath + + const uri = vscode.Uri.file(fullPath) + + // Check if file exists + try { + await vscode.workspace.fs.stat(uri) + } catch { + // File doesn't exist + if (!options.create) { + throw new Error('File does not exist') + } + + // Create with provided content or empty string + const content = options.content || '' + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')) + } + + // Check if the document is already open in a tab group that's not in the active editor's column try { for (const group of vscode.window.tabGroups.all) { const existingTab = group.tabs.find( @@ -47,6 +77,10 @@ export async function openFile(absolutePath: string) { const document = await vscode.workspace.openTextDocument(uri) await vscode.window.showTextDocument(document, { preview: false }) } catch (error) { - vscode.window.showErrorMessage(`Could not open file!`) + if (error instanceof Error) { + vscode.window.showErrorMessage(`Could not open file: ${error.message}`) + } else { + vscode.window.showErrorMessage(`Could not open file!`) + } } } diff --git a/src/shared/modes.ts b/src/shared/modes.ts index ad0d462..dc3ad6a 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -6,6 +6,7 @@ export type Mode = typeof codeMode | typeof architectMode | typeof askMode; export type PromptComponent = { roleDefinition?: string; + customInstructions?: string; } export type CustomPrompts = { diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 6b7bdb4..4ae7659 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -1,4 +1,4 @@ -import { VSCodeButton, VSCodeTextArea, VSCodeDropdown, VSCodeOption, VSCodeDivider } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton, VSCodeTextArea, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" import { useExtensionState } from "../../context/ExtensionStateContext" import { defaultPrompts, askMode, codeMode, architectMode, Mode, PromptComponent } from "../../../../src/shared/modes" import { vscode } from "../../utils/vscode" @@ -15,7 +15,14 @@ const AGENT_MODES = [ ] as const const PromptsView = ({ onDone }: PromptsViewProps) => { - const { customPrompts, listApiConfigMeta, enhancementApiConfigId, setEnhancementApiConfigId, mode } = useExtensionState() + const { + customPrompts, + listApiConfigMeta, + enhancementApiConfigId, + setEnhancementApiConfigId, + mode, + customInstructions + } = useExtensionState() const [testPrompt, setTestPrompt] = useState('') const [isEnhancing, setIsEnhancing] = useState(false) const [activeTab, setActiveTab] = useState(mode) @@ -47,10 +54,20 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { type AgentMode = typeof codeMode | typeof architectMode | typeof askMode const updateAgentPrompt = (mode: AgentMode, promptData: PromptComponent) => { + const updatedPrompt = { + ...customPrompts?.[mode], + ...promptData + } + + // Only include properties that differ from defaults + if (updatedPrompt.roleDefinition === defaultPrompts[mode].roleDefinition) { + delete updatedPrompt.roleDefinition + } + vscode.postMessage({ type: "updatePrompt", promptMode: mode, - customPrompt: promptData + customPrompt: updatedPrompt }) } @@ -68,11 +85,17 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { const handleEnhancePromptChange = (e: Event | React.FormEvent) => { const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value - updateEnhancePrompt(value.trim() || undefined) + const trimmedValue = value.trim() + if (trimmedValue !== defaultPrompts.enhance) { + updateEnhancePrompt(trimmedValue || undefined) + } } const handleAgentReset = (mode: AgentMode) => { - updateAgentPrompt(mode, { roleDefinition: undefined }) + updateAgentPrompt(mode, { + ...customPrompts?.[mode], + roleDefinition: undefined + }) } const handleEnhanceReset = () => { @@ -120,71 +143,156 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
-

Agent Modes

- -
- Customize Cline's prompt in each mode. The rest of the system prompt will be automatically appended. Click the button to preview the full prompt. Leave empty or click the reset button to use the default. -
- -
-
- {AGENT_MODES.map((tab, index) => ( - - - {index < AGENT_MODES.length - 1 && ( - | - )} - - ))} +
+
Custom Instructions for All Modes
+
+ These instructions apply to all modes. They provide a base set of behaviors that can be enhanced by mode-specific instructions below.
- handleAgentReset(activeTab)} - data-testid="reset-prompt-button" - title="Revert to default" - > - - -
- -
handleAgentPromptChange(activeTab, e)} + value={customInstructions ?? ''} + onChange={(e) => { + const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value + vscode.postMessage({ + type: "customInstructions", + text: value.trim() || undefined + }) + }} rows={4} resize="vertical" style={{ width: "100%" }} - data-testid={`${activeTab}-prompt-textarea`} + data-testid="global-custom-instructions-textarea" /> +
+ Instructions can also be loaded from vscode.postMessage({ + type: "openFile", + text: "./.clinerules", + values: { + create: true, + content: "", + } + })} + >.clinerules in your workspace. +
+
+ +

Mode-Specific Prompts

+ +
+ {AGENT_MODES.map((tab) => ( + + ))} +
+ +
+
+
+
+
Role Definition
+ handleAgentReset(activeTab)} + data-testid="reset-prompt-button" + title="Revert to default" + > + + +
+
+ Define Cline's expertise and personality for this mode. This description shapes how Cline presents itself and approaches tasks. +
+
+ handleAgentPromptChange(activeTab, e)} + rows={4} + resize="vertical" + style={{ width: "100%" }} + data-testid={`${activeTab}-prompt-textarea`} + /> +
+
+
Mode-specific Custom Instructions
+
+ Add behavioral guidelines specific to {activeTab} mode. These instructions enhance the base behaviors defined above. +
+ { + const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value + updateAgentPrompt(activeTab, { + ...customPrompts?.[activeTab], + customInstructions: value.trim() || undefined + }) + }} + rows={4} + resize="vertical" + style={{ width: "100%" }} + data-testid={`${activeTab}-custom-instructions-textarea`} + /> +
+ Custom instructions specific to {activeTab} mode can also be loaded from { + // First create/update the file with current custom instructions + const defaultContent = `# ${activeTab} Mode Rules\n\nAdd mode-specific rules and guidelines here.` + vscode.postMessage({ + type: "updatePrompt", + promptMode: activeTab, + customPrompt: { + ...customPrompts?.[activeTab], + customInstructions: customPrompts?.[activeTab]?.customInstructions || defaultContent + } + }) + // Then open the file + vscode.postMessage({ + type: "openFile", + text: `./.clinerules-${activeTab}`, + values: { + create: true, + content: "", + } + }) + }} + >.clinerules-{activeTab} in your workspace. +
+
{

Prompt Enhancement

+
+ Use prompt enhancement to get tailored suggestions or improvements for your inputs. This ensures Cline understands your intent and provides the best possible responses. +
+
@@ -234,12 +351,17 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
-
-
Enhancement Prompt
-
- - - +
+
+
Enhancement Prompt
+
+ + + +
+
+
+ This prompt will be used to refine your input when you hit the sparkle icon in chat.
{ boxShadow: '-2px 0 5px rgba(0, 0, 0, 0.2)', display: 'flex', flexDirection: 'column', - padding: '20px', - overflowY: 'auto' + position: 'relative' }}>
-

{selectedPromptTitle}

- setIsDialogOpen(false)}> + setIsDialogOpen(false)} + style={{ + position: 'absolute', + top: '20px', + right: '20px' + }} + > +

{selectedPromptTitle}

+
+								{selectedPromptContent}
+							
- -
-							{selectedPromptContent}
-						
+ setIsDialogOpen(false)}> + Close + +
)} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 13e3a82..17467ea 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -278,24 +278,26 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

- setCustomInstructions(e.target?.value ?? "")}> +
Custom Instructions - -

- These instructions are added to the end of the system prompt sent with every request. Custom instructions set in .clinerules and .cursorrules in the working directory are also included. -

+ setCustomInstructions(e.target?.value ?? "")} + /> +

+ These instructions are added to the end of the system prompt sent with every request. Custom instructions set in .clinerules in the working directory are also included. For mode-specific instructions, use the Prompts tab in the top menu. +

+
From 1f17f72d449221f17c09a8619186cf2b02727a51 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 14 Jan 2025 15:23:45 -0500 Subject: [PATCH 07/15] Release --- .changeset/fifty-lemons-double.md | 5 +++++ README.md | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .changeset/fifty-lemons-double.md diff --git a/.changeset/fifty-lemons-double.md b/.changeset/fifty-lemons-double.md new file mode 100644 index 0000000..d84b2c7 --- /dev/null +++ b/.changeset/fifty-lemons-double.md @@ -0,0 +1,5 @@ +--- +"roo-cline": minor +--- + +3.1 diff --git a/README.md b/README.md index 67efe0b..e5f38a4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,23 @@ -# Roo-Cline +# Roo Cline A fork of Cline, an autonomous coding agent, with some additional experimental features. It’s been mainly writing itself recently, with a light touch of human guidance here and there. -## New in 3.0 - chat modes! +## New in 3.1: Chat Mode Prompt Customization & Prompt Enhancements + +Hot off the heels of **v3.0** introducing Code, Architect, and Ask chat modes, one of the most requested features has arrived: **customizable prompts for each mode**! 🎉 + +You can now tailor the **role definition** and **custom instructions** for every chat mode to perfectly fit your workflow. Want to adjust Architect mode to focus more on system scalability? Or tweak Ask mode for deeper research queries? Done. Plus, you can define these via **mode-specific `.clinerules-[mode]` files**. You’ll find all of this in the new **Prompts** tab in the top menu. + +The second big feature in this release is a complete revamp of **prompt enhancements**. This feature helps you craft messages to get even better results from Cline. Here’s what’s new: +- Works with **any provider** and API configuration, not just OpenRouter. +- Fully customizable prompts to match your unique needs. +- Same simple workflow: just hit the ✨ **Enhance Prompt** button in the chat input to try it out. + +Whether you’re using GPT-4, other APIs, or switching configurations, this gives you total control over how your prompts are optimized. + +As always, we’d love to hear your thoughts and ideas! What features do you want to see in **v3.2**? Drop by https://www.reddit.com/r/roocline and join the discussion - we're building Roo Cline together. 🚀 + +## New in 3.0 - Chat Modes! You can now choose between different prompts for Roo Cline to better suit your workflow. Here’s what’s available: From f2d25dd5a5a4106d85169401a219fe090896278c Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 14 Jan 2025 16:01:59 -0500 Subject: [PATCH 08/15] Fix bug with clearing custom instructions --- .changeset/kind-nails-jog.md | 5 ++++ .../src/components/prompts/PromptsView.tsx | 4 ++- .../prompts/__tests__/PromptsView.test.tsx | 28 ++++++++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .changeset/kind-nails-jog.md diff --git a/.changeset/kind-nails-jog.md b/.changeset/kind-nails-jog.md new file mode 100644 index 0000000..252f9d3 --- /dev/null +++ b/.changeset/kind-nails-jog.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Fix bug with clearing custom instructions diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 4ae7659..85e9038 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -21,7 +21,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { enhancementApiConfigId, setEnhancementApiConfigId, mode, - customInstructions + customInstructions, + setCustomInstructions } = useExtensionState() const [testPrompt, setTestPrompt] = useState('') const [isEnhancing, setIsEnhancing] = useState(false) @@ -152,6 +153,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { value={customInstructions ?? ''} onChange={(e) => { const value = (e as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value + setCustomInstructions(value || undefined) vscode.postMessage({ type: "customInstructions", text: value.trim() || undefined diff --git a/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx b/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx index 311702f..2ccc7f1 100644 --- a/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx +++ b/webview-ui/src/components/prompts/__tests__/PromptsView.test.tsx @@ -19,7 +19,9 @@ const mockExtensionState = { ], enhancementApiConfigId: '', setEnhancementApiConfigId: jest.fn(), - mode: 'code' + mode: 'code', + customInstructions: 'Initial instructions', + setCustomInstructions: jest.fn() } const renderPromptsView = (props = {}) => { @@ -131,4 +133,28 @@ describe('PromptsView', () => { text: 'config1' }) }) + + it('handles clearing custom instructions correctly', async () => { + const setCustomInstructions = jest.fn() + renderPromptsView({ + ...mockExtensionState, + customInstructions: 'Initial instructions', + setCustomInstructions + }) + + const textarea = screen.getByTestId('global-custom-instructions-textarea') + const changeEvent = new CustomEvent('change', { + detail: { target: { value: '' } } + }) + Object.defineProperty(changeEvent, 'target', { + value: { value: '' } + }) + await fireEvent(textarea, changeEvent) + + expect(setCustomInstructions).toHaveBeenCalledWith(undefined) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: 'customInstructions', + text: undefined + }) + }) }) \ No newline at end of file From a128c3720dbfcef1b7b9247b0342569ddf0af9af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jan 2025 21:13:33 +0000 Subject: [PATCH 09/15] changeset version bump --- .changeset/fifty-lemons-double.md | 5 ----- .changeset/kind-nails-jog.md | 5 ----- .changeset/real-rockets-sort.md | 5 ----- CHANGELOG.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 14 insertions(+), 18 deletions(-) delete mode 100644 .changeset/fifty-lemons-double.md delete mode 100644 .changeset/kind-nails-jog.md delete mode 100644 .changeset/real-rockets-sort.md diff --git a/.changeset/fifty-lemons-double.md b/.changeset/fifty-lemons-double.md deleted file mode 100644 index d84b2c7..0000000 --- a/.changeset/fifty-lemons-double.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": minor ---- - -3.1 diff --git a/.changeset/kind-nails-jog.md b/.changeset/kind-nails-jog.md deleted file mode 100644 index 252f9d3..0000000 --- a/.changeset/kind-nails-jog.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Fix bug with clearing custom instructions diff --git a/.changeset/real-rockets-sort.md b/.changeset/real-rockets-sort.md deleted file mode 100644 index 3b24461..0000000 --- a/.changeset/real-rockets-sort.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Add a button to copy markdown out of the chat diff --git a/CHANGELOG.md b/CHANGELOG.md index 853801c..2c5da16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Roo Cline Changelog +## 3.1.0 + +### Minor Changes + +- 3.1 + +### Patch Changes + +- Fix bug with clearing custom instructions +- Add a button to copy markdown out of the chat + ## [3.0.3] - Update required vscode engine to ^1.84.0 to match cline diff --git a/package-lock.json b/package-lock.json index f9e1cb6..52339e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roo-cline", - "version": "3.0.3", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "3.0.3", + "version": "3.1.0", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.26.0", diff --git a/package.json b/package.json index ef43cda..7219253 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": "3.0.3", + "version": "3.1.0", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", From ed14c4bbc50063d43b6e6d7fc00727f3efb5432c Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 14 Jan 2025 16:15:31 -0500 Subject: [PATCH 10/15] Update CHANGELOG.md --- CHANGELOG.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5da16..db9aee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,8 @@ # Roo Cline Changelog -## 3.1.0 +## [3.1.0] -### Minor Changes - -- 3.1 - -### Patch Changes - -- Fix bug with clearing custom instructions +- You can now customize the role definition and instructions for each chat mode (Code, Architect, and Ask), either through the new Prompts tab in the top menu or mode-specific .clinerules-mode files. Prompt Enhancements have also been revamped: the "Enhance Prompt" button now works with any provider and API configuration, giving you the ability to craft messages with fully customizable prompts for even better results. - Add a button to copy markdown out of the chat ## [3.0.3] From 14d0b69c79760fb744a3968fba2de1fdf3a74f02 Mon Sep 17 00:00:00 2001 From: Joe Manley Date: Tue, 14 Jan 2025 14:58:24 -0800 Subject: [PATCH 11/15] Fix icon color - light theme --- webview-ui/src/components/settings/SettingsView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 17467ea..88731c6 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -556,7 +556,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { minWidth: '20px', display: 'flex', alignItems: 'center', - justifyContent: 'center' + justifyContent: 'center', + color: 'var(--vscode-button-foreground)', }} onClick={() => { const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index) From 9418a8fa42809eff7c982d8e7c8f8315da62d35c Mon Sep 17 00:00:00 2001 From: Joe Manley Date: Tue, 14 Jan 2025 15:14:02 -0800 Subject: [PATCH 12/15] Add changeset --- .changeset/sharp-hairs-itch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sharp-hairs-itch.md diff --git a/.changeset/sharp-hairs-itch.md b/.changeset/sharp-hairs-itch.md new file mode 100644 index 0000000..20a6e16 --- /dev/null +++ b/.changeset/sharp-hairs-itch.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Fix color for the light+ themes From 84a0063b99813c6c64c0402e95c80bd15a945cc8 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 14 Jan 2025 23:45:43 -0500 Subject: [PATCH 13/15] Fix ChatTextArea layout --- .changeset/fresh-jobs-repair.md | 5 + .../src/components/chat/ChatTextArea.tsx | 546 +++++++++--------- .../src/components/common/CaretIcon.tsx | 16 + 3 files changed, 306 insertions(+), 261 deletions(-) create mode 100644 .changeset/fresh-jobs-repair.md create mode 100644 webview-ui/src/components/common/CaretIcon.tsx diff --git a/.changeset/fresh-jobs-repair.md b/.changeset/fresh-jobs-repair.md new file mode 100644 index 0000000..23bc920 --- /dev/null +++ b/.changeset/fresh-jobs-repair.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Fix chat text input layout issues diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c7cddb5..57f1ec7 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -15,6 +15,7 @@ import Thumbnails from "../common/Thumbnails" import { vscode } from "../../utils/vscode" import { WebviewMessage } from "../../../../src/shared/WebviewMessage" import { Mode } from "../../../../src/core/prompts/types" +import { CaretIcon } from "../common/CaretIcon" interface ChatTextAreaProps { inputValue: string @@ -50,7 +51,6 @@ const ChatTextArea = forwardRef( ref, ) => { const { filePaths, currentApiConfigName, listApiConfigMeta } = useExtensionState() - const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) @@ -376,7 +376,6 @@ const ChatTextArea = forwardRef( if (!isMouseDownOnMenu) { setShowContextMenu(false) } - setIsTextAreaFocused(false) }, [isMouseDownOnMenu]) const handlePaste = useCallback( @@ -494,65 +493,97 @@ const ChatTextArea = forwardRef( [updateCursorPosition], ) + const selectStyle = { + fontSize: "11px", + cursor: textAreaDisabled ? "not-allowed" : "pointer", + backgroundColor: "transparent", + border: "none", + color: "var(--vscode-foreground)", + opacity: textAreaDisabled ? 0.5 : 0.8, + outline: "none", + paddingLeft: "20px", + paddingRight: "6px", + WebkitAppearance: "none" as const, + MozAppearance: "none" as const, + appearance: "none" as const + } + + const caretContainerStyle = { + position: "absolute" as const, + left: 6, + top: "50%", + transform: "translateY(-45%)", + pointerEvents: "none" as const, + opacity: textAreaDisabled ? 0.5 : 0.8 + } + return ( -
{ - e.preventDefault() - const files = Array.from(e.dataTransfer.files) - const text = e.dataTransfer.getData("text") - if (text) { - const newValue = - inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition) - setInputValue(newValue) - const newCursorPosition = cursorPosition + text.length - setCursorPosition(newCursorPosition) - setIntendedCursorPosition(newCursorPosition) - return - } - const acceptedTypes = ["png", "jpeg", "webp"] - const imageFiles = files.filter((file) => { - const [type, subtype] = file.type.split("/") - return type === "image" && acceptedTypes.includes(subtype) - }) - if (!shouldDisableImages && imageFiles.length > 0) { - const imagePromises = imageFiles.map((file) => { - return new Promise((resolve) => { - const reader = new FileReader() - reader.onloadend = () => { - if (reader.error) { - console.error("Error reading file:", reader.error) - resolve(null) - } else { - const result = reader.result - resolve(typeof result === "string" ? result : null) - } - } - reader.readAsDataURL(file) - }) - }) - const imageDataArray = await Promise.all(imagePromises) - const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) - if (dataUrls.length > 0) { - setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) - if (typeof vscode !== 'undefined') { - vscode.postMessage({ - type: 'draggedImages', - dataUrls: dataUrls - }) - } - } else { - console.warn("No valid images were processed") +
{ + e.preventDefault() + const files = Array.from(e.dataTransfer.files) + const text = e.dataTransfer.getData("text") + if (text) { + const newValue = + inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition) + setInputValue(newValue) + const newCursorPosition = cursorPosition + text.length + setCursorPosition(newCursorPosition) + setIntendedCursorPosition(newCursorPosition) + return } - } - }} - onDragOver={(e) => { - e.preventDefault() - }}> + const acceptedTypes = ["png", "jpeg", "webp"] + const imageFiles = files.filter((file) => { + const [type, subtype] = file.type.split("/") + return type === "image" && acceptedTypes.includes(subtype) + }) + if (!shouldDisableImages && imageFiles.length > 0) { + const imagePromises = imageFiles.map((file) => { + return new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = () => { + if (reader.error) { + console.error("Error reading file:", reader.error) + resolve(null) + } else { + const result = reader.result + resolve(typeof result === "string" ? result : null) + } + } + reader.readAsDataURL(file) + }) + }) + const imageDataArray = await Promise.all(imagePromises) + const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) + if (dataUrls.length > 0) { + setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) + if (typeof vscode !== 'undefined') { + vscode.postMessage({ + type: 'draggedImages', + dataUrls: dataUrls + }) + } + } else { + console.warn("No valid images were processed") + } + } + }} + onDragOver={(e) => { + e.preventDefault() + }} + > {showContextMenu && (
( />
)} - {!isTextAreaFocused && ( + +
0 ? `${thumbnailsHeight + 16}px` : 0, + zIndex: 1 }} /> - )} -
- { - if (typeof ref === "function") { - ref(el) - } else if (ref) { - ref.current = el - } - textAreaRef.current = el - }} - value={inputValue} - disabled={textAreaDisabled} - onChange={(e) => { - handleInputChange(e) - updateHighlights() - }} - onKeyDown={handleKeyDown} - onKeyUp={handleKeyUp} - onFocus={() => setIsTextAreaFocused(true)} - onBlur={handleBlur} - onPaste={handlePaste} - onSelect={updateCursorPosition} - onMouseUp={updateCursorPosition} - onHeightChange={(height) => { - if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { - setTextAreaBaseHeight(height) - } - onHeightChange?.(height) - }} - placeholder={placeholderText} - minRows={2} - maxRows={20} - autoFocus={true} - style={{ - width: "100%", - boxSizing: "border-box", - backgroundColor: "transparent", - color: "var(--vscode-input-foreground)", - borderRadius: 2, - fontFamily: "var(--vscode-font-family)", - fontSize: "var(--vscode-editor-font-size)", - lineHeight: "var(--vscode-editor-line-height)", - resize: "none", - overflowX: "hidden", - overflowY: "scroll", - borderLeft: 0, - borderRight: 0, - borderTop: 0, - borderBottom: `${thumbnailsHeight + 6}px solid transparent`, - borderColor: "transparent", - padding: "9px 9px 25px 9px", - marginBottom: "15px", - cursor: textAreaDisabled ? "not-allowed" : undefined, - flex: 1, - zIndex: 1, - }} - onScroll={() => updateHighlights()} - /> + { + if (typeof ref === "function") { + ref(el) + } else if (ref) { + ref.current = el + } + textAreaRef.current = el + }} + value={inputValue} + disabled={textAreaDisabled} + onChange={(e) => { + handleInputChange(e) + updateHighlights() + }} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onBlur={handleBlur} + onPaste={handlePaste} + onSelect={updateCursorPosition} + onMouseUp={updateCursorPosition} + onHeightChange={(height) => { + if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { + setTextAreaBaseHeight(height) + } + onHeightChange?.(height) + }} + placeholder={placeholderText} + minRows={4} + maxRows={20} + autoFocus={true} + style={{ + width: "100%", + boxSizing: "border-box", + backgroundColor: "transparent", + color: "var(--vscode-input-foreground)", + borderRadius: 2, + fontFamily: "var(--vscode-font-family)", + fontSize: "var(--vscode-editor-font-size)", + lineHeight: "var(--vscode-editor-line-height)", + resize: "none", + overflowX: "hidden", + overflowY: "auto", + border: "none", + padding: "8px", + marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0, + cursor: textAreaDisabled ? "not-allowed" : undefined, + flex: "0 1 auto", + zIndex: 2 + }} + onScroll={() => updateHighlights()} + /> +
+ {selectedImages.length > 0 && ( ( onHeightChange={handleThumbnailsHeightChange} style={{ position: "absolute", - paddingTop: 4, - bottom: 36, - left: 22, - right: 67, + bottom: "36px", + left: "16px", zIndex: 2, + marginBottom: "8px" }} /> )} -
+
- - { + const newMode = e.target.value as Mode + setMode(newMode) + vscode.postMessage({ + type: "mode", + text: newMode + }) + }} style={{ - backgroundColor: "var(--vscode-dropdown-background)", - color: "var(--vscode-dropdown-foreground)" + ...selectStyle, + minWidth: "70px", + flex: "0 0 auto" }} > - {config.name} - - ))} - -
-
- -
- {isEnhancingPrompt ? ( - - ) : ( - !textAreaDisabled && handleEnhancePrompt()} - style={{ fontSize: 16.5 }} - /> - )} + + + + +
+ +
- !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} /> - !textAreaDisabled && onSend()} style={{ fontSize: 15 }} /> - + +
+ +
+ +
+
+
+ +
+
+ {isEnhancingPrompt ? ( + + ) : ( + !textAreaDisabled && handleEnhancePrompt()} + style={{ fontSize: 16.5 }} + /> + )} +
+ !shouldDisableImages && onSelectImages()} + style={{ fontSize: 16.5 }} + /> + !textAreaDisabled && onSend()} + style={{ fontSize: 15 }} + /> +
) diff --git a/webview-ui/src/components/common/CaretIcon.tsx b/webview-ui/src/components/common/CaretIcon.tsx new file mode 100644 index 0000000..ab1126d --- /dev/null +++ b/webview-ui/src/components/common/CaretIcon.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +export const CaretIcon = () => ( + + + +) \ No newline at end of file From 84d1bae081898f1d2a5c6bceda1e83e5769f3902 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jan 2025 05:57:32 +0000 Subject: [PATCH 14/15] changeset version bump --- .changeset/fresh-jobs-repair.md | 5 ----- .changeset/sharp-hairs-itch.md | 5 ----- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 .changeset/fresh-jobs-repair.md delete mode 100644 .changeset/sharp-hairs-itch.md diff --git a/.changeset/fresh-jobs-repair.md b/.changeset/fresh-jobs-repair.md deleted file mode 100644 index 23bc920..0000000 --- a/.changeset/fresh-jobs-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Fix chat text input layout issues diff --git a/.changeset/sharp-hairs-itch.md b/.changeset/sharp-hairs-itch.md deleted file mode 100644 index 20a6e16..0000000 --- a/.changeset/sharp-hairs-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Fix color for the light+ themes diff --git a/CHANGELOG.md b/CHANGELOG.md index db9aee9..46f3c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Roo Cline Changelog +## 3.1.1 + +### Patch Changes + +- Fix chat text input layout issues +- Fix color for the light+ themes + ## [3.1.0] - You can now customize the role definition and instructions for each chat mode (Code, Architect, and Ask), either through the new Prompts tab in the top menu or mode-specific .clinerules-mode files. Prompt Enhancements have also been revamped: the "Enhance Prompt" button now works with any provider and API configuration, giving you the ability to craft messages with fully customizable prompts for even better results. diff --git a/package-lock.json b/package-lock.json index 52339e0..dd78add 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roo-cline", - "version": "3.1.0", + "version": "3.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roo-cline", - "version": "3.1.0", + "version": "3.1.1", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.26.0", diff --git a/package.json b/package.json index 7219253..8892e2a 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": "3.1.0", + "version": "3.1.1", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", From 2f2e9d875f0d7d9c3f90257e81bd03667863245f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 15 Jan 2025 01:02:51 -0500 Subject: [PATCH 15/15] Update CHANGELOG.md --- CHANGELOG.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f3c6c..fb45893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,8 @@ # Roo Cline Changelog -## 3.1.1 +## [3.1.1] -### Patch Changes - -- Fix chat text input layout issues -- Fix color for the light+ themes +- Visual fixes to chat input and settings for the light+ themes ## [3.1.0]