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",
"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"
}
]
},

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(
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

View File

@@ -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()}

View File

@@ -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()}

View File

@@ -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()}

View File

@@ -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)
}
}

View File

@@ -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<ApiProvider | 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("mode") as Promise<Mode | 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
@@ -1786,6 +1862,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
modeApiConfigs: modeApiConfigs ?? {} as Record<Mode, string>,
customPrompts: customPrompts ?? {},
enhancementApiConfigId,
}
}

View File

@@ -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')
})
})
})

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 () => {
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)

View File

@@ -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<Mode, string>;
modeApiConfigs?: Record<Mode, string>
enhancementApiConfigId?: string
}
export interface ClineMessage {

View File

@@ -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<string, any>
query?: string

View File

@@ -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;
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 { 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')
})
})

View File

@@ -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<string> {
export async function enhancePrompt(apiConfiguration: ApiConfiguration, promptText: string, enhancePrompt?: string): Promise<string> {
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)
}

View File

@@ -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 && <SettingsView onDone={() => setShowSettings(false)} />}
{showHistory && <HistoryView onDone={() => setShowHistory(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.) */}
<ChatView
showHistoryView={() => {
setShowSettings(false)
setShowMcp(false)
setShowPrompts(false)
setShowHistory(true)
}}
isHidden={showSettings || showHistory || showMcp}
isHidden={showSettings || showHistory || showMcp || showPrompts}
showAnnouncement={showAnnouncement}
hideAnnouncement={() => {
setShowAnnouncement(false)

View File

@@ -29,100 +29,39 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
style={{ position: "absolute", top: "8px", right: "8px" }}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
<h2 style={{ margin: "0 0 8px" }}>
🎉{" "}Introducing Roo Cline v{minorVersion}
</h2>
<h3 style={{ margin: "0 0 8px" }}>
🎉{" "}New in Cline v{minorVersion}
Agent Modes Customization
</h3>
<p style={{ margin: "5px 0px", fontWeight: "bold" }}>Add custom tools to Cline using MCP!</p>
<p style={{ margin: "5px 0px" }}>
The Model Context Protocol allows agents like Cline to plug and play custom tools,{" "}
<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:
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.
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
<li>"...fetches Jira tickets": Get ticket ACs and put Cline to work</li>
<li>"...manages AWS EC2s": Check server metrics and scale up or down</li>
<li>"...pulls PagerDuty incidents": Pulls details to help Cline fix bugs</li>
<li>Tailor how Roo Cline behaves in different modes: Code, Architect, and Ask.</li>
<li>Preview and verify your changes using the Preview System Prompt button.</li>
</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>
<h3 style={{ margin: "0 0 8px" }}>
Prompt Enhancement Configuration
</h3>
<p style={{ margin: "5px 0px" }}>
Try it yourself by asking Cline to "add a tool that gets the latest npm docs", or
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}>
see a demo of MCP in action here.
</VSCodeLink>
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.
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
<li>Customize how prompts are enhanced for better results in your workflow.</li>
<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>
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
so I recommend trying them out.
<br />
{!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
<p style={{ margin: "5px 0px" }}>
We're very excited to see what you build with this new feature! Join us at
<VSCodeLink href="https://www.reddit.com/r/roocline" style={{ display: "inline" }}>
reddit.com/r/roocline
</VSCodeLink>
for more updates!
to discuss and share feedback.
</p>
</div>
)

View File

@@ -49,7 +49,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState()
const { filePaths, currentApiConfigName, listApiConfigMeta } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false)
@@ -69,8 +69,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
</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)", }}>
<span style={{ display: "flex", alignItems: "center", gap: 12 }}>
{apiConfiguration?.apiProvider === "openrouter" && (
<div style={{ display: "flex", alignItems: "center" }}>
{isEnhancingPrompt && <span style={{ marginRight: 10, color: "var(--vscode-input-foreground)", opacity: 0.5 }}>Enhancing prompt...</span>}
<span
role="button"
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>
)}
<div style={{ display: "flex", alignItems: "center" }}>
{isEnhancingPrompt ? (
<span className="codicon codicon-loading codicon-modifier-spin" style={{
color: "var(--vscode-input-foreground)",
opacity: 0.5,
fontSize: 16.5,
marginRight: 10
}}></span>
) : (
<span
role="button"
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 ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`} onClick={() => !textAreaDisabled && onSend()} style={{ fontSize: 15 }} />
</span>

View File

@@ -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(<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', () => {
(useExtensionState as jest.Mock).mockReturnValue({
filePaths: [],
apiConfiguration: {
apiProvider: 'openrouter',
},
});
render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />);
@@ -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();
});
});

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
} 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<ExtensionStateContextType | undefined>(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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>