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

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