Merge branch 'main' of https://github.com/RooVetGit/Roo-Cline into feature/pupeteer-updates

This commit is contained in:
a8trejo 2024-12-05 09:09:22 -08:00
commit cbdbd9bfe7
28 changed files with 2858 additions and 3951 deletions

View file

@ -1,229 +1,38 @@
# Change Log
# Roo Cline Changelog
## Roo Cline 2.1.2
## [2.1.10]
- Roo Cline now publishes to the VS Code Marketplace!
- Incorporate HeavenOSK's [PR](https://github.com/cline/cline/pull/818) to add sound effects to Cline
## [2.1.9]
- Add instructions for using .clinerules on the settings screen
## [2.1.8]
- Roo Cline now allows configuration of which commands are allowed without approval!
## [2.1.7]
- Updated extension icon and metadata
## [2.1.6]
- Add LM Studio as an API provider option (make sure to start the LM Studio server to use it with the extension!)
- Roo Cline now runs in all VSCode-compatible editors
## [2.1.5]
- Add support for prompt caching for new Claude model IDs on OpenRouter (e.g. `anthropic/claude-3.5-sonnet-20240620`)
- Fix bug in browser action approval
## [2.1.4]
- AWS Bedrock fixes (add missing regions, support for cross-region inference, and older Sonnet model for regions where new model is not available)
- Roo Cline now can run side-by-side with Cline
## [2.1.3]
- Add support for Claude 3.5 Haiku, 66% cheaper than Sonnet with similar intelligence
- Roo Cline now allows browser actions without approval when `alwaysAllowBrowser` is true
## [2.1.2]
- Misc. bug fixes
- Update README with new browser feature
## [2.1.1]
- Add stricter prompt to prevent Cline from editing files during a browser session without first closing the browser
## [2.1.0]
- Cline now uses Anthropic's new "Computer Use" feature to launch a browser, click, type, and scroll. This gives him more autonomy in runtime debugging, end-to-end testing, and even general web use. Try asking "Look up the weather in Colorado" to see it in action! (Available with Claude 3.5 Sonnet v2)
## [2.0.19]
- Fix model info for Claude 3.5 Sonnet v1 on OpenRouter
## [2.0.18]
- Add support for both v1 and v2 of Claude 3.5 Sonnet for GCP Vertex and AWS Bedrock (for cases where the new model is not enabled yet or unavailable in your region)
## [2.0.17]
- Update Anthropic model IDs
## [2.0.16]
- Adjustments to system prompt
## [2.0.15]
- Fix bug where modifying Cline's edits would lead him to try to re-apply the edits
- Fix bug where weaker models would display file contents before using the write_to_file tool
- Fix o1-mini and o1-preview errors when using OpenAI native
## [2.0.14]
- Gracefully cancel requests while stream could be hanging
## [2.0.13]
- Detect code omission and show warning with troubleshooting link
## [2.0.12]
- Keep cursor out of the way during file edit streaming animation
## [2.0.11]
- Adjust prompts around read_file to prevent re-reading files unnecessarily
## [2.0.10]
- More adjustments to system prompt to prevent lazy coding
## [2.0.9]
- Update system prompt to try to prevent Cline from lazy coding (`// rest of code here...`)
## [2.0.8]
- Fix o1-mini and o1-preview for OpenAI
- Fix diff editor not opening sometimes in slow environments like project idx
## [2.0.7]
- Misc. bug fixes
## [2.0.6]
- Update URLs to https://github.com/cline/cline
## [2.0.5]
- Fixed bug where Cline's edits would stream into the active tab when switching tabs during a write_to_file
- Added explanation in task continuation prompt that an interrupted write_to_file reverts the file to its original contents, preventing unnecessary re-reads
- Fixed non-first chunk error handling in case stream fails mid-way through
## [2.0.0]
- New name! Meet Cline, an AI assistant that can use your CLI and Editor
- Responses are now streamed with a yellow text decoration animation to keep track of Cline's progress as he edits files
- New Cancel button to give Cline feedback if he goes off in the wrong direction, giving you more control over tasks
- Re-imagined tool calling prompt resulting in ~40% fewer requests to accomplish tasks + better performance with other models
- Search and use any model with OpenRouter
## [1.9.7]
- Only auto-include error diagnostics after file edits, removed warnings to keep Claude from getting distracted in projects with strict linting rules
## [1.9.6]
- Added support for new Google Gemini models `gemini-1.5-flash-002` and `gemini-1.5-pro-002`
- Updated system prompt to be more lenient when terminal output doesn't stream back properly
- Adjusted system prompt to prevent overuse of the inspect_site tool
- Increased global line height for improved readability
## [1.9.0]
- Claude can now use a browser! This update adds a new `inspect_site` tool that captures screenshots and console logs from websites (including localhost), making it easier for Claude to troubleshoot issues on his own.
- Improved automatic linter/compiler debugging by only sending Claude new errors that result from his edits, rather than reporting all workspace problems.
## [1.8.0]
- You can now use '@' in the textarea to add context!
- @url: Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Claude the latest docs!
- @problems: Add workspace errors and warnings for Claude to fix, no more back-and-forth about debugging
- @file: Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
- @folder: Adds folder's files all at once to speed up your workflow even more
## [1.7.0]
- Adds problems monitoring to keep Claude updated on linter/compiler/build issues, letting him proactively fix errors on his own! (adding missing imports, fixing type errors, etc.)
## [1.6.5]
- Adds support for OpenAI o1, Azure OpenAI, and Google Gemini (free for up to 15 requests per minute!)
- Task header can now be collapsed to provide more space for viewing conversations
- Adds fuzzy search and sorting to Task History, making it easier to find specific tasks
## [1.6.0]
- Commands now run directly in your terminal thanks to VSCode 1.93's new shell integration updates! Plus a new 'Proceed While Running' button to let Claude continue working while commands run, sending him new output along the way (i.e. letting him react to server errors as he edits files)
## [1.5.27]
- Claude's changes now appear in your file's Timeline, allowing you to easily view a diff of each edit. This is especially helpful if you want to revert to a previous version. No need for git—everything is tracked by VSCode's local history!
- Updated system prompt to keep Claude from re-reading files unnecessarily
## [1.5.19]
- Adds support for OpenAI compatible API providers (e.g. Ollama!)
## [1.5.13]
- New terminal emulator! When Claude runs commands, you can now type directly in the terminal (+ support for Python environments)
- Adds search to Task History
## [1.5.6]
- You can now edit Claude's changes before accepting! When he edits or creates a file, you can modify his changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in the center to undo `// rest of code here` shenanigans)
## [1.5.4]
- Adds support for reading .pdf and .docx files (try "turn my business_plan.docx into a company website")
## [1.5.0]
- Adds new `search_files` tool that lets Claude perform regex searches in your project, making it easy for him to refactor code, address TODOs and FIXMEs, remove dead code, and more!
## [1.4.0]
- Adds "Always allow read-only operations" setting to let Claude read files and view directories without needing approval (off by default)
- Implement sliding window context management to keep tasks going past 200k tokens
- Adds Google Cloud Vertex AI support and updates Claude 3.5 Sonnet max output to 8192 tokens for all providers.
- Improves system prompt to gaurd against lazy edits (less "//rest of code here")
## [1.3.0]
- Adds task history
## [1.2.0]
- Adds support for Prompt Caching to significantly reduce costs and response times (currently only available through Anthropic API for Claude 3.5 Sonnet and Claude 3.0 Haiku)
## [1.1.1]
- Adds option to choose other Claude models (+ GPT-4o, DeepSeek, and Mistral if you use OpenRouter)
- Adds option to add custom instructions to the end of the system prompt
## [1.1.0]
- Paste images in chat to use Claude's vision capabilities and turn mockups into fully functional applications or fix bugs with screenshots
## [1.0.9]
- Add support for OpenRouter and AWS Bedrock
## [1.0.8]
- Shows diff view of new or edited files right in the editor
## [1.0.7]
- Replace `list_files` and `analyze_project` with more explicit `list_files_top_level`, `list_files_recursive`, and `view_source_code_definitions_top_level` to get source code definitions only for files relevant to the task
## [1.0.6]
- Interact with CLI commands by sending messages to stdin and terminating long-running processes like servers
- Export tasks to markdown files (useful as context for future tasks)
## [1.0.5]
- Claude now has context about vscode's visible editors and opened tabs
## [1.0.4]
- Open in the editor (using menu bar or `Claude Dev: Open In New Tab` in command palette) to see how Claude updates your workspace more clearly
- New `analyze_project` tool to help Claude get a comprehensive overview of your project's source code definitions and file structure
- Provide feedback to tool use like terminal commands and file edits
- Updated max output tokens to 8192 so less lazy coding (`// rest of code here...`)
- Added ability to retry failed API requests (helpful for rate limits)
- Quality of life improvements like markdown rendering, memory optimizations, better theme support
## [0.0.6]
- Initial release
- Support for auto-approval of write operations and command execution
- Support for .clinerules custom instructions

View file

@ -44,7 +44,7 @@
**Note:** The VSIX file is checked into the git repository's `bin/` directory for easy distribution.
After installation, Roo Cline will appear in your Cursor's installed extensions list. You can verify this by opening Cursor's Extensions panel (Cmd/Ctrl+Shift+X) and checking under the "Installed" section.
After installation, Roo Cline will appear in your VSCode-compatible editor's installed extensions list. You can verify this by opening your editor's Extensions panel (Cmd/Ctrl+Shift+X) and checking under the "Installed" section.
### Publishing
@ -100,7 +100,7 @@ Each browser action provides feedback through screenshots and console logs, allo
<table>
<tbody>
<td align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>Download on VS Marketplace</strong></a>
<a href="https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline" target="_blank"><strong>Download on VS Marketplace</strong></a>
</td>
<td align="center">
<a href="https://discord.gg/cline" target="_blank"><strong>Join the Discord</strong></a>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 B

BIN
assets/icons/rocket.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
audio/celebration.wav Normal file

Binary file not shown.

BIN
audio/notification.wav Normal file

Binary file not shown.

BIN
audio/progress_loop.wav Normal file

Binary file not shown.

View file

@ -32,8 +32,8 @@ export class OpenRouterHandler {
headers: {
"Authorization": `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/mattvr/roo-cline",
"X-Title": "Cline CLI"
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
"X-Title": "Roo Cline"
},
body: JSON.stringify({
model: this.model,

24
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "roo-cline",
"version": "2.1.2",
"version": "2.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
"version": "2.1.2",
"version": "2.1.10",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0",
@ -32,6 +32,7 @@
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"play-sound": "^1.1.6",
"puppeteer": "^23.9.0",
"serialize-error": "^11.0.3",
"strip-ansi": "^7.1.0",
@ -58,7 +59,7 @@
"typescript": "^5.4.5"
},
"engines": {
"vscode": "1.93.1"
"vscode": "^1.93.1"
}
},
"node_modules/@ampproject/remapping": {
@ -9410,6 +9411,14 @@
"node": ">=8"
}
},
"node_modules/find-exec": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/find-exec/-/find-exec-1.0.3.tgz",
"integrity": "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug==",
"dependencies": {
"shell-quote": "^1.8.1"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -13510,6 +13519,14 @@
"node": ">=8"
}
},
"node_modules/play-sound": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/play-sound/-/play-sound-1.1.6.tgz",
"integrity": "sha512-09eO4QiXNFXJffJaOW5P6x6F5RLihpLUkXttvUZeWml0fU6x6Zp7AjG9zaeMpgH2ZNvq4GR1ytB22ddYcqJIZA==",
"dependencies": {
"find-exec": "1.0.3"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -14456,7 +14473,6 @@
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"

View file

@ -1,9 +1,9 @@
{
"name": "roo-cline",
"displayName": "Roo Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
"publisher": "RooVeterinaryInc",
"version": "2.1.2",
"version": "2.1.10",
"files": [
"assets/icons/icon_Roo.png"
],
@ -13,7 +13,7 @@
"theme": "dark"
},
"engines": {
"vscode": "1.93.1"
"vscode": "^1.93.1"
},
"author": {
"name": "Roo Vet"
@ -22,7 +22,7 @@
"type": "git",
"url": "https://github.com/RooVetGit/Roo-Cline"
},
"homepage": "https://cline.bot",
"homepage": "https://github.com/RooVetGit/Roo-Cline",
"categories": [
"AI",
"Chat",
@ -50,78 +50,89 @@
"viewsContainers": {
"activitybar": [
{
"id": "claude-dev-ActivityBar",
"title": "Cline",
"icon": "$(robot)"
"id": "roo-cline-ActivityBar",
"title": "Roo Cline",
"icon": "$(rocket)"
}
]
},
"views": {
"claude-dev-ActivityBar": [
"roo-cline-ActivityBar": [
{
"type": "webview",
"id": "claude-dev.SidebarProvider",
"id": "roo-cline.SidebarProvider",
"name": ""
}
]
},
"commands": [
{
"command": "cline.plusButtonClicked",
"command": "roo-cline.plusButtonClicked",
"title": "New Task",
"icon": "$(add)"
},
{
"command": "cline.historyButtonClicked",
"command": "roo-cline.historyButtonClicked",
"title": "History",
"icon": "$(history)"
},
{
"command": "cline.popoutButtonClicked",
"command": "roo-cline.popoutButtonClicked",
"title": "Open in Editor",
"icon": "$(link-external)"
},
{
"command": "cline.settingsButtonClicked",
"command": "roo-cline.settingsButtonClicked",
"title": "Settings",
"icon": "$(settings-gear)"
},
{
"command": "cline.openInNewTab",
"command": "roo-cline.openInNewTab",
"title": "Open In New Tab",
"category": "Cline"
"category": "Roo Cline"
}
],
"menus": {
"view/title": [
{
"command": "cline.plusButtonClicked",
"command": "roo-cline.plusButtonClicked",
"group": "navigation@1",
"when": "view == claude-dev.SidebarProvider"
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "cline.historyButtonClicked",
"command": "roo-cline.historyButtonClicked",
"group": "navigation@2",
"when": "view == claude-dev.SidebarProvider"
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "cline.popoutButtonClicked",
"command": "roo-cline.popoutButtonClicked",
"group": "navigation@3",
"when": "view == claude-dev.SidebarProvider"
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "cline.settingsButtonClicked",
"command": "roo-cline.settingsButtonClicked",
"group": "navigation@4",
"when": "view == claude-dev.SidebarProvider"
"when": "view == roo-cline.SidebarProvider"
}
]
},
"configuration": {
"title": "RooCline",
"properties": {
"cline.alwaysAllowBrowser": {
"type": "boolean",
"default": false,
"description": "Always allow browser actions without requiring confirmation"
"roo-cline.allowedCommands": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"npm test",
"npm install",
"tsc",
"git log",
"git diff",
"git show"
],
"description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled"
}
}
}
@ -193,6 +204,7 @@
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"puppeteer": "^23.9.0",
"play-sound": "^1.1.6",
"serialize-error": "^11.0.3",
"strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11",

View file

@ -56,16 +56,6 @@ type UserContent = Array<
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
>
const ALLOWED_AUTO_EXECUTE_COMMANDS = [
'npm',
'npx',
'tsc',
'git log',
'git diff',
'git show',
'list'
] as const
export class Cline {
readonly taskId: string
api: ApiHandler
@ -76,10 +66,6 @@ export class Cline {
private isInteractiveMode: boolean = false
private browserPort: string = '7333'
customInstructions?: string
alwaysAllowReadOnly: boolean
alwaysAllowWrite: boolean
alwaysAllowExecute: boolean
alwaysAllowBrowser: boolean
apiConversationHistory: Anthropic.MessageParam[] = []
clineMessages: ClineMessage[] = []
@ -109,10 +95,6 @@ export class Cline {
provider: ClineProvider,
apiConfiguration: ApiConfiguration,
customInstructions?: string,
alwaysAllowReadOnly?: boolean,
alwaysAllowWrite?: boolean,
alwaysAllowExecute?: boolean,
alwaysAllowBrowser?: boolean,
task?: string,
images?: string[],
historyItem?: HistoryItem,
@ -124,10 +106,6 @@ export class Cline {
this.browserSession = new BrowserSession(provider.context)
this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
this.alwaysAllowWrite = alwaysAllowWrite ?? false
this.alwaysAllowExecute = alwaysAllowExecute ?? false
this.alwaysAllowBrowser = alwaysAllowBrowser ?? false
if (historyItem) {
this.taskId = historyItem.id
@ -140,25 +118,6 @@ export class Cline {
}
}
private isAllowedCommand(command?: string): boolean {
if (!command) {
return false;
}
// Check for command chaining characters
if (command.includes('&&') ||
command.includes(';') ||
command.includes('||') ||
command.includes('|') ||
command.includes('$(') ||
command.includes('`')) {
return false;
}
const trimmedCommand = command.trim().toLowerCase();
return ALLOWED_AUTO_EXECUTE_COMMANDS.some(prefix =>
trimmedCommand.startsWith(prefix.toLowerCase())
);
}
// Storing task to disk for history
private async ensureTaskDirectoryExists(): Promise<string> {
@ -1142,11 +1101,7 @@ export class Cline {
if (block.partial) {
// update gui message
const partialMessage = JSON.stringify(sharedMessageProps)
if (this.alwaysAllowWrite) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
await this.ask("tool", partialMessage, block.partial).catch(() => {})
// update editor
if (!this.diffViewProvider.isEditing) {
// open the editor and prepare to stream content in
@ -1176,11 +1131,7 @@ export class Cline {
if (!this.diffViewProvider.isEditing) {
// show gui message before showing edit animation
const partialMessage = JSON.stringify(sharedMessageProps)
if (this.alwaysAllowWrite) {
await this.say("tool", partialMessage, undefined, true)
} else {
await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
}
await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
await this.diffViewProvider.open(relPath)
}
await this.diffViewProvider.update(newContent, true)
@ -1199,7 +1150,7 @@ export class Cline {
)
: undefined,
} satisfies ClineSayTool)
const didApprove = this.alwaysAllowWrite || (await askApproval("tool", completeMessage))
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.diffViewProvider.revertChanges()
break
@ -1252,11 +1203,7 @@ export class Cline {
...sharedMessageProps,
content: undefined,
} satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
await this.ask("tool", partialMessage, block.partial).catch(() => {})
break
} else {
if (!relPath) {
@ -1270,13 +1217,9 @@ export class Cline {
...sharedMessageProps,
content: absolutePath,
} satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", completeMessage, undefined, false) // need to be sending partialValue bool, since undefined has its own purpose in that the message is treated neither as a partial or completion of a partial, but as a single complete message
} else {
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
// now execute the tool like normal
const content = await extractTextFromFile(absolutePath)
@ -1302,11 +1245,7 @@ export class Cline {
...sharedMessageProps,
content: "",
} satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
await this.ask("tool", partialMessage, block.partial).catch(() => {})
break
} else {
if (!relDirPath) {
@ -1322,13 +1261,9 @@ export class Cline {
...sharedMessageProps,
content: result,
} satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", completeMessage, undefined, false)
} else {
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
pushToolResult(result)
break
@ -1350,11 +1285,7 @@ export class Cline {
...sharedMessageProps,
content: "",
} satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
await this.ask("tool", partialMessage, block.partial).catch(() => {})
break
} else {
if (!relDirPath) {
@ -1371,13 +1302,9 @@ export class Cline {
...sharedMessageProps,
content: result,
} satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", completeMessage, undefined, false)
} else {
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
pushToolResult(result)
break
@ -1403,11 +1330,7 @@ export class Cline {
...sharedMessageProps,
content: "",
} satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
await this.ask("tool", partialMessage, block.partial).catch(() => {})
break
} else {
if (!relDirPath) {
@ -1427,13 +1350,9 @@ export class Cline {
...sharedMessageProps,
content: results,
} satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", completeMessage, undefined, false)
} else {
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
pushToolResult(results)
break
@ -1462,24 +1381,11 @@ export class Cline {
try {
if (block.partial) {
if (action === "launch") {
if (this.alwaysAllowBrowser) {
await this.say(
"browser_action",
JSON.stringify({
action: action as BrowserAction,
coordinate: undefined,
text: undefined
} satisfies ClineSayBrowserAction),
undefined,
block.partial
)
} else {
await this.ask(
"browser_action_launch",
removeClosingTag("url", url),
block.partial
).catch(() => {})
}
await this.ask(
"browser_action_launch",
removeClosingTag("url", url),
block.partial
).catch(() => {})
} else {
await this.say(
"browser_action",
@ -1504,7 +1410,7 @@ export class Cline {
}
this.consecutiveMistakeCount = 0
const didApprove = this.alwaysAllowBrowser || await askApproval("browser_action_launch", url)
const didApprove = await askApproval("browser_action_launch", url)
if (!didApprove) {
break
}
@ -1603,13 +1509,9 @@ export class Cline {
const command: string | undefined = block.params.command
try {
if (block.partial) {
if (this.alwaysAllowExecute && this.isAllowedCommand(command)) {
await this.say("command", command, undefined, block.partial)
} else {
await this.ask("command", removeClosingTag("command", command), block.partial).catch(
() => {}
)
}
await this.ask("command", removeClosingTag("command", command), block.partial).catch(
() => {}
)
break
} else {
if (!command) {
@ -1621,8 +1523,7 @@ export class Cline {
}
this.consecutiveMistakeCount = 0
const didApprove = (this.alwaysAllowExecute && this.isAllowedCommand(command)) ||
(await askApproval("command", command))
const didApprove = await askApproval("command", command)
if (!didApprove) {
break
}

View file

@ -231,40 +231,14 @@ describe('Cline', () => {
});
describe('constructor', () => {
it('should initialize with default settings', () => {
const cline = new Cline(
mockProvider,
mockApiConfig,
undefined, // customInstructions
undefined, // alwaysAllowReadOnly
undefined, // alwaysAllowWrite
undefined, // alwaysAllowExecute
undefined, // alwaysAllowBrowser
'test task'
);
expect(cline.alwaysAllowReadOnly).toBe(false);
expect(cline.alwaysAllowWrite).toBe(false);
expect(cline.alwaysAllowExecute).toBe(false);
expect(cline.alwaysAllowBrowser).toBe(false);
});
it('should respect provided settings', () => {
const cline = new Cline(
mockProvider,
mockApiConfig,
'custom instructions',
true, // alwaysAllowReadOnly
true, // alwaysAllowWrite
true, // alwaysAllowExecute
true, // alwaysAllowBrowser
'test task'
);
expect(cline.alwaysAllowReadOnly).toBe(true);
expect(cline.alwaysAllowWrite).toBe(true);
expect(cline.alwaysAllowExecute).toBe(true);
expect(cline.alwaysAllowBrowser).toBe(true);
expect(cline.customInstructions).toBe('custom instructions');
});
@ -277,142 +251,4 @@ describe('Cline', () => {
}).toThrow('Either historyItem or task/images must be provided');
});
});
describe('file operations', () => {
let cline: Cline;
beforeEach(() => {
cline = new Cline(
mockProvider,
mockApiConfig,
undefined,
false,
false,
false,
false,
'test task'
);
});
it('should bypass approval when alwaysAllowWrite is true', async () => {
const writeEnabledCline = new Cline(
mockProvider,
mockApiConfig,
undefined,
false,
true, // alwaysAllowWrite
false,
false,
'test task'
);
expect(writeEnabledCline.alwaysAllowWrite).toBe(true);
// The write operation would bypass approval in actual implementation
});
it('should require approval when alwaysAllowWrite is false', async () => {
const writeDisabledCline = new Cline(
mockProvider,
mockApiConfig,
undefined,
false,
false, // alwaysAllowWrite
false,
false,
'test task'
);
expect(writeDisabledCline.alwaysAllowWrite).toBe(false);
// The write operation would require approval in actual implementation
});
});
describe('isAllowedCommand', () => {
let cline: any
beforeEach(() => {
// Create a more complete mock provider
const mockProvider = {
context: {
globalStorageUri: { fsPath: '/mock/path' }
},
postStateToWebview: jest.fn(),
postMessageToWebview: jest.fn(),
updateTaskHistory: jest.fn()
}
// Mock the required dependencies
const mockApiConfig = {
getModel: () => ({
id: 'claude-3-sonnet',
info: { supportsComputerUse: true }
})
}
// Create test instance with mocked constructor params
cline = new Cline(
mockProvider as any,
mockApiConfig as any,
undefined, // customInstructions
false, // alwaysAllowReadOnly
false, // alwaysAllowWrite
false, // alwaysAllowExecute
false, // alwaysAllowBrowser
'test task' // task
)
// Mock internal methods that are called during initialization
cline.initiateTaskLoop = jest.fn()
cline.say = jest.fn()
cline.addToClineMessages = jest.fn()
cline.overwriteClineMessages = jest.fn()
cline.addToApiConversationHistory = jest.fn()
cline.overwriteApiConversationHistory = jest.fn()
})
test('returns true for allowed commands', () => {
expect(cline.isAllowedCommand('npm install')).toBe(true)
expect(cline.isAllowedCommand('npx create-react-app')).toBe(true)
expect(cline.isAllowedCommand('tsc --watch')).toBe(true)
expect(cline.isAllowedCommand('git log --oneline')).toBe(true)
expect(cline.isAllowedCommand('git diff main')).toBe(true)
})
test('returns true regardless of case or whitespace', () => {
expect(cline.isAllowedCommand('NPM install')).toBe(true)
expect(cline.isAllowedCommand(' npm install')).toBe(true)
expect(cline.isAllowedCommand('GIT DIFF')).toBe(true)
})
test('returns false for non-allowed commands', () => {
expect(cline.isAllowedCommand('rm -rf /')).toBe(false)
expect(cline.isAllowedCommand('git push')).toBe(false)
expect(cline.isAllowedCommand('git commit')).toBe(false)
expect(cline.isAllowedCommand('curl http://example.com')).toBe(false)
})
test('returns false for undefined or empty commands', () => {
expect(cline.isAllowedCommand()).toBe(false)
expect(cline.isAllowedCommand('')).toBe(false)
expect(cline.isAllowedCommand(' ')).toBe(false)
})
test('returns false for commands with chaining operators', () => {
const maliciousCommands = [
'npm install && rm -rf /',
'git status; dangerous-command',
'git log || evil-script',
'git status | malicious-pipe',
'git log $(evil-command)',
'git status `rm -rf /`',
'npm install && echo "malicious"',
'git status; curl http://evil.com',
'tsc --watch || wget malware',
];
maliciousCommands.forEach(cmd => {
expect(cline.isAllowedCommand(cmd)).toBe(false);
});
});
})
});

View file

@ -20,6 +20,7 @@ import { Cline } from "../Cline"
import { openMention } from "../mentions"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { playSound, setSoundEnabled } from "../../utils/sound"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@ -60,6 +61,8 @@ type GlobalStateKey =
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
| "allowedCommands"
| "soundEnabled"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
@ -68,8 +71,8 @@ export const GlobalFileNames = {
}
export class ClineProvider implements vscode.WebviewViewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
public static readonly sideBarId = "roo-cline.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "roo-cline.TabPanelProvider"
private static activeInstances: Set<ClineProvider> = new Set()
private disposables: vscode.Disposable[] = []
private view?: vscode.WebviewView | vscode.WebviewPanel
@ -197,20 +200,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const {
apiConfiguration,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser
} = await this.getState()
this.cline = new Cline(
this,
apiConfiguration,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
task,
images
)
@ -221,20 +216,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const {
apiConfiguration,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser
} = await this.getState()
this.cline = new Cline(
this,
apiConfiguration,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
undefined,
undefined,
historyItem,
@ -440,23 +427,18 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break
case "alwaysAllowReadOnly":
await this.updateGlobalState("alwaysAllowReadOnly", message.bool ?? undefined)
if (this.cline) {
this.cline.alwaysAllowReadOnly = message.bool ?? false
}
await this.postStateToWebview()
break
case "alwaysAllowWrite":
await this.updateGlobalState("alwaysAllowWrite", message.bool ?? undefined)
if (this.cline) {
this.cline.alwaysAllowWrite = message.bool ?? false
}
await this.postStateToWebview()
break
case "alwaysAllowExecute":
await this.updateGlobalState("alwaysAllowExecute", message.bool ?? undefined)
if (this.cline) {
this.cline.alwaysAllowExecute = message.bool ?? false
}
await this.postStateToWebview()
break
case "alwaysAllowBrowser":
await this.updateGlobalState("alwaysAllowBrowser", message.bool ?? undefined)
await this.postStateToWebview()
break
case "askResponse":
@ -531,15 +513,27 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
case "alwaysAllowBrowser":
await this.updateGlobalState("alwaysAllowBrowser", message.bool ?? undefined)
if (this.cline) {
this.cline.alwaysAllowBrowser = message.bool ?? false
}
await this.postStateToWebview()
break
case "allowedCommands":
await this.context.globalState.update('allowedCommands', message.commands);
// Also update workspace settings
await vscode.workspace
.getConfiguration('roo-cline')
.update('allowedCommands', message.commands, vscode.ConfigurationTarget.Global);
break;
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
case "playSound":
if (message.audioType) {
const soundPath = path.join(this.context.extensionPath, "audio", `${message.audioType}.wav`)
playSound(soundPath)
}
break
case "soundEnabled":
const enabled = message.bool ?? true
await this.updateGlobalState("soundEnabled", enabled)
setSoundEnabled(enabled)
await this.postStateToWebview()
break
}
},
null,
@ -840,14 +834,19 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const {
apiConfiguration,
lastShownAnnouncementId,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
taskHistory
alwaysAllowBrowser,
soundEnabled,
taskHistory,
} = await this.getState()
const allowedCommands = vscode.workspace
.getConfiguration('roo-cline')
.get<string[]>('allowedCommands') || []
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
@ -861,7 +860,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
taskHistory: (taskHistory || [])
.filter((item) => item.ts && item.task)
.sort((a, b) => b.ts - a.ts),
soundEnabled: soundEnabled ?? true,
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
allowedCommands,
}
}
@ -947,8 +948,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
taskHistory,
alwaysAllowBrowser,
taskHistory,
allowedCommands,
soundEnabled,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@ -979,8 +982,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
])
let apiProvider: ApiProvider
@ -1031,6 +1036,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowExecute: alwaysAllowExecute ?? false,
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
taskHistory,
allowedCommands,
soundEnabled,
}
}

View file

@ -26,6 +26,16 @@ export function activate(context: vscode.ExtensionContext) {
outputChannel.appendLine("Cline extension activated")
// Get default commands from configuration
const defaultCommands = vscode.workspace
.getConfiguration('roo-cline')
.get<string[]>('allowedCommands') || [];
// Initialize global state if not already set
if (!context.globalState.get('allowedCommands')) {
context.globalState.update('allowedCommands', defaultCommands);
}
const sidebarProvider = new ClineProvider(context, outputChannel)
context.subscriptions.push(
@ -35,7 +45,7 @@ export function activate(context: vscode.ExtensionContext) {
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.plusButtonClicked", async () => {
vscode.commands.registerCommand("roo-cline.plusButtonClicked", async () => {
outputChannel.appendLine("Plus button Clicked")
await sidebarProvider.clearTask()
await sidebarProvider.postStateToWebview()
@ -66,8 +76,8 @@ export function activate(context: vscode.ExtensionContext) {
// TODO: use better svg icon with light and dark variants (see https://stackoverflow.com/questions/58365687/vscode-extension-iconpath)
panel.iconPath = {
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "robot_panel_light.png"),
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "robot_panel_dark.png"),
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "rocket.png"),
}
tabProvider.resolveWebviewView(panel)
@ -76,18 +86,18 @@ export function activate(context: vscode.ExtensionContext) {
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
}
context.subscriptions.push(vscode.commands.registerCommand("cline.popoutButtonClicked", openClineInNewTab))
context.subscriptions.push(vscode.commands.registerCommand("cline.openInNewTab", openClineInNewTab))
context.subscriptions.push(vscode.commands.registerCommand("roo-cline.popoutButtonClicked", openClineInNewTab))
context.subscriptions.push(vscode.commands.registerCommand("roo-cline.openInNewTab", openClineInNewTab))
context.subscriptions.push(
vscode.commands.registerCommand("cline.settingsButtonClicked", () => {
vscode.commands.registerCommand("roo-cline.settingsButtonClicked", () => {
//vscode.window.showInformationMessage(message)
sidebarProvider.postMessageToWebview({ type: "action", action: "settingsButtonClicked" })
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.historyButtonClicked", () => {
vscode.commands.registerCommand("roo-cline.historyButtonClicked", () => {
sidebarProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" })
}),
)

View file

@ -30,6 +30,9 @@ export interface ExtensionMessage {
export interface ExtensionState {
version: string
clineMessages: ClineMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
apiConfiguration?: ApiConfiguration
customInstructions?: string
alwaysAllowReadOnly?: boolean
@ -37,9 +40,8 @@ export interface ExtensionState {
alwaysAllowExecute?: boolean
alwaysAllowBrowser?: boolean
uriScheme?: string
clineMessages: ClineMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
allowedCommands?: string[]
soundEnabled?: boolean
}
export interface ClineMessage {

View file

@ -1,9 +1,12 @@
import { ApiConfiguration, ApiProvider } from "./api"
export type AudioType = "notification" | "celebration" | "progress_loop"
export interface WebviewMessage {
type:
| "apiConfiguration"
| "customInstructions"
| "allowedCommands"
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowExecute"
@ -26,11 +29,15 @@ export interface WebviewMessage {
| "cancelTask"
| "refreshOpenRouterModels"
| "alwaysAllowBrowser"
| "playSound"
| "soundEnabled"
text?: string
askResponse?: ClineAskResponse
apiConfiguration?: ApiConfiguration
images?: string[]
bool?: boolean
commands?: string[]
audioType?: AudioType
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"

68
src/utils/sound.ts Normal file
View file

@ -0,0 +1,68 @@
import * as vscode from "vscode"
import * as path from "path"
/**
* Minimum interval (in milliseconds) to prevent continuous playback
*/
const MIN_PLAY_INTERVAL = 500
/**
* Timestamp of when sound was last played
*/
let lastPlayedTime = 0
/**
* Determine if a file is a WAV file
* @param filepath string
* @returns boolean
*/
export const isWAV = (filepath: string): boolean => {
return path.extname(filepath).toLowerCase() === ".wav"
}
let isSoundEnabled = true
/**
* Set sound configuration
* @param enabled boolean
*/
export const setSoundEnabled = (enabled: boolean): void => {
isSoundEnabled = enabled
}
/**
* Play a sound file
* @param filepath string
* @return void
*/
export const playSound = (filepath: string): void => {
try {
if (!isSoundEnabled) {
return
}
if (!filepath) {
return
}
if (!isWAV(filepath)) {
throw new Error("Only wav files are supported.")
}
const currentTime = Date.now()
if (currentTime - lastPlayedTime < MIN_PLAY_INTERVAL) {
return // Skip playback within minimum interval to prevent continuous playback
}
const player = require("play-sound")()
player.play(filepath, function (err: any) {
if (err) {
throw new Error("Failed to play sound effect")
}
})
lastPlayedTime = currentTime
} catch (error: any) {
vscode.window.showErrorMessage(error.message)
}
}

File diff suppressed because it is too large Load diff

View file

@ -52,6 +52,15 @@
]
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/vscode-webview": "^1.57.5"
},
"jest": {
"transformIgnorePatterns": [
"/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text)/)"
],
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
}
}

View file

@ -24,6 +24,7 @@ import BrowserSessionRow from "./BrowserSessionRow"
import ChatRow from "./ChatRow"
import ChatTextArea from "./ChatTextArea"
import TaskHeader from "./TaskHeader"
import { AudioType } from "../../../../src/shared/WebviewMessage"
interface ChatViewProps {
isHidden: boolean
@ -35,7 +36,7 @@ interface ChatViewProps {
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration } = useExtensionState()
const { version, clineMessages: messages, taskHistory, apiConfiguration, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, allowedCommands } = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
@ -61,10 +62,24 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
const [hasStarted, setHasStarted] = useState(false)
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
const lastMessage = useMemo(() => messages.at(-1), [messages])
const secondLastMessage = useMemo(() => messages.at(-2), [messages])
function playSound(audioType: AudioType) {
vscode.postMessage({ type: "playSound", audioType })
}
function playSoundOnMessage(audioType: AudioType) {
if (hasStarted && !isStreaming) {
playSound(audioType)
}
}
useDeepCompareEffect(() => {
// if last message is an ask, show user ask UI
// if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost.
@ -75,6 +90,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const isPartial = lastMessage.partial === true
switch (lastMessage.ask) {
case "api_req_failed":
playSoundOnMessage("progress_loop")
setTextAreaDisabled(true)
setClineAsk("api_req_failed")
setEnableButtons(true)
@ -82,6 +98,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Start New Task")
break
case "mistake_limit_reached":
playSoundOnMessage("progress_loop")
setTextAreaDisabled(false)
setClineAsk("mistake_limit_reached")
setEnableButtons(true)
@ -89,6 +106,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Start New Task")
break
case "followup":
playSoundOnMessage("notification")
setTextAreaDisabled(isPartial)
setClineAsk("followup")
setEnableButtons(isPartial)
@ -96,6 +114,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// setSecondaryButtonText(undefined)
break
case "tool":
playSoundOnMessage("notification")
setTextAreaDisabled(isPartial)
setClineAsk("tool")
setEnableButtons(!isPartial)
@ -113,6 +132,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
break
case "browser_action_launch":
playSoundOnMessage("notification")
setTextAreaDisabled(isPartial)
setClineAsk("browser_action_launch")
setEnableButtons(!isPartial)
@ -120,6 +140,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Reject")
break
case "command":
playSoundOnMessage("notification")
setTextAreaDisabled(isPartial)
setClineAsk("command")
setEnableButtons(!isPartial)
@ -127,6 +148,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Reject")
break
case "command_output":
playSoundOnMessage("notification")
setTextAreaDisabled(false)
setClineAsk("command_output")
setEnableButtons(true)
@ -135,6 +157,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break
case "completion_result":
// extension waiting for feedback. but we can just present a new task button
playSoundOnMessage("celebration")
setTextAreaDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
@ -142,6 +165,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText(undefined)
break
case "resume_task":
playSoundOnMessage("notification")
setTextAreaDisabled(false)
setClineAsk("resume_task")
setEnableButtons(true)
@ -150,6 +174,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setDidClickCancel(false) // special case where we reset the cancel button state
break
case "resume_completed_task":
playSoundOnMessage("celebration")
setTextAreaDisabled(false)
setClineAsk("resume_completed_task")
setEnableButtons(true)
@ -441,6 +466,36 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
return true
})
}, [modifiedMessages])
useEffect(() => {
if (isStreaming) {
// Set to true once any request has started
setHasStarted(true)
}
// Only execute when isStreaming changes from true to false
if (wasStreaming && !isStreaming && lastMessage) {
// Play appropriate sound based on lastMessage content
if (lastMessage.type === "ask") {
switch (lastMessage.ask) {
case "api_req_failed":
case "mistake_limit_reached":
playSound("progress_loop")
break
case "tool":
case "followup":
case "browser_action_launch":
case "resume_task":
playSound("notification")
break
case "completion_result":
case "resume_completed_task":
playSound("celebration")
break
}
}
}
// Update previous value
setWasStreaming(isStreaming)
}, [isStreaming, lastMessage, wasStreaming])
const isBrowserSessionMessage = (message: ClineMessage): boolean => {
// which of visible messages are browser session messages, see above
@ -675,6 +730,55 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
[expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange],
)
useEffect(() => {
// Only proceed if we have an ask and buttons are enabled
if (!clineAsk || !enableButtons) return
const isReadOnlyToolAction = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const tool = JSON.parse(lastMessage.text)
return ["readFile", "listFiles", "listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool)
}
return false
}
const isWriteToolAction = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const tool = JSON.parse(lastMessage.text)
return ["editedExistingFile", "newFileCreated"].includes(tool.tool)
}
return false
}
const isAllowedCommand = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const command = lastMessage.text
// Split command by chaining operators
const commands = command.split(/&&|\|\||;|\||\$\(|`/).map(cmd => cmd.trim())
// Check if all individual commands are allowed
return commands.every((cmd) => {
const trimmedCommand = cmd.toLowerCase()
return allowedCommands?.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase()))
})
}
return false
}
if (
(alwaysAllowBrowser && clineAsk === "browser_action_launch") ||
(alwaysAllowReadOnly && clineAsk === "tool" && isReadOnlyToolAction()) ||
(alwaysAllowWrite && clineAsk === "tool" && isWriteToolAction()) ||
(alwaysAllowExecute && clineAsk === "command" && isAllowedCommand())
) {
handlePrimaryButtonClick()
}
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, messages, allowedCommands])
return (
<div
style={{

View file

@ -1,171 +1,549 @@
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ExtensionStateContextType } from '../../../context/ExtensionStateContext'
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import ChatView from '../ChatView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import * as ExtensionStateContext from '../../../context/ExtensionStateContext'
// Mock vscode
// Define minimal types needed for testing
interface ClineMessage {
type: 'say' | 'ask';
say?: string;
ask?: string;
ts: number;
text?: string;
partial?: boolean;
}
interface ExtensionState {
version: string;
clineMessages: ClineMessage[];
taskHistory: any[];
shouldShowAnnouncement: boolean;
allowedCommands: string[];
alwaysAllowExecute: boolean;
[key: string]: any;
}
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn()
}
}))
// Mock all components that use problematic dependencies
jest.mock('../../common/CodeBlock', () => ({
__esModule: true,
default: () => <div data-testid="mock-code-block" />
}))
jest.mock('../../common/MarkdownBlock', () => ({
__esModule: true,
default: () => <div data-testid="mock-markdown-block" />
vscode: {
postMessage: jest.fn(),
},
}))
// Mock components that use ESM dependencies
jest.mock('../BrowserSessionRow', () => ({
__esModule: true,
default: () => <div data-testid="mock-browser-session-row" />
__esModule: true,
default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
}
}))
// Update ChatRow mock to capture props
let chatRowProps = null
jest.mock('../ChatRow', () => ({
__esModule: true,
default: function MockChatRow({ message }: { message: ClineMessage }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
}
}))
interface ChatTextAreaProps {
onSend: (value: string) => void;
inputValue?: string;
textAreaDisabled?: boolean;
placeholderText?: string;
selectedImages?: string[];
shouldDisableImages?: boolean;
}
jest.mock('../ChatTextArea', () => {
const mockReact = require('react')
return {
__esModule: true,
default: (props: any) => {
chatRowProps = props
return <div data-testid="mock-chat-row" />
}
default: mockReact.forwardRef(function MockChatTextArea(props: ChatTextAreaProps, ref: React.ForwardedRef<HTMLInputElement>) {
return (
<div data-testid="chat-textarea">
<input ref={ref} type="text" onChange={(e) => props.onSend(e.target.value)} />
</div>
)
})
}
})
jest.mock('../TaskHeader', () => ({
__esModule: true,
default: function MockTaskHeader({ task }: { task: ClineMessage }) {
return <div data-testid="task-header">{JSON.stringify(task)}</div>
}
}))
// Mock Virtuoso component
jest.mock('react-virtuoso', () => ({
Virtuoso: ({ children }: any) => (
<div data-testid="mock-virtuoso">{children}</div>
)
}))
// Mock VS Code components
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick }: any) => (
<button onClick={onClick}>{children}</button>
),
VSCodeProgressRing: () => <div data-testid="progress-ring" />
VSCodeButton: function MockVSCodeButton({
children,
onClick,
appearance
}: {
children: React.ReactNode;
onClick?: () => void;
appearance?: string;
}) {
return <button onClick={onClick} data-appearance={appearance}>{children}</button>
},
VSCodeTextField: function MockVSCodeTextField({
value,
onInput,
placeholder
}: {
value?: string;
onInput?: (e: { target: { value: string } }) => void;
placeholder?: string;
}) {
return (
<input
type="text"
value={value}
onChange={(e) => onInput?.({ target: { value: e.target.value } })}
placeholder={placeholder}
/>
)
},
VSCodeLink: function MockVSCodeLink({
children,
href
}: {
children: React.ReactNode;
href?: string;
}) {
return <a href={href}>{children}</a>
}
}))
describe('ChatView', () => {
const mockShowHistoryView = jest.fn()
const mockHideAnnouncement = jest.fn()
let mockState: ExtensionStateContextType
beforeEach(() => {
jest.clearAllMocks()
mockState = {
clineMessages: [],
apiConfiguration: {
apiProvider: 'anthropic',
apiModelId: 'claude-3-sonnet'
},
version: '1.0.0',
customInstructions: '',
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
openRouterModels: {},
didHydrateState: true,
showWelcome: false,
theme: 'dark',
filePaths: [],
taskHistory: [],
shouldShowAnnouncement: false,
uriScheme: 'vscode',
setAlwaysAllowReadOnly: jest.fn(),
setAlwaysAllowWrite: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowExecute: jest.fn(),
setApiConfiguration: jest.fn(),
setShowAnnouncement: jest.fn()
}
// Mock the useExtensionState hook
jest.spyOn(ExtensionStateContext, 'useExtensionState').mockReturnValue(mockState)
})
const renderChatView = () => {
return render(
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={mockHideAnnouncement}
showHistoryView={mockShowHistoryView}
/>
)
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: Partial<ExtensionState>) => {
window.postMessage({
type: 'state',
state: {
version: '1.0.0',
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
...state
}
}, '*')
}
describe('Streaming State', () => {
it('should show cancel button while streaming and trigger cancel on click', async () => {
mockState.clineMessages = [
{
type: 'say',
say: 'task',
ts: Date.now(),
},
{
type: 'say',
say: 'text',
partial: true,
ts: Date.now(),
}
]
renderChatView()
const cancelButton = screen.getByText('Cancel')
await userEvent.click(cancelButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'cancelTask'
})
})
describe('ChatView - Auto Approval Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should show terminate button when task is paused and trigger terminate on click', async () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'resume_task',
ts: Date.now(),
}
]
renderChatView()
const terminateButton = screen.getByText('Terminate')
await userEvent.click(terminateButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'clearTask'
})
})
it('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
it('should show retry button when API error occurs and trigger retry on click', async () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'api_req_failed',
ts: Date.now(),
}
]
renderChatView()
const retryButton = screen.getByText('Retry')
await userEvent.click(retryButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
// First hydrate state with initial task
mockPostMessage({
alwaysAllowBrowser: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
})
// Then send the browser action ask message
mockPostMessage({
alwaysAllowBrowser: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves read-only tools when alwaysAllowReadOnly is enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the read-only tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves write tools when alwaysAllowWrite is enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the write tool ask message
mockPostMessage({
alwaysAllowWrite: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves allowed commands when alwaysAllowExecute is enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the command ask message
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'command',
ts: Date.now(),
text: 'npm test',
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('does not auto-approve disallowed commands even when alwaysAllowExecute is enabled', () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the disallowed command ask message
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'command',
ts: Date.now(),
text: 'rm -rf /',
partial: false
}
]
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
describe('Command Chaining Tests', () => {
it('auto-approves chained commands when all parts are allowed', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// Test various allowed command chaining scenarios
const allowedChainedCommands = [
'npm test && npm run build',
'npm test; npm run build',
'npm test || npm run build',
'npm test | npm run build'
]
for (const command of allowedChainedCommands) {
jest.clearAllMocks()
// First hydrate state with initial task
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'npm run build'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the chained command ask message
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'npm run build'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'command',
ts: Date.now(),
text: command,
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
}
})
it('does not auto-approve chained commands when any part is disallowed', () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// Test various command chaining scenarios with disallowed parts
const disallowedChainedCommands = [
'npm test && rm -rf /',
'npm test; rm -rf /',
'npm test || rm -rf /',
'npm test | rm -rf /',
'npm test $(echo dangerous)',
'npm test `echo dangerous`'
]
disallowedChainedCommands.forEach(command => {
// First hydrate state with initial task
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the chained command ask message
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'command',
ts: Date.now(),
text: command,
partial: false
}
]
})
// Verify no auto-approval message was sent for chained commands with disallowed parts
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
})
})

View file

@ -1,4 +1,4 @@
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
@ -25,10 +25,16 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysAllowExecute,
alwaysAllowBrowser,
setAlwaysAllowBrowser,
soundEnabled,
setSoundEnabled,
openRouterModels,
setAllowedCommands,
allowedCommands,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("")
const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
@ -42,6 +48,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
onDone()
}
}
@ -51,22 +59,31 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setModelIdErrorMessage(undefined)
}, [apiConfiguration])
// validate as soon as the component is mounted
/*
useEffect will use stale values of variables if they are not included in the dependency array. so trying to use useEffect with a dependency array of only one value for example will use any other variables' old values. In most cases you don't want this, and should opt to use react-use hooks.
// Initial validation on mount
useEffect(() => {
// uses someVar and anotherVar
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [someVar])
If we only want to run code once on mount we can use react-use's useEffectOnce or useMount
*/
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult)
}, [apiConfiguration, openRouterModels])
const handleResetState = () => {
vscode.postMessage({ type: "resetState" })
}
const handleAddCommand = () => {
const currentCommands = allowedCommands ?? []
if (commandInput && !currentCommands.includes(commandInput)) {
const newCommands = [...currentCommands, commandInput]
setAllowedCommands(newCommands)
setCommandInput("")
vscode.postMessage({
type: "allowedCommands",
commands: newCommands
})
}
}
return (
<div
style={{
@ -118,7 +135,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
These instructions are added to the end of the system prompt sent with every request.
These instructions are added to the end of the system prompt sent with every request. Custom instructions set in .clinerules and .cursorrules in the working directory are also included.
</p>
</div>
@ -149,27 +166,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
padding: "8px",
border: "1px solid var(--vscode-errorBorder)",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}>
When enabled, Cline will automatically write to files and create directories
without requiring you to click the Approve button.
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowExecute}
onChange={(e: any) => setAlwaysAllowExecute(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve execute operations</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will automatically CLI commands without requiring
you to click the Approve button.
WARNING: When enabled, Cline will automatically create and edit files without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.
</p>
</div>
@ -179,14 +181,124 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
onChange={(e: any) => setAlwaysAllowBrowser(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
padding: "8px",
backgroundColor: "var(--vscode-errorBackground)",
border: "1px solid var(--vscode-errorBorder)",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}>
WARNING: When enabled, Cline will automatically perform browser actions without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.<br/><br/>NOTE: The checkbox only applies when the model supports computer use.
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowExecute}
onChange={(e: any) => setAlwaysAllowExecute(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve allowed execute operations</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
padding: "8px",
backgroundColor: "var(--vscode-errorBackground)",
border: "1px solid var(--vscode-errorBorder)",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}>
WARNING: When enabled, Cline will automatically execute allowed terminal commands without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.
</p>
</div>
{alwaysAllowExecute && (
<div style={{ marginBottom: 5 }}>
<div style={{ marginBottom: "10px" }}>
<span style={{ fontWeight: "500" }}>Allowed Auto-Execute Commands</span>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Command prefixes that can be auto-executed when "Always approve execute operations" is enabled.
</p>
<div style={{ display: 'flex', gap: '5px', marginTop: '10px' }}>
<VSCodeTextField
value={commandInput}
onInput={(e: any) => setCommandInput(e.target.value)}
placeholder="Enter command prefix (e.g., 'git ')"
style={{ flexGrow: 1 }}
/>
<VSCodeButton onClick={handleAddCommand}>
Add
</VSCodeButton>
</div>
<div style={{
marginTop: '10px',
display: 'flex',
flexWrap: 'wrap',
gap: '5px'
}}>
{(allowedCommands ?? []).map((cmd, index) => (
<div key={index} style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
backgroundColor: 'var(--vscode-button-secondaryBackground)',
padding: '2px 6px',
borderRadius: '4px',
border: '1px solid var(--vscode-button-secondaryBorder)',
height: '24px'
}}>
<span>{cmd}</span>
<VSCodeButton
appearance="icon"
style={{
padding: 0,
margin: 0,
height: '20px',
width: '20px',
minWidth: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={() => {
const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index)
setAllowedCommands(newCommands)
vscode.postMessage({
type: "allowedCommands",
commands: newCommands
})
}}
>
<span className="codicon codicon-close" />
</VSCodeButton>
</div>
))}
</div>
</div>
</div>
)}
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable sound effects</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will automatically perform browser actions without requiring
you to click the Approve button.
When enabled, Cline will play sound effects for notifications and events.
</p>
</div>
@ -218,8 +330,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
}}>
<p style={{ wordWrap: "break-word", margin: 0, padding: 0 }}>
If you have any questions or feedback, feel free to open an issue at{" "}
<VSCodeLink href="https://github.com/cline/cline" style={{ display: "inline" }}>
https://github.com/cline/cline
<VSCodeLink href="https://github.com/RooVetGit/Roo-Cline" style={{ display: "inline" }}>
https://github.com/RooVetGit/Roo-Cline
</VSCodeLink>
</p>
<p style={{ fontStyle: "italic", margin: "10px 0 0 0", padding: 0 }}>v{version}</p>

View file

@ -1,230 +1,259 @@
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ExtensionStateContextType } from '../../../context/ExtensionStateContext'
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import SettingsView from '../SettingsView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import * as ExtensionStateContext from '../../../context/ExtensionStateContext'
import { ModelInfo } from '../../../../../src/shared/api'
// Mock dependencies
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn()
}
vscode: {
postMessage: jest.fn(),
},
}))
// Mock validation functions
jest.mock('../../../utils/validate', () => ({
validateApiConfiguration: jest.fn(() => undefined),
validateModelId: jest.fn(() => undefined)
}))
// Mock ApiOptions component
jest.mock('../ApiOptions', () => ({
__esModule: true,
default: () => <div data-testid="mock-api-options" />
}))
// Mock VS Code components
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick }: any) => (
<button onClick={onClick}>{children}</button>
),
VSCodeCheckbox: ({ children, checked, onChange }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={e => onChange(e)}
aria-checked={checked}
/>
{children}
</label>
),
VSCodeTextArea: ({ children, value, onInput }: any) => (
<textarea
data-testid="custom-instructions"
value={value}
readOnly
aria-label="Custom Instructions"
>{children}</textarea>
),
VSCodeLink: ({ children, href }: any) => (
<a href={href}>{children}</a>
)
VSCodeButton: ({ children, onClick, appearance }: any) => (
appearance === 'icon' ?
<button onClick={onClick} className="codicon codicon-close" aria-label="Remove command">
<span className="codicon codicon-close" />
</button> :
<button onClick={onClick} data-appearance={appearance}>{children}</button>
),
VSCodeCheckbox: ({ children, onChange, checked }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
aria-label={typeof children === 'string' ? children : undefined}
/>
{children}
</label>
),
VSCodeTextField: ({ value, onInput, placeholder }: any) => (
<input
type="text"
value={value}
onChange={(e) => onInput({ target: { value: e.target.value } })}
placeholder={placeholder}
/>
),
VSCodeTextArea: () => <textarea />,
VSCodeLink: () => <a />,
VSCodeDropdown: ({ children, value, onChange }: any) => (
<select value={value} onChange={onChange}>
{children}
</select>
),
VSCodeOption: ({ children, value }: any) => (
<option value={value}>{children}</option>
),
VSCodeRadio: ({ children, value, checked, onChange }: any) => (
<input
type="radio"
value={value}
checked={checked}
onChange={onChange}
/>
),
VSCodeRadioGroup: ({ children, value, onChange }: any) => (
<div onChange={onChange}>
{children}
</div>
)
}))
describe('SettingsView', () => {
const mockOnDone = jest.fn()
const mockSetAlwaysAllowWrite = jest.fn()
const mockSetAlwaysAllowReadOnly = jest.fn()
const mockSetCustomInstructions = jest.fn()
const mockSetAlwaysAllowExecute = jest.fn()
let mockState: ExtensionStateContextType
const mockOpenRouterModels: Record<string, ModelInfo> = {
'claude-3-sonnet': {
maxTokens: 200000,
contextWindow: 200000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 0.000008,
outputPrice: 0.000024,
description: "Anthropic's Claude 3 Sonnet model"
}
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage({
type: 'state',
state: {
version: '1.0.0',
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
...state
}
}, '*')
}
beforeEach(() => {
jest.clearAllMocks()
mockState = {
apiConfiguration: {
apiProvider: 'anthropic',
apiModelId: 'claude-3-sonnet'
},
version: '1.0.0',
customInstructions: 'Test instructions',
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
openRouterModels: mockOpenRouterModels,
didHydrateState: true,
showWelcome: false,
theme: 'dark',
filePaths: [],
taskHistory: [],
shouldShowAnnouncement: false,
clineMessages: [],
uriScheme: 'vscode',
setAlwaysAllowReadOnly: mockSetAlwaysAllowReadOnly,
setAlwaysAllowWrite: mockSetAlwaysAllowWrite,
setCustomInstructions: mockSetCustomInstructions,
setAlwaysAllowExecute: mockSetAlwaysAllowExecute,
setApiConfiguration: jest.fn(),
setShowAnnouncement: jest.fn()
}
// Mock the useExtensionState hook
jest.spyOn(ExtensionStateContext, 'useExtensionState').mockReturnValue(mockState)
const renderSettingsView = () => {
const onDone = jest.fn()
render(
<ExtensionStateContextProvider>
<SettingsView onDone={onDone} />
</ExtensionStateContextProvider>
)
// Hydrate initial state
mockPostMessage({})
return { onDone }
}
describe('SettingsView - Sound Settings', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('initializes with sound disabled by default', () => {
renderSettingsView()
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
expect(soundCheckbox).not.toBeChecked()
})
const renderSettingsView = () => {
return render(
<SettingsView onDone={mockOnDone} />
)
}
describe('Checkboxes', () => {
it('should toggle alwaysAllowWrite checkbox', async () => {
mockState.alwaysAllowWrite = false
renderSettingsView()
const writeCheckbox = screen.getByRole('checkbox', {
name: /Always approve write operations/i
})
expect(writeCheckbox).not.toBeChecked()
await act(async () => {
await userEvent.click(writeCheckbox)
})
expect(mockSetAlwaysAllowWrite).toHaveBeenCalledWith(true)
})
it('should toggle alwaysAllowExecute checkbox', async () => {
mockState.alwaysAllowExecute = false
renderSettingsView()
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve execute operations/i
})
expect(executeCheckbox).not.toBeChecked()
await act(async () => {
await userEvent.click(executeCheckbox)
})
expect(mockSetAlwaysAllowExecute).toHaveBeenCalledWith(true)
})
it('should toggle alwaysAllowReadOnly checkbox', async () => {
mockState.alwaysAllowReadOnly = false
renderSettingsView()
const readOnlyCheckbox = screen.getByRole('checkbox', {
name: /Always approve read-only operations/i
})
expect(readOnlyCheckbox).not.toBeChecked()
await act(async () => {
await userEvent.click(readOnlyCheckbox)
})
expect(mockSetAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
})
})
describe('Form Submission', () => {
it('should send correct messages when form is submitted', async () => {
renderSettingsView()
// Submit form
const doneButton = screen.getByRole('button', { name: /Done/i })
await act(async () => {
await userEvent.click(doneButton)
})
// Verify messages were sent in the correct order
const calls = (vscode.postMessage as jest.Mock).mock.calls
expect(calls).toHaveLength(5)
expect(calls[0][0]).toEqual({
type: 'apiConfiguration',
apiConfiguration: {
apiProvider: 'anthropic',
apiModelId: 'claude-3-sonnet'
}
})
expect(calls[1][0]).toEqual({
type: 'customInstructions',
text: 'Test instructions'
})
expect(calls[2][0]).toEqual({
type: 'alwaysAllowReadOnly',
bool: true
})
expect(calls[3][0]).toEqual({
type: 'alwaysAllowWrite',
bool: true
})
expect(calls[4][0]).toEqual({
type: 'alwaysAllowExecute',
bool: true
})
// Verify onDone was called
expect(mockOnDone).toHaveBeenCalled()
})
})
describe('Accessibility', () => {
it('should have accessible form controls', () => {
renderSettingsView()
// Check for proper labels and ARIA attributes
const writeCheckbox = screen.getByRole('checkbox', {
name: /Always approve write operations/i
})
expect(writeCheckbox).toHaveAttribute('aria-checked')
const textarea = screen.getByRole('textbox', {
name: /Custom Instructions/i
})
expect(textarea).toBeInTheDocument()
})
it('toggles sound setting and sends message to VSCode', () => {
renderSettingsView()
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
// Enable sound
fireEvent.click(soundCheckbox)
expect(soundCheckbox).toBeChecked()
// Click Done to save settings
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'soundEnabled',
bool: true
})
)
})
})
describe('SettingsView - Allowed Commands', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('shows allowed commands section when alwaysAllowExecute is enabled', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Verify allowed commands section appears
expect(screen.getByText(/Allowed Auto-Execute Commands/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/Enter command prefix/i)).toBeInTheDocument()
})
it('adds new command to the list', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Add a new command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
// Verify command was added
expect(screen.getByText('npm test')).toBeInTheDocument()
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'allowedCommands',
commands: ['npm test']
})
})
it('removes command from the list', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Add a command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
// Remove the command
const removeButton = screen.getByRole('button', { name: 'Remove command' })
fireEvent.click(removeButton)
// Verify command was removed
expect(screen.queryByText('npm test')).not.toBeInTheDocument()
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: 'allowedCommands',
commands: []
})
})
it('prevents duplicate commands', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Add a command twice
const input = screen.getByPlaceholderText(/Enter command prefix/i)
const addButton = screen.getByText('Add')
// First addition
fireEvent.change(input, { target: { value: 'npm test' } })
fireEvent.click(addButton)
// Second addition attempt
fireEvent.change(input, { target: { value: 'npm test' } })
fireEvent.click(addButton)
// Verify command appears only once
const commands = screen.getAllByText('npm test')
expect(commands).toHaveLength(1)
})
it('saves allowed commands when clicking Done', () => {
const { onDone } = renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Add a command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
// Click Done
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
// Verify VSCode messages were sent
expect(vscode.postMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'allowedCommands',
commands: ['npm test']
}))
expect(onDone).toHaveBeenCalled()
})
})

View file

@ -24,6 +24,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowExecute: (value: boolean) => void
setAlwaysAllowBrowser: (value: boolean) => void
setShowAnnouncement: (value: boolean) => void
setAllowedCommands: (value: string[]) => void
setSoundEnabled: (value: boolean) => void
}
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@ -34,6 +36,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
soundEnabled: false,
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@ -121,6 +125,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setAlwaysAllowExecute: (value) => setState((prevState) => ({ ...prevState, alwaysAllowExecute: value })),
setAlwaysAllowBrowser: (value) => setState((prevState) => ({ ...prevState, alwaysAllowBrowser: value })),
setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -0,0 +1,71 @@
import React from 'react'
import { render, screen, act } from '@testing-library/react'
import { ExtensionStateContextProvider, useExtensionState } from '../ExtensionStateContext'
// Test component that consumes the context
const TestComponent = () => {
const { allowedCommands, setAllowedCommands, soundEnabled } = useExtensionState()
return (
<div>
<div data-testid="allowed-commands">{JSON.stringify(allowedCommands)}</div>
<div data-testid="sound-enabled">{JSON.stringify(soundEnabled)}</div>
<button
data-testid="update-button"
onClick={() => setAllowedCommands(['npm install', 'git status'])}
>
Update Commands
</button>
</div>
)
}
describe('ExtensionStateContext', () => {
it('initializes with empty allowedCommands array', () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>
)
expect(JSON.parse(screen.getByTestId('allowed-commands').textContent!)).toEqual([])
})
it('initializes with soundEnabled set to false', () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>
)
expect(JSON.parse(screen.getByTestId('sound-enabled').textContent!)).toBe(false)
})
it('updates allowedCommands through setAllowedCommands', () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>
)
act(() => {
screen.getByTestId('update-button').click()
})
expect(JSON.parse(screen.getByTestId('allowed-commands').textContent!)).toEqual([
'npm install',
'git status'
])
})
it('throws error when used outside provider', () => {
// Suppress console.error for this test since we expect an error
const consoleSpy = jest.spyOn(console, 'error')
consoleSpy.mockImplementation(() => {})
expect(() => {
render(<TestComponent />)
}).toThrow('useExtensionState must be used within an ExtensionStateContextProvider')
consoleSpy.mockRestore()
})
})