From 75e308b0332113d0964b511a32634e86787aff9a Mon Sep 17 00:00:00 2001
From: Matt Rubens
Date: Mon, 13 Jan 2025 03:16:10 -0500
Subject: [PATCH] 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) => (
+
+ setActiveTab(tab.id)}
+ style={{
+ padding: '4px 0',
+ border: 'none',
+ background: 'none',
+ color: activeTab === tab.id ? 'var(--vscode-textLink-foreground)' : 'var(--vscode-foreground)',
+ cursor: 'pointer',
+ opacity: activeTab === tab.id ? 1 : 0.8,
+ borderBottom: activeTab === tab.id ?
+ '1px solid var(--vscode-textLink-foreground)' :
+ '1px solid var(--vscode-foreground)',
+ fontWeight: 'bold'
+ }}
+ >
+ {tab.label}
+
+ {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}