Add a screen for custom prompts

This commit is contained in:
Matt Rubens
2025-01-13 03:16:10 -05:00
parent 4027e1c10c
commit 75e308b033
21 changed files with 1044 additions and 238 deletions

View File

@@ -74,6 +74,11 @@
"title": "MCP Servers", "title": "MCP Servers",
"icon": "$(server)" "icon": "$(server)"
}, },
{
"command": "roo-cline.promptsButtonClicked",
"title": "Prompts",
"icon": "$(notebook)"
},
{ {
"command": "roo-cline.historyButtonClicked", "command": "roo-cline.historyButtonClicked",
"title": "History", "title": "History",
@@ -103,24 +108,29 @@
"when": "view == roo-cline.SidebarProvider" "when": "view == roo-cline.SidebarProvider"
}, },
{ {
"command": "roo-cline.mcpButtonClicked", "command": "roo-cline.promptsButtonClicked",
"group": "navigation@2", "group": "navigation@2",
"when": "view == roo-cline.SidebarProvider" "when": "view == roo-cline.SidebarProvider"
}, },
{ {
"command": "roo-cline.historyButtonClicked", "command": "roo-cline.mcpButtonClicked",
"group": "navigation@3", "group": "navigation@3",
"when": "view == roo-cline.SidebarProvider" "when": "view == roo-cline.SidebarProvider"
}, },
{ {
"command": "roo-cline.popoutButtonClicked", "command": "roo-cline.historyButtonClicked",
"group": "navigation@4", "group": "navigation@4",
"when": "view == roo-cline.SidebarProvider" "when": "view == roo-cline.SidebarProvider"
}, },
{ {
"command": "roo-cline.settingsButtonClicked", "command": "roo-cline.popoutButtonClicked",
"group": "navigation@5", "group": "navigation@5",
"when": "view == roo-cline.SidebarProvider" "when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.settingsButtonClicked",
"group": "navigation@6",
"when": "view == roo-cline.SidebarProvider"
} }
] ]
}, },

View File

@@ -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( const systemPrompt = await SYSTEM_PROMPT(
cwd, cwd,
this.api.getModel().info.supportsComputerUse ?? false, this.api.getModel().info.supportsComputerUse ?? false,
mcpHub, mcpHub,
this.diffStrategy, this.diffStrategy,
browserViewportSize, browserViewportSize,
mode mode,
customPrompts
) + await addCustomInstructions(this.customInstructions ?? '', cwd, preferredLanguage) ) + 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 // 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

View File

@@ -1,4 +1,4 @@
import { architectMode } from "./modes" import { architectMode, defaultPrompts } from "../../shared/modes"
import { getToolDescriptionsForMode } from "./tools" import { getToolDescriptionsForMode } from "./tools"
import { import {
getRulesSection, getRulesSection,
@@ -20,7 +20,8 @@ export const ARCHITECT_PROMPT = async (
mcpHub?: McpHub, mcpHub?: McpHub,
diffStrategy?: DiffStrategy, diffStrategy?: DiffStrategy,
browserViewportSize?: string, 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()} ${getSharedToolUseSection()}

View File

@@ -1,4 +1,4 @@
import { Mode, askMode } from "./modes" import { Mode, askMode, defaultPrompts } from "../../shared/modes"
import { getToolDescriptionsForMode } from "./tools" import { getToolDescriptionsForMode } from "./tools"
import { import {
getRulesSection, getRulesSection,
@@ -21,7 +21,8 @@ export const ASK_PROMPT = async (
mcpHub?: McpHub, mcpHub?: McpHub,
diffStrategy?: DiffStrategy, diffStrategy?: DiffStrategy,
browserViewportSize?: string, 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()} ${getSharedToolUseSection()}

View File

@@ -1,4 +1,4 @@
import { Mode, codeMode } from "./modes" import { Mode, codeMode, defaultPrompts } from "../../shared/modes"
import { getToolDescriptionsForMode } from "./tools" import { getToolDescriptionsForMode } from "./tools"
import { import {
getRulesSection, getRulesSection,
@@ -21,7 +21,8 @@ export const CODE_PROMPT = async (
mcpHub?: McpHub, mcpHub?: McpHub,
diffStrategy?: DiffStrategy, diffStrategy?: DiffStrategy,
browserViewportSize?: string, 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()} ${getSharedToolUseSection()}

View File

@@ -63,15 +63,16 @@ export const SYSTEM_PROMPT = async (
mcpHub?: McpHub, mcpHub?: McpHub,
diffStrategy?: DiffStrategy, diffStrategy?: DiffStrategy,
browserViewportSize?: string, browserViewportSize?: string,
mode: Mode = codeMode, mode: Mode = codeMode,
customPrompts?: { ask?: string; code?: string; architect?: string; enhance?: string },
) => { ) => {
switch (mode) { switch (mode) {
case architectMode: case architectMode:
return ARCHITECT_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize) return ARCHITECT_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize, customPrompts?.architect)
case askMode: case askMode:
return ASK_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize) return ASK_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize, customPrompts?.ask)
default: default:
return CODE_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize) return CODE_PROMPT(cwd, supportsComputerUse, mcpHub, diffStrategy, browserViewportSize, customPrompts?.code)
} }
} }

View File

@@ -17,6 +17,8 @@ import { findLast } from "../../shared/array"
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem" import { HistoryItem } from "../../shared/HistoryItem"
import { WebviewMessage } from "../../shared/WebviewMessage" import { WebviewMessage } from "../../shared/WebviewMessage"
import { defaultPrompts } from "../../shared/modes"
import { SYSTEM_PROMPT, addCustomInstructions } from "../prompts/system"
import { fileExistsAtPath } from "../../utils/fs" import { fileExistsAtPath } from "../../utils/fs"
import { Cline } from "../Cline" import { Cline } from "../Cline"
import { openMention } from "../mentions" import { openMention } from "../mentions"
@@ -28,7 +30,7 @@ import { enhancePrompt } from "../../utils/enhance-prompt"
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git" import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
import { ConfigManager } from "../config/ConfigManager" import { ConfigManager } from "../config/ConfigManager"
import { Mode } from "../prompts/types" 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 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" | "listApiConfigMeta"
| "mode" | "mode"
| "modeApiConfigs" | "modeApiConfigs"
| "customPrompts"
| "enhancementApiConfigId"
export const GlobalFileNames = { export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json", apiConversationHistory: "api_conversation_history.json",
@@ -111,7 +115,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private cline?: Cline private cline?: Cline
private workspaceTracker?: WorkspaceTracker private workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub 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 configManager: ConfigManager
constructor( constructor(
@@ -727,6 +731,32 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postStateToWebview() await this.postStateToWebview()
break 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": { case "deleteMessage": {
const answer = await vscode.window.showInformationMessage( const answer = await vscode.window.showInformationMessage(
"What would you like to delete?", "What would you like to delete?",
@@ -797,16 +827,28 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("screenshotQuality", message.value) await this.updateGlobalState("screenshotQuality", message.value)
await this.postStateToWebview() await this.postStateToWebview()
break break
case "enhancementApiConfigId":
await this.updateGlobalState("enhancementApiConfigId", message.text)
await this.postStateToWebview()
break
case "enhancePrompt": case "enhancePrompt":
if (message.text) { if (message.text) {
try { try {
const { apiConfiguration } = await this.getState() const { apiConfiguration, customPrompts, listApiConfigMeta, enhancementApiConfigId } = await this.getState()
const enhanceConfig = {
...apiConfiguration, // Try to get enhancement config first, fall back to current config
apiProvider: "openrouter" as const, let configToUse: ApiConfiguration = apiConfiguration
openRouterModelId: "gpt-4o", 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({ await this.postMessageToWebview({
type: "enhancedPrompt", type: "enhancedPrompt",
text: enhancedPrompt text: enhancedPrompt
@@ -814,11 +856,37 @@ export class ClineProvider implements vscode.WebviewViewProvider {
} catch (error) { } catch (error) {
console.error("Error enhancing prompt:", error) console.error("Error enhancing prompt:", error)
vscode.window.showErrorMessage("Failed to enhance prompt") vscode.window.showErrorMessage("Failed to enhance prompt")
await this.postMessageToWebview({
type: "enhancedPrompt"
})
} }
} }
break 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": { case "searchCommits": {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (cwd) { if (cwd) {
@@ -1482,6 +1550,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
currentApiConfigName, currentApiConfigName,
listApiConfigMeta, listApiConfigMeta,
mode, mode,
customPrompts,
enhancementApiConfigId,
} = await this.getState() } = await this.getState()
const allowedCommands = vscode.workspace const allowedCommands = vscode.workspace
@@ -1500,11 +1570,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
uriScheme: vscode.env.uriScheme, uriScheme: vscode.env.uriScheme,
clineMessages: this.cline?.clineMessages || [], clineMessages: this.cline?.clineMessages || [],
taskHistory: (taskHistory || []) taskHistory: (taskHistory || [])
.filter((item) => item.ts && item.task) .filter((item: HistoryItem) => item.ts && item.task)
.sort((a, b) => b.ts - a.ts), .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
soundEnabled: soundEnabled ?? false, soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? true, diffEnabled: diffEnabled ?? true,
shouldShowAnnouncement: false, // lastShownAnnouncementId !== this.latestAnnouncementId, shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
allowedCommands, allowedCommands,
soundVolume: soundVolume ?? 0.5, soundVolume: soundVolume ?? 0.5,
browserViewportSize: browserViewportSize ?? "900x600", browserViewportSize: browserViewportSize ?? "900x600",
@@ -1519,6 +1589,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
currentApiConfigName: currentApiConfigName ?? "default", currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [], listApiConfigMeta: listApiConfigMeta ?? [],
mode: mode ?? codeMode, mode: mode ?? codeMode,
customPrompts: customPrompts ?? {},
enhancementApiConfigId,
} }
} }
@@ -1630,6 +1702,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
listApiConfigMeta, listApiConfigMeta,
mode, mode,
modeApiConfigs, modeApiConfigs,
customPrompts,
enhancementApiConfigId,
] = await Promise.all([ ] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>, this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1686,6 +1760,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>, this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
this.getGlobalState("mode") as Promise<Mode | undefined>, this.getGlobalState("mode") as Promise<Mode | undefined>,
this.getGlobalState("modeApiConfigs") as Promise<Record<Mode, string> | undefined>, this.getGlobalState("modeApiConfigs") as Promise<Record<Mode, string> | undefined>,
this.getGlobalState("customPrompts") as Promise<CustomPrompts | undefined>,
this.getGlobalState("enhancementApiConfigId") as Promise<string | undefined>,
]) ])
let apiProvider: ApiProvider let apiProvider: ApiProvider
@@ -1786,6 +1862,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
currentApiConfigName: currentApiConfigName ?? "default", currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [], listApiConfigMeta: listApiConfigMeta ?? [],
modeApiConfigs: modeApiConfigs ?? {} as Record<Mode, string>, modeApiConfigs: modeApiConfigs ?? {} as Record<Mode, string>,
customPrompts: customPrompts ?? {},
enhancementApiConfigId,
} }
} }

View File

@@ -62,6 +62,7 @@ jest.mock('vscode', () => ({
}, },
window: { window: {
showInformationMessage: jest.fn(), showInformationMessage: jest.fn(),
showErrorMessage: jest.fn(),
}, },
workspace: { workspace: {
getConfiguration: jest.fn().mockReturnValue({ getConfiguration: jest.fn().mockReturnValue({
@@ -113,6 +114,13 @@ jest.mock('../../../api', () => ({
buildApiHandler: jest.fn() 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 // Mock WorkspaceTracker
jest.mock('../../../integrations/workspace/WorkspaceTracker', () => { jest.mock('../../../integrations/workspace/WorkspaceTracker', () => {
return jest.fn().mockImplementation(() => ({ return jest.fn().mockImplementation(() => ({
@@ -504,6 +512,106 @@ describe('ClineProvider', () => {
expect(mockPostMessage).toHaveBeenCalled() 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 () => { test('file content includes line numbers', async () => {
const { extractTextFromFile } = require('../../../integrations/misc/extract-text') const { extractTextFromFile } = require('../../../integrations/misc/extract-text')
const result = await extractTextFromFile('test.js') const result = await extractTextFromFile('test.js')
@@ -654,4 +762,103 @@ describe('ClineProvider', () => {
expect(mockCline.overwriteApiConversationHistory).not.toHaveBeenCalled() 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')
})
})
}) })

View File

@@ -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 () => { const openClineInNewTab = async () => {
outputChannel.appendLine("Opening Cline in new tab") 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) // (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)

View File

@@ -4,7 +4,7 @@ import { ApiConfiguration, ApiProvider, ModelInfo } from "./api"
import { HistoryItem } from "./HistoryItem" import { HistoryItem } from "./HistoryItem"
import { McpServer } from "./mcp" import { McpServer } from "./mcp"
import { GitCommit } from "../utils/git" import { GitCommit } from "../utils/git"
import { Mode } from "../core/prompts/types" import { Mode, CustomPrompts } from "./modes"
// webview will hold state // webview will hold state
export interface ExtensionMessage { export interface ExtensionMessage {
@@ -25,12 +25,15 @@ export interface ExtensionMessage {
| "enhancedPrompt" | "enhancedPrompt"
| "commitSearchResults" | "commitSearchResults"
| "listApiConfig" | "listApiConfig"
| "updatePrompt"
| "systemPrompt"
text?: string text?: string
action?: action?:
| "chatButtonClicked" | "chatButtonClicked"
| "mcpButtonClicked" | "mcpButtonClicked"
| "settingsButtonClicked" | "settingsButtonClicked"
| "historyButtonClicked" | "historyButtonClicked"
| "promptsButtonClicked"
| "didBecomeVisible" | "didBecomeVisible"
invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
state?: ExtensionState state?: ExtensionState
@@ -45,6 +48,7 @@ export interface ExtensionMessage {
mcpServers?: McpServer[] mcpServers?: McpServer[]
commits?: GitCommit[] commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[] listApiConfig?: ApiConfigMeta[]
mode?: Mode | 'enhance'
} }
export interface ApiConfigMeta { export interface ApiConfigMeta {
@@ -62,6 +66,7 @@ export interface ExtensionState {
currentApiConfigName?: string currentApiConfigName?: string
listApiConfigMeta?: ApiConfigMeta[] listApiConfigMeta?: ApiConfigMeta[]
customInstructions?: string customInstructions?: string
customPrompts?: CustomPrompts
alwaysAllowReadOnly?: boolean alwaysAllowReadOnly?: boolean
alwaysAllowWrite?: boolean alwaysAllowWrite?: boolean
alwaysAllowExecute?: boolean alwaysAllowExecute?: boolean
@@ -82,7 +87,8 @@ export interface ExtensionState {
terminalOutputLineLimit?: number terminalOutputLineLimit?: number
mcpEnabled: boolean mcpEnabled: boolean
mode: Mode mode: Mode
modeApiConfigs?: Record<Mode, string>; modeApiConfigs?: Record<Mode, string>
enhancementApiConfigId?: string
} }
export interface ClineMessage { export interface ClineMessage {

View File

@@ -1,4 +1,7 @@
import { ApiConfiguration, ApiProvider } from "./api" import { ApiConfiguration, ApiProvider } from "./api"
import { Mode } from "./modes"
export type PromptMode = Mode | 'enhance'
export type AudioType = "notification" | "celebration" | "progress_loop" export type AudioType = "notification" | "celebration" | "progress_loop"
@@ -62,6 +65,10 @@ export interface WebviewMessage {
| "requestDelaySeconds" | "requestDelaySeconds"
| "setApiConfigPassword" | "setApiConfigPassword"
| "mode" | "mode"
| "updatePrompt"
| "getSystemPrompt"
| "systemPrompt"
| "enhancementApiConfigId"
text?: string text?: string
disabled?: boolean disabled?: boolean
askResponse?: ClineAskResponse askResponse?: ClineAskResponse
@@ -74,6 +81,9 @@ export interface WebviewMessage {
serverName?: string serverName?: string
toolName?: string toolName?: string
alwaysAllow?: boolean alwaysAllow?: boolean
mode?: Mode
promptMode?: PromptMode
customPrompt?: string
dataUrls?: string[] dataUrls?: string[]
values?: Record<string, any> values?: Record<string, any>
query?: string query?: string

View File

@@ -3,3 +3,17 @@ export const architectMode = 'architect' as const;
export const askMode = 'ask' as const; export const askMode = 'ask' as const;
export type Mode = typeof codeMode | typeof architectMode | typeof askMode; 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;

View File

@@ -1,80 +1,126 @@
import { enhancePrompt } from '../enhance-prompt' import { enhancePrompt } from '../enhance-prompt'
import { buildApiHandler } from '../../api'
import { ApiConfiguration } from '../../shared/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', () => ({ jest.mock('../../api', () => ({
buildApiHandler: jest.fn() buildApiHandler: jest.fn()
})) }))
describe('enhancePrompt', () => { describe('enhancePrompt', () => {
const mockApiConfig: ApiConfiguration = { const mockApiConfig: ApiConfiguration = {
apiProvider: 'openrouter', apiProvider: 'openai',
apiKey: 'test-key', openAiApiKey: 'test-key',
openRouterApiKey: 'test-key', openAiBaseUrl: 'https://api.openai.com/v1'
openRouterModelId: 'test-model' }
}
// Create a mock handler that looks like OpenRouterHandler beforeEach(() => {
const mockHandler = { jest.clearAllMocks()
completePrompt: jest.fn(),
createMessage: jest.fn(),
getModel: jest.fn()
}
// Make instanceof check work // Mock the API handler with a completePrompt method
Object.setPrototypeOf(mockHandler, OpenRouterHandler.prototype) ;(buildApiHandler as jest.Mock).mockReturnValue({
completePrompt: jest.fn().mockResolvedValue('Enhanced prompt'),
beforeEach(() => { createMessage: jest.fn(),
jest.clearAllMocks() getModel: jest.fn().mockReturnValue({
;(buildApiHandler as jest.Mock).mockReturnValue(mockHandler) id: 'test-model',
}) info: {
maxTokens: 4096,
it('should throw error for non-OpenRouter providers', async () => { contextWindow: 8192,
const nonOpenRouterConfig: ApiConfiguration = { supportsPromptCache: false
apiProvider: 'anthropic',
apiKey: 'test-key',
apiModelId: 'claude-3'
} }
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 () => { await expect(enhancePrompt(mockApiConfig, 'Test prompt')).rejects.toThrow('The selected API provider does not support prompt enhancement')
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) it('uses appropriate model based on provider', async () => {
const openRouterConfig: ApiConfiguration = {
apiProvider: 'openrouter',
openRouterApiKey: 'test-key',
openRouterModelId: 'test-model'
}
const result = await enhancePrompt(mockApiConfig, 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)
expect(result).toBe(enhancedPrompt) const result = await enhancePrompt(openRouterConfig, 'Test prompt')
expect(buildApiHandler).toHaveBeenCalledWith(mockApiConfig)
expect(mockHandler.completePrompt).toHaveBeenCalledWith(
expect.stringContaining(inputPrompt)
)
})
it('should throw error when no prompt text is provided', async () => { expect(buildApiHandler).toHaveBeenCalledWith(openRouterConfig)
await expect(enhancePrompt(mockApiConfig, '')).rejects.toThrow('No prompt text provided') expect(result).toBe('Enhanced prompt')
expect(mockHandler.completePrompt).not.toHaveBeenCalled() })
})
it('should pass through API errors', async () => { it('propagates API errors', async () => {
const inputPrompt = 'Test prompt' (buildApiHandler as jest.Mock).mockReturnValue({
mockHandler.completePrompt.mockRejectedValue('API error') 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') await expect(enhancePrompt(mockApiConfig, 'Test prompt')).rejects.toThrow('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'
)
})
}) })

View File

@@ -1,26 +1,27 @@
import { ApiConfiguration } from "../shared/api" import { ApiConfiguration } from "../shared/api"
import { buildApiHandler } from "../api" import { buildApiHandler, SingleCompletionHandler } from "../api"
import { OpenRouterHandler } from "../api/providers/openrouter" 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. * This is a lightweight alternative that only uses the API's completion functionality.
*/ */
export async function enhancePrompt(apiConfiguration: ApiConfiguration, promptText: string): Promise<string> { export async function enhancePrompt(apiConfiguration: ApiConfiguration, promptText: string, enhancePrompt?: string): Promise<string> {
if (!promptText) { if (!promptText) {
throw new Error("No prompt text provided") throw new Error("No prompt text provided")
} }
if (apiConfiguration.apiProvider !== "openrouter") { if (!apiConfiguration || !apiConfiguration.apiProvider) {
throw new Error("Prompt enhancement is only available with OpenRouter") throw new Error("No valid API configuration provided")
} }
const handler = buildApiHandler(apiConfiguration) const handler = buildApiHandler(apiConfiguration)
// Type guard to check if handler is OpenRouterHandler // Check if handler supports single completions
if (!(handler instanceof OpenRouterHandler)) { if (!('completePrompt' in handler)) {
throw new Error("Expected OpenRouter 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}` const enhancePromptText = enhancePrompt ?? defaultPrompts.enhance
return handler.completePrompt(prompt) const prompt = `${enhancePromptText}\n\n${promptText}`
return (handler as SingleCompletionHandler).completePrompt(prompt)
} }

View File

@@ -8,12 +8,14 @@ import WelcomeView from "./components/welcome/WelcomeView"
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext" import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
import { vscode } from "./utils/vscode" import { vscode } from "./utils/vscode"
import McpView from "./components/mcp/McpView" import McpView from "./components/mcp/McpView"
import PromptsView from "./components/prompts/PromptsView"
const AppContent = () => { const AppContent = () => {
const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState() const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState()
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false) const [showHistory, setShowHistory] = useState(false)
const [showMcp, setShowMcp] = useState(false) const [showMcp, setShowMcp] = useState(false)
const [showPrompts, setShowPrompts] = useState(false)
const [showAnnouncement, setShowAnnouncement] = useState(false) const [showAnnouncement, setShowAnnouncement] = useState(false)
const handleMessage = useCallback((e: MessageEvent) => { const handleMessage = useCallback((e: MessageEvent) => {
@@ -25,21 +27,31 @@ const AppContent = () => {
setShowSettings(true) setShowSettings(true)
setShowHistory(false) setShowHistory(false)
setShowMcp(false) setShowMcp(false)
setShowPrompts(false)
break break
case "historyButtonClicked": case "historyButtonClicked":
setShowSettings(false) setShowSettings(false)
setShowHistory(true) setShowHistory(true)
setShowMcp(false) setShowMcp(false)
setShowPrompts(false)
break break
case "mcpButtonClicked": case "mcpButtonClicked":
setShowSettings(false) setShowSettings(false)
setShowHistory(false) setShowHistory(false)
setShowMcp(true) setShowMcp(true)
setShowPrompts(false)
break
case "promptsButtonClicked":
setShowSettings(false)
setShowHistory(false)
setShowMcp(false)
setShowPrompts(true)
break break
case "chatButtonClicked": case "chatButtonClicked":
setShowSettings(false) setShowSettings(false)
setShowHistory(false) setShowHistory(false)
setShowMcp(false) setShowMcp(false)
setShowPrompts(false)
break break
} }
break break
@@ -68,14 +80,16 @@ const AppContent = () => {
{showSettings && <SettingsView onDone={() => setShowSettings(false)} />} {showSettings && <SettingsView onDone={() => setShowSettings(false)} />}
{showHistory && <HistoryView onDone={() => setShowHistory(false)} />} {showHistory && <HistoryView onDone={() => setShowHistory(false)} />}
{showMcp && <McpView onDone={() => setShowMcp(false)} />} {showMcp && <McpView onDone={() => setShowMcp(false)} />}
{showPrompts && <PromptsView onDone={() => 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.) */} {/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
<ChatView <ChatView
showHistoryView={() => { showHistoryView={() => {
setShowSettings(false) setShowSettings(false)
setShowMcp(false) setShowMcp(false)
setShowPrompts(false)
setShowHistory(true) setShowHistory(true)
}} }}
isHidden={showSettings || showHistory || showMcp} isHidden={showSettings || showHistory || showMcp || showPrompts}
showAnnouncement={showAnnouncement} showAnnouncement={showAnnouncement}
hideAnnouncement={() => { hideAnnouncement={() => {
setShowAnnouncement(false) setShowAnnouncement(false)

View File

@@ -29,100 +29,39 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
style={{ position: "absolute", top: "8px", right: "8px" }}> style={{ position: "absolute", top: "8px", right: "8px" }}>
<span className="codicon codicon-close"></span> <span className="codicon codicon-close"></span>
</VSCodeButton> </VSCodeButton>
<h2 style={{ margin: "0 0 8px" }}>
🎉{" "}Introducing Roo Cline v{minorVersion}
</h2>
<h3 style={{ margin: "0 0 8px" }}> <h3 style={{ margin: "0 0 8px" }}>
🎉{" "}New in Cline v{minorVersion} Agent Modes Customization
</h3> </h3>
<p style={{ margin: "5px 0px", fontWeight: "bold" }}>Add custom tools to Cline using MCP!</p>
<p style={{ margin: "5px 0px" }}> <p style={{ margin: "5px 0px" }}>
The Model Context Protocol allows agents like Cline to plug and play custom tools,{" "} Click the new <span className="codicon codicon-notebook" style={{ fontSize: "10px" }}></span> icon in the menu bar to open the Prompts Settings and customize Agent Modes for new levels of productivity.
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
e.g. a web-search tool or GitHub tool.
</VSCodeLink>
</p>
<p style={{ margin: "5px 0px" }}>
You can add and configure MCP servers by clicking the new{" "}
<span className="codicon codicon-server" style={{ fontSize: "10px" }}></span> icon in the menu bar.
</p>
<p style={{ margin: "5px 0px" }}>
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{" "}
<i>your workflow</i>. For example:
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}> <ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
<li>"...fetches Jira tickets": Get ticket ACs and put Cline to work</li> <li>Tailor how Roo Cline behaves in different modes: Code, Architect, and Ask.</li>
<li>"...manages AWS EC2s": Check server metrics and scale up or down</li> <li>Preview and verify your changes using the Preview System Prompt button.</li>
<li>"...pulls PagerDuty incidents": Pulls details to help Cline fix bugs</li>
</ul> </ul>
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 <code>~/Documents/Cline/MCP</code> so you can easily share them
with others too.{" "}
</p> </p>
<h3 style={{ margin: "0 0 8px" }}>
Prompt Enhancement Configuration
</h3>
<p style={{ margin: "5px 0px" }}> <p style={{ margin: "5px 0px" }}>
Try it yourself by asking Cline to "add a tool that gets the latest npm docs", or Now available for all providers! Access it directly in the chat box by clicking the <span className="codicon codicon-sparkle" style={{ fontSize: "10px" }}></span> sparkle icon next to the input field. From there, you can customize the enhancement logic and provider to best suit your workflow.
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}> <ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
see a demo of MCP in action here. <li>Customize how prompts are enhanced for better results in your workflow.</li>
</VSCodeLink> <li>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.</li>
<li>Test your changes instantly with the Preview Prompt Enhancement tool.</li>
</ul>
</p> </p>
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li> <p style={{ margin: "5px 0px" }}>
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers, We're very excited to see what you build with this new feature! Join us at
so I recommend trying them out. <VSCodeLink href="https://www.reddit.com/r/roocline" style={{ display: "inline" }}>
<br /> reddit.com/r/roocline
{!apiConfiguration?.openRouterApiKey && (
<VSCodeButtonLink
href={getOpenRouterAuthUrl(vscodeUriScheme)}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
margin: "4px -30px 2px 0",
}}>
Get OpenRouter API Key
</VSCodeButtonLink>
)}
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
<VSCodeButton
onClick={() => {
vscode.postMessage({
type: "apiConfiguration",
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
})
}}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
margin: "4px -30px 2px 0",
}}>
Switch to OpenRouter
</VSCodeButton>
)}
</li>
<li>
<b>Edit Cline's changes before accepting!</b> 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 "<code>{"// rest of code here"}</code>" shenanigans)
</li>
<li>
New <code>search_files</code> tool that lets Cline perform regex searches in your project, letting
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
</li>
<li>
When Cline runs commands, you can now type directly in the terminal (+ support for Python
environments)
</li>
</ul>*/}
<div
style={{
height: "1px",
background: "var(--vscode-foreground)",
opacity: 0.1,
margin: "8px 0",
}}
/>
<p style={{ margin: "0" }}>
Join
<VSCodeLink style={{ display: "inline" }} href="https://discord.gg/cline">
discord.gg/cline
</VSCodeLink> </VSCodeLink>
for more updates! to discuss and share feedback.
</p> </p>
</div> </div>
) )

View File

@@ -49,7 +49,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, },
ref, ref,
) => { ) => {
const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState() const { filePaths, currentApiConfigName, listApiConfigMeta } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<any[]>([]) const [gitCommits, setGitCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false) const [showDropdown, setShowDropdown] = useState(false)
@@ -69,8 +69,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
useEffect(() => { useEffect(() => {
const messageHandler = (event: MessageEvent) => { const messageHandler = (event: MessageEvent) => {
const message = event.data const message = event.data
if (message.type === 'enhancedPrompt' && message.text) { if (message.type === 'enhancedPrompt') {
setInputValue(message.text) if (message.text) {
setInputValue(message.text)
}
setIsEnhancingPrompt(false) setIsEnhancingPrompt(false)
} else if (message.type === 'commitSearchResults') { } else if (message.type === 'commitSearchResults') {
const commits = message.commits.map((commit: any) => ({ const commits = message.commits.map((commit: any) => ({
@@ -767,19 +769,25 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div> </div>
<div className="button-row" style={{ position: "absolute", right: 16, display: "flex", alignItems: "center", height: 31, bottom: 11, zIndex: 3, padding: "0 8px", justifyContent: "flex-end", backgroundColor: "var(--vscode-input-background)", }}> <div className="button-row" style={{ position: "absolute", right: 16, display: "flex", alignItems: "center", height: 31, bottom: 11, zIndex: 3, padding: "0 8px", justifyContent: "flex-end", backgroundColor: "var(--vscode-input-background)", }}>
<span style={{ display: "flex", alignItems: "center", gap: 12 }}> <span style={{ display: "flex", alignItems: "center", gap: 12 }}>
{apiConfiguration?.apiProvider === "openrouter" && ( <div style={{ display: "flex", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center" }}> {isEnhancingPrompt ? (
{isEnhancingPrompt && <span style={{ marginRight: 10, color: "var(--vscode-input-foreground)", opacity: 0.5 }}>Enhancing prompt...</span>} <span className="codicon codicon-loading codicon-modifier-spin" style={{
<span color: "var(--vscode-input-foreground)",
role="button" opacity: 0.5,
aria-label="enhance prompt" fontSize: 16.5,
data-testid="enhance-prompt-button" marginRight: 10
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-sparkle`} }}></span>
onClick={() => !textAreaDisabled && handleEnhancePrompt()} ) : (
style={{ fontSize: 16.5 }} <span
/> role="button"
</div> aria-label="enhance prompt"
)} data-testid="enhance-prompt-button"
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-sparkle`}
onClick={() => !textAreaDisabled && handleEnhancePrompt()}
style={{ fontSize: 16.5 }}
/>
)}
</div>
<span className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`} onClick={() => !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} /> <span className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`} onClick={() => !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} />
<span className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`} onClick={() => !textAreaDisabled && onSend()} style={{ fontSize: 15 }} /> <span className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`} onClick={() => !textAreaDisabled && onSend()} style={{ fontSize: 15 }} />
</span> </span>

View File

@@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
import ChatTextArea from '../ChatTextArea'; import ChatTextArea from '../ChatTextArea';
import { useExtensionState } from '../../../context/ExtensionStateContext'; import { useExtensionState } from '../../../context/ExtensionStateContext';
import { vscode } from '../../../utils/vscode'; import { vscode } from '../../../utils/vscode';
import { codeMode } from '../../../../../src/shared/modes';
// Mock modules // Mock modules
jest.mock('../../../utils/vscode', () => ({ jest.mock('../../../utils/vscode', () => ({
@@ -32,6 +33,8 @@ describe('ChatTextArea', () => {
selectedImages: [], selectedImages: [],
setSelectedImages: jest.fn(), setSelectedImages: jest.fn(),
onHeightChange: jest.fn(), onHeightChange: jest.fn(),
mode: codeMode,
setMode: jest.fn(),
}; };
beforeEach(() => { beforeEach(() => {
@@ -46,37 +49,9 @@ describe('ChatTextArea', () => {
}); });
describe('enhance prompt button', () => { 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(<ChatTextArea {...defaultProps} />);
expect(screen.queryByTestId('enhance-prompt-button')).not.toBeInTheDocument();
// Test with openrouter provider
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'openrouter',
},
});
rerender(<ChatTextArea {...defaultProps} />);
const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i });
expect(enhanceButton).toBeInTheDocument();
});
it('should be disabled when textAreaDisabled is true', () => { it('should be disabled when textAreaDisabled is true', () => {
(useExtensionState as jest.Mock).mockReturnValue({ (useExtensionState as jest.Mock).mockReturnValue({
filePaths: [], filePaths: [],
apiConfiguration: {
apiProvider: 'openrouter',
},
}); });
render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />); render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />);
@@ -137,7 +112,8 @@ describe('ChatTextArea', () => {
const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i }); const enhanceButton = screen.getByRole('button', { name: /enhance prompt/i });
fireEvent.click(enhanceButton); fireEvent.click(enhanceButton);
expect(screen.getByText('Enhancing prompt...')).toBeInTheDocument(); const loadingSpinner = screen.getByText('', { selector: '.codicon-loading' });
expect(loadingSpinner).toBeInTheDocument();
}); });
}); });

View File

@@ -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>(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<HTMLElement>) => {
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 (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
flexDirection: "column",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 17px 10px 20px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Prompts</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0 20px" }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: "0 0 20px 0" }}>Agent Modes</h3>
<div
style={{
color: "var(--vscode-foreground)",
fontSize: "13px",
marginBottom: "20px",
marginTop: "5px",
}}>
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.
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
}}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{[
{ id: codeMode, label: 'Code' },
{ id: architectMode, label: 'Architect' },
{ id: askMode, label: 'Ask' },
].map((tab, index) => (
<React.Fragment key={tab.id}>
<button
data-testid={`${tab.id}-tab`}
data-active={activeTab === tab.id ? "true" : "false"}
onClick={() => 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}
</button>
{index < 2 && (
<span style={{ color: 'var(--vscode-foreground)', opacity: 0.4 }}>|</span>
)}
</React.Fragment>
))}
</div>
<VSCodeButton
appearance="icon"
onClick={() => handleReset(activeTab as any)}
data-testid="reset-prompt-button"
title="Revert to default"
>
<span className="codicon codicon-discard"></span>
</VSCodeButton>
</div>
<div style={{ marginBottom: '8px' }}>
{activeTab === codeMode && (
<VSCodeTextArea
value={getPromptValue(codeMode)}
onChange={(e) => handlePromptChange(codeMode, e)}
rows={4}
resize="vertical"
style={{ width: "100%" }}
data-testid="code-prompt-textarea"
/>
)}
{activeTab === architectMode && (
<VSCodeTextArea
value={getPromptValue(architectMode)}
onChange={(e) => handlePromptChange(architectMode, e)}
rows={4}
resize="vertical"
style={{ width: "100%" }}
data-testid="architect-prompt-textarea"
/>
)}
{activeTab === askMode && (
<VSCodeTextArea
value={getPromptValue(askMode)}
onChange={(e) => handlePromptChange(askMode, e)}
rows={4}
resize="vertical"
style={{ width: "100%" }}
data-testid="ask-prompt-textarea"
/>
)}
</div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'flex-end' }}>
<VSCodeButton
appearance="primary"
onClick={() => {
vscode.postMessage({
type: "getSystemPrompt",
mode: activeTab
})
}}
data-testid="preview-prompt-button"
>
Preview System Prompt
</VSCodeButton>
</div>
<h3 style={{ color: "var(--vscode-foreground)", margin: "40px 0 20px 0" }}>Prompt Enhancement</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div>
<div style={{ marginBottom: "12px" }}>
<div style={{ marginBottom: "8px" }}>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>API Configuration</div>
<div style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)" }}>
You can select an API configuration to always use for enhancing prompts, or just use whatever is currently selected
</div>
</div>
<VSCodeDropdown
value={enhancementApiConfigId || ''}
data-testid="api-config-dropdown"
onChange={(e: any) => {
const value = e.detail?.target?.value || e.target?.value
setEnhancementApiConfigId(value)
vscode.postMessage({
type: "enhancementApiConfigId",
text: value
})
}}
style={{ width: "300px" }}
>
<VSCodeOption value="">Use currently selected API configuration</VSCodeOption>
{(listApiConfigMeta || []).map((config) => (
<VSCodeOption key={config.id} value={config.id}>
{config.name}
</VSCodeOption>
))}
</VSCodeDropdown>
</div>
<div style={{ marginBottom: "8px", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: "bold" }}>Enhancement Prompt</div>
<div style={{ display: "flex", gap: "8px" }}>
<VSCodeButton appearance="icon" onClick={() => handleReset('enhance')} title="Revert to default">
<span className="codicon codicon-discard"></span>
</VSCodeButton>
</div>
</div>
<VSCodeTextArea
value={getPromptValue('enhance')}
onChange={(e) => handlePromptChange('enhance', e)}
rows={4}
resize="vertical"
style={{ width: "100%" }}
/>
<div style={{ marginTop: "12px" }}>
<VSCodeTextArea
value={testPrompt}
onChange={(e) => 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"
/>
<div style={{
marginTop: "8px",
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
gap: 8
}}>
<VSCodeButton
onClick={handleTestEnhancement}
disabled={isEnhancing}
appearance="primary"
>
Preview Prompt Enhancement
</VSCodeButton>
</div>
</div>
</div>
</div>
{/* Bottom padding */}
<div style={{ height: "20px" }} />
</div>
{isDialogOpen && (
<div style={{
position: 'fixed',
inset: 0,
display: 'flex',
justifyContent: 'flex-end',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000
}}>
<div style={{
width: 'calc(100vw - 100px)',
height: '100%',
backgroundColor: 'var(--vscode-editor-background)',
boxShadow: '-2px 0 5px rgba(0, 0, 0, 0.2)',
display: 'flex',
flexDirection: 'column',
padding: '20px',
overflowY: 'auto'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
}}>
<h2 style={{ margin: 0 }}>{selectedPromptTitle}</h2>
<VSCodeButton appearance="icon" onClick={() => setIsDialogOpen(false)}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
</div>
<VSCodeDivider />
<pre style={{
margin: '16px 0',
padding: '8px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--vscode-editor-font-family)',
fontSize: 'var(--vscode-editor-font-size)',
color: 'var(--vscode-editor-foreground)',
backgroundColor: 'var(--vscode-editor-background)',
border: '1px solid var(--vscode-editor-lineHighlightBorder)',
borderRadius: '4px',
flex: 1,
overflowY: 'auto'
}}>
{selectedPromptContent}
</pre>
</div>
</div>
)}
</div>
)
}
export default PromptsView

View File

@@ -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(
<ExtensionStateContext.Provider value={{ ...mockExtensionState, ...props } as any}>
<PromptsView onDone={mockOnDone} />
</ExtensionStateContext.Provider>
)
}
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'
})
})
})

View File

@@ -17,7 +17,7 @@ import {
checkExistKey checkExistKey
} from "../../../src/shared/checkExistApiConfig" } from "../../../src/shared/checkExistApiConfig"
import { Mode } from "../../../src/core/prompts/types" 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 { export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean didHydrateState: boolean
@@ -60,6 +60,9 @@ export interface ExtensionStateContextType extends ExtensionState {
onUpdateApiConfig: (apiConfig: ApiConfiguration) => void onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
mode: Mode mode: Mode
setMode: (value: Mode) => void setMode: (value: Mode) => void
setCustomPrompts: (value: CustomPrompts) => void
enhancementApiConfigId?: string
setEnhancementApiConfigId: (value: string) => void
} }
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined) export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -86,6 +89,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
currentApiConfigName: 'default', currentApiConfigName: 'default',
listApiConfigMeta: [], listApiConfigMeta: [],
mode: codeMode, mode: codeMode,
customPrompts: {},
enhancementApiConfigId: '',
}) })
const [didHydrateState, setDidHydrateState] = useState(false) const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false) const [showWelcome, setShowWelcome] = useState(false)
@@ -230,6 +235,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setListApiConfigMeta, setListApiConfigMeta,
onUpdateApiConfig, onUpdateApiConfig,
setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })), setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })),
setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
} }
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider> return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>