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

This commit is contained in:
a8trejo 2024-12-16 15:42:14 -08:00
commit a1b48facb1
66 changed files with 3931 additions and 1291 deletions

View file

@ -0,0 +1,20 @@
import { ChangelogFunctions } from '@changesets/types';
const getReleaseLine: ChangelogFunctions['getReleaseLine'] = async (changeset) => {
const [firstLine] = changeset.summary
.split('\n')
.map(l => l.trim())
.filter(Boolean);
return `- ${firstLine}`;
};
const getDependencyReleaseLine: ChangelogFunctions['getDependencyReleaseLine'] = async () => {
return '';
};
const changelogFunctions: ChangelogFunctions = {
getReleaseLine,
getDependencyReleaseLine,
};
export default changelogFunctions;

View file

@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.4/schema.json",
"changelog": "@changesets/cli/changelog",
"changelog": "./changelog-config.ts",
"commit": false,
"fixed": [],
"linked": [],

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Add volume slider in settings and change sound effects to only trigger when user intervention is required, an error occurs, or a task is completed.

3
.changeset/package.json Normal file
View file

@ -0,0 +1,3 @@
{
"type": "module"
}

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Fix lint errors and change npm run lint to also run on webview-ui

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Feature Request
url: https://github.com/RooVetGit/Roo-Cline/discussions/categories/feature-requests
about: Share and vote on feature requests for Roo Cline
- name: Leave a Review
url: https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline&ssr=false#review-details
about: Enjoying Roo Cline? Leave a review here!

View file

@ -20,17 +20,14 @@ inputs:
default: ''
type: string
git_ref:
required: false
required: true
type: string
default: ''
head_ref:
required: false
required: true
type: string
default: main
base_ref:
required: false
required: true
type: string
default: main
outputs:
RELEASE_NOTES:
@ -41,9 +38,9 @@ outputs:
value: ${{ steps.ai_prompt.outputs.OPENAI_PROMPT }}
env:
GITHUB_REF: ${{ inputs.git_ref == '' && github.event.pull_request.head.ref || inputs.git_ref }}
BASE_REF: ${{ inputs.base_ref == '' && github.base_ref || inputs.base_ref }}
HEAD_REF: ${{ inputs.head_ref == '' && github.event.pull_request.head.sha || inputs.head_ref }}
GITHUB_REF: ${{ inputs.git_ref }}
BASE_REF: ${{ inputs.base_ref }}
HEAD_REF: ${{ inputs.head_ref }}
runs:
using: "composite"

View file

@ -1,215 +0,0 @@
name: Changeset AI Release
run-name: Changeset AI Release ${{ github.actor != 'R00-B0T' && '- Create PR' || '- Approve & Release' }}
# This workflow automates the release process by:
# 1. Creating a version bump PR when changesets are merged to main
# 2. Using AI to generate release notes for the version bump PR
# 3. Auto-approving and merging the version bump PR
# 4. Creating a GitHub release with the AI-generated notes
on:
pull_request:
types: [closed, opened, synchronize, labeled]
env:
REPO_PATH: ${{ github.repository }}
GIT_REF: ${{ github.event.pull_request.head.sha }}
jobs:
# Job 1: Create version bump PR when changesets are merged to main
changeset-pr-version-bump:
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'R00-B0T'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Git Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.GIT_REF }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Dependencies
run: npm install
# Check if there are any new changesets to process
- name: Check for changesets
id: check-changesets
run: |
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
echo "Changesets diff with previous version: $NEW_CHANGESETS"
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
# Create version bump PR using changesets/action if there are new changesets
- name: Changeset Pull Request
if: steps.check-changesets.outputs.new_changesets != '0'
id: changesets
uses: changesets/action@v1
with:
commit: "changeset version bump"
title: "Changeset version bump"
version: npm run version-packages # This performs the changeset version bump
env:
GITHUB_TOKEN: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }}
# Job 2: Process version bump PR created by R00-B0T
changeset-pr-approve-merge:
name: Auto approve and merge Bump version PRs
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.base.ref == 'main' &&
github.actor == 'R00-B0T' &&
contains(github.event.pull_request.title, 'Changeset version bump')
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
token: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }}
fetch-depth: 0
ref: ${{ env.GIT_REF }}
# Get current and previous versions for changelog processing
- name: Get version
id: get_version
run: |
VERSION=$(git show HEAD:package.json | jq -r '.version')
echo "version=$VERSION" >> $GITHUB_OUTPUT
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
echo "version=$VERSION"
echo "prev_version=$PREV_VERSION"
# Get previous version refs, GITHUB_OUTPUT: 'BASE_REF' and 'HEAD_REF'
- name: Get Previous Version Refs
id: version_refs
run: python .github/scripts/get_prev_version_refs.py
# Generate release notes using OpenAI if not already edited, GITHUB_OUTPUT: 'RELEASE_NOTES' and 'OPENAI_PROMPT'
- name: AI Release Notes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'openai-edited') }}
uses: ./.github/actions/ai-release-notes
id: ai_release_notes
with:
GHA_PAT: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
model_name: gpt-4o-mini
repo_path: ${{ env.REPO_PATH }}
base_ref: ${{ steps.version_refs.outputs.base_ref }}
head_ref: ${{ steps.version_refs.outputs.head_ref }}
# Update CHANGELOG.md with AI-generated notes
- name: Update Changeset Changelog
if: ${{ !contains(github.event.pull_request.labels.*.name, 'openai-edited') }}
env:
VERSION: ${{ steps.get_version.outputs.version }}
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
NEW_CONTENT: ${{ steps.ai_release_notes.outputs.RELEASE_NOTES }}
run: python .github/scripts/overwrite_changeset_changelog.py
# Commit and push changelog updates
- name: Push Changelog updates
if: ${{ !contains(github.event.pull_request.labels.*.name, 'openai-edited') }}
run: |
git config user.name "R00-B0T"
git config user.email github-actions@github.com
git status
echo "Updating changelog.md..."
git add CHANGELOG.md
git commit -m "Updating changeset changelog"
echo "--------------------------------------------------------------------------------"
echo "Pushing to remote..."
echo "--------------------------------------------------------------------------------"
git push
# Add label to indicate OpenAI has processed this PR
- name: Add openai-edited label
if: ${{ !contains(github.event.pull_request.labels.*.name, 'openai-edited') }}
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['openai-edited']
});
# Auto-approve PR once OpenAI has processed it
- name: Auto approve PR
if: contains(github.event.pull_request.labels.*.name, 'openai-edited')
uses: hmarr/auto-approve-action@v4
with:
review-message: "I'm approving since it's a bump version PR"
# Enable auto-merge for the PR
- name: Enable automerge on PR
if: contains(github.event.pull_request.labels.*.name, 'openai-edited')
run: gh pr merge --squash --auto ${{ github.event.pull_request.number }}
env:
GH_TOKEN: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }}
# Job 3: Create GitHub release after version bump PR is merged
github-release:
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor == 'R00-B0T' &&
contains(github.event.pull_request.title, 'Changeset version bump')
permissions:
contents: write
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Get version
id: get_version
run: |
VERSION=$(git show HEAD:package.json | jq -r '.version')
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Extract release notes from CHANGELOG.md, GITHUB_OUTPUT: 'release-notes'
- name: Parse CHANGELOG.md
id: changelog
env:
CHANGELOG_PATH: CHANGELOG.md
VERSION: ${{ steps.get_version.outputs.version }}
run: python .github/scripts/parse_changeset_changelog.py
# Create GitHub release with extracted notes
- name: Create or Update Release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.get_version.outputs.version }}
name: Release v${{ steps.get_version.outputs.version }}
draft: false
prerelease: false
append_body: false
make_latest: true
body: ${{ steps.changelog.outputs.release-notes }}

91
.github/workflows/changeset-release.yml vendored Normal file
View file

@ -0,0 +1,91 @@
name: Changeset Release
run-name: Changeset Release ${{ github.actor != 'R00-B0T' && '- Create PR' || '- Approve & Merge' }}
on:
pull_request:
types: [closed, opened, synchronize, labeled]
env:
REPO_PATH: ${{ github.repository }}
GIT_REF: ${{ github.event.pull_request.head.sha }}
jobs:
# Job 1: Create version bump PR when changesets are merged to main
changeset-pr-version-bump:
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'R00-B0T'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Git Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.GIT_REF }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Dependencies
run: npm install
# Check if there are any new changesets to process
- name: Check for changesets
id: check-changesets
run: |
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
echo "Changesets diff with previous version: $NEW_CHANGESETS"
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
# Create version bump PR using changesets/action if there are new changesets
- name: Changeset Pull Request
if: steps.check-changesets.outputs.new_changesets != '0'
id: changesets
uses: changesets/action@v1
with:
commit: "changeset version bump"
title: "Changeset version bump"
version: npm run version-packages # This performs the changeset version bump
env:
GITHUB_TOKEN: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }}
# Job 2: Process version bump PR created by R00-B0T
changeset-pr-approve-merge:
name: Auto approve and merge Bump version PRs
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.base.ref == 'main' &&
github.actor == 'R00-B0T' &&
contains(github.event.pull_request.title, 'Changeset version bump')
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
token: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }}
fetch-depth: 0
ref: ${{ env.GIT_REF }}
# Auto-approve PR
- name: Auto approve PR
uses: hmarr/auto-approve-action@v4
with:
review-message: "I'm approving since it's a bump version PR"
# Enable auto-merge for the PR
- name: Enable automerge on PR
run: gh pr merge --squash --auto ${{ github.event.pull_request.number }}
env:
GH_TOKEN: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }}

View file

@ -69,4 +69,4 @@ jobs:
run: npm run install:all
- name: Run unit tests
run: npx jest
run: npm test

View file

@ -0,0 +1,31 @@
name: Publish Extension Temporary
on:
push:
branches: ["main"]
workflow_dispatch:
jobs:
publish-extension:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: |
git config user.name github-actions
git config user.email github-actions@github.com
- name: Install Dependencies
run: |
npm install -g vsce ovsx
npm install
cd webview-ui
npm install
cd ..
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
current_package_version=$(node -p "require('./package.json').version")
npm run publish:marketplace
echo "Successfully published version $current_package_version to VS Code Marketplace"

3
.gitignore vendored
View file

@ -10,5 +10,4 @@ bin/
roo-cline-*.vsix
# Local prompts and rules
prompts
.clinerules
/local-prompts

View file

@ -1,5 +1,33 @@
# Roo Cline Changelog
## [2.2.12]
- Better support for pure deletion and insertion diffs
## [2.2.11]
- Added settings checkbox for verbose diff debugging
## [2.2.6 - 2.2.10]
- More fixes to search/replace diffs
## [2.2.5]
- Allow MCP servers to be enabled/disabled
## [2.2.4]
- Tweak the prompt to encourage diff edits when they're enabled
## [2.2.3]
- Clean up the settings screen
## [2.2.2]
- Add checkboxes to auto-approve MCP tools
## [2.2.1]
- Fix another diff editing indentation bug
@ -72,9 +100,9 @@
## [2.2.0]
- Add support for Model Context Protocol (MCP), enabling Cline to use custom tools like web-search tool or GitHub tool
- Add MCP server management tab accessible via the server icon in the menu bar
- Add ability for Cline to dynamically create new MCP servers based on user requests (e.g., "add a tool that gets the latest npm docs")
- Add support for Model Context Protocol (MCP), enabling Cline to use custom tools like web-search tool or GitHub tool
- Add MCP server management tab accessible via the server icon in the menu bar
- Add ability for Cline to dynamically create new MCP servers based on user requests (e.g., "add a tool that gets the latest npm docs")
## [2.1.6]

View file

@ -1,14 +1,19 @@
# Roo-Cline
A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.
- Auto-approval capabilities for commands, write, and browser operations
- Support for .clinerules per-project custom instructions
- Ability to run side-by-side with Cline
- Unit test coverage (written almost entirely by Roo Cline!)
- Support for playing sound effects
- Support for OpenRouter compression
- Support for copying prompts from the history screen
- Support for editing through diffs / handling truncated full-file edits
- Support for newer Gemini models (gemini-exp-1206 and gemini-2.0-flash-exp)
A fork of Cline, an autonomous coding agent, tweaked for more speed and flexibility. Its been mainly writing itself recently, with a light touch of human guidance here and there.
## Features
- Automatically approve commands, browsing, file writing, and MCP tools
- Faster, more targeted edits via diffs (even on big files)
- Detects and fixes missing code chunks
- `.clinerules` for project-specific instructions
- Drag and drop images into chats
- Sound effects for feedback
- Quick prompt copying from history
- OpenRouter compression support
- Support for newer Gemini models (gemini-exp-1206, gemini-2.0-flash-exp)
- Runs alongside the original Cline
## Disclaimer
@ -103,7 +108,7 @@ Subscribe to our [Github releases](https://github.com/RooVetGit/Roo-Cline/releas
Meet Cline, an AI assistant that can use your **CLI** a**N**d **E**ditor.
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
1. Enter your task and add images to convert mockups into functional apps or fix bugs with screenshots.
2. Cline starts by analyzing your file structure & source code ASTs, running regex searches, and reading relevant files to get up to speed in existing projects. By carefully managing what information is added to context, Cline can provide valuable assistance even for large, complex projects without overwhelming the context window.
@ -184,13 +189,13 @@ Thanks to the [Model Context Protocol](https://github.com/modelcontextprotocol),
### Add Context
**`@url`:** Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Cline the latest docs
**`@url`:** Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Cline the latest docs
**`@problems`:** Add workspace errors and warnings ('Problems' panel) for Cline to fix
**`@problems`:** Add workspace errors and warnings ('Problems' panel) for Cline to fix
**`@file`:** Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
**`@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
**`@folder`:** Adds folder's files all at once to speed up your workflow even more
## Contributing

View file

@ -5,17 +5,35 @@ module.exports = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.json'
tsconfig: {
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"allowJs": true
}
}]
},
testMatch: ['**/__tests__/**/*.test.ts'],
moduleNameMapper: {
'^vscode$': '<rootDir>/node_modules/@types/vscode/index.d.ts'
'^vscode$': '<rootDir>/src/__mocks__/vscode.js',
'@modelcontextprotocol/sdk$': '<rootDir>/src/__mocks__/@modelcontextprotocol/sdk/index.js',
'@modelcontextprotocol/sdk/(.*)': '<rootDir>/src/__mocks__/@modelcontextprotocol/sdk/$1',
'^delay$': '<rootDir>/src/__mocks__/delay.js',
'^p-wait-for$': '<rootDir>/src/__mocks__/p-wait-for.js',
'^globby$': '<rootDir>/src/__mocks__/globby.js',
'^serialize-error$': '<rootDir>/src/__mocks__/serialize-error.js',
'^strip-ansi$': '<rootDir>/src/__mocks__/strip-ansi.js',
'^default-shell$': '<rootDir>/src/__mocks__/default-shell.js',
'^os-name$': '<rootDir>/src/__mocks__/os-name.js'
},
transformIgnorePatterns: [
'node_modules/(?!(@modelcontextprotocol|delay|p-wait-for|globby|serialize-error|strip-ansi|default-shell|os-name)/)'
],
setupFiles: [],
globals: {
'ts-jest': {
diagnostics: false
diagnostics: false,
isolatedModules: true
}
}
};

11
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "roo-cline",
"version": "2.2.1",
"version": "2.2.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
"version": "2.2.1",
"version": "2.2.12",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0",
@ -37,6 +37,7 @@
"play-sound": "^1.1.6",
"puppeteer": "^23.9.0",
"serialize-error": "^11.0.3",
"sound-play": "^1.1.0",
"strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0",
@ -46,6 +47,7 @@
},
"devDependencies": {
"@changesets/cli": "^2.27.10",
"@changesets/types": "^6.0.0",
"@types/diff": "^5.2.1",
"@types/jest": "^29.5.14",
"@types/mocha": "^10.0.7",
@ -14337,6 +14339,11 @@
"node": ">= 14"
}
},
"node_modules/sound-play": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/sound-play/-/sound-play-1.1.0.tgz",
"integrity": "sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA=="
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View file

@ -3,7 +3,7 @@
"displayName": "Roo Cline",
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
"publisher": "RooVeterinaryInc",
"version": "2.2.1",
"version": "2.2.12",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",
@ -153,11 +153,11 @@
"compile": "npm run check-types && npm run lint && node esbuild.js",
"compile-tests": "tsc -p . --outDir out",
"install:all": "npm install && cd webview-ui && npm install",
"lint": "eslint src --ext ts",
"lint": "eslint src --ext ts && npm run lint --prefix webview-ui",
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
"start:webview": "cd webview-ui && npm run start",
"test": "jest",
"test": "jest && npm run test:webview",
"test:webview": "cd webview-ui && npm run test",
"publish:marketplace": "vsce publish",
"publish": "npm run build && changeset publish && npm install --package-lock-only",
@ -172,6 +172,7 @@
},
"devDependencies": {
"@changesets/cli": "^2.27.10",
"@changesets/types": "^6.0.0",
"@types/diff": "^5.2.1",
"@types/jest": "^29.5.14",
"@types/mocha": "^10.0.7",
@ -219,6 +220,7 @@
"puppeteer": "^23.9.0",
"play-sound": "^1.1.6",
"serialize-error": "^11.0.3",
"sound-play": "^1.1.0",
"strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0",

View file

@ -0,0 +1,17 @@
class Client {
constructor() {
this.request = jest.fn()
}
connect() {
return Promise.resolve()
}
close() {
return Promise.resolve()
}
}
module.exports = {
Client
}

View file

@ -0,0 +1,22 @@
class StdioClientTransport {
constructor() {
this.start = jest.fn().mockResolvedValue(undefined)
this.close = jest.fn().mockResolvedValue(undefined)
this.stderr = {
on: jest.fn()
}
}
}
class StdioServerParameters {
constructor() {
this.command = ''
this.args = []
this.env = {}
}
}
module.exports = {
StdioClientTransport,
StdioServerParameters
}

View file

@ -0,0 +1,24 @@
const { Client } = require('./client/index.js')
const { StdioClientTransport, StdioServerParameters } = require('./client/stdio.js')
const {
CallToolResultSchema,
ListToolsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
ErrorCode,
McpError
} = require('./types.js')
module.exports = {
Client,
StdioClientTransport,
StdioServerParameters,
CallToolResultSchema,
ListToolsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
ErrorCode,
McpError
}

View file

@ -0,0 +1,51 @@
const CallToolResultSchema = {
parse: jest.fn().mockReturnValue({})
}
const ListToolsResultSchema = {
parse: jest.fn().mockReturnValue({
tools: []
})
}
const ListResourcesResultSchema = {
parse: jest.fn().mockReturnValue({
resources: []
})
}
const ListResourceTemplatesResultSchema = {
parse: jest.fn().mockReturnValue({
resourceTemplates: []
})
}
const ReadResourceResultSchema = {
parse: jest.fn().mockReturnValue({
contents: []
})
}
const ErrorCode = {
InvalidRequest: 'InvalidRequest',
MethodNotFound: 'MethodNotFound',
InvalidParams: 'InvalidParams',
InternalError: 'InternalError'
}
class McpError extends Error {
constructor(code, message) {
super(message)
this.code = code
}
}
module.exports = {
CallToolResultSchema,
ListToolsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
ErrorCode,
McpError
}

17
src/__mocks__/McpHub.ts Normal file
View file

@ -0,0 +1,17 @@
export class McpHub {
connections = []
isConnecting = false
constructor() {
this.toggleToolAlwaysAllow = jest.fn()
this.callTool = jest.fn()
}
async toggleToolAlwaysAllow(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
return Promise.resolve()
}
async callTool(serverName: string, toolName: string, toolArguments?: Record<string, unknown>): Promise<any> {
return Promise.resolve({ result: 'success' })
}
}

View file

@ -0,0 +1,12 @@
// Mock default shell based on platform
const os = require('os');
let defaultShell;
if (os.platform() === 'win32') {
defaultShell = 'cmd.exe';
} else {
defaultShell = '/bin/bash';
}
module.exports = defaultShell;
module.exports.default = defaultShell;

6
src/__mocks__/delay.js Normal file
View file

@ -0,0 +1,6 @@
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = delay;
module.exports.default = delay;

10
src/__mocks__/globby.js Normal file
View file

@ -0,0 +1,10 @@
function globby(patterns, options) {
return Promise.resolve([]);
}
globby.sync = function(patterns, options) {
return [];
};
module.exports = globby;
module.exports.default = globby;

6
src/__mocks__/os-name.js Normal file
View file

@ -0,0 +1,6 @@
function osName() {
return 'macOS';
}
module.exports = osName;
module.exports.default = osName;

View file

@ -0,0 +1,20 @@
function pWaitFor(condition, options = {}) {
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
if (condition()) {
clearInterval(interval);
resolve();
}
}, options.interval || 20);
if (options.timeout) {
setTimeout(() => {
clearInterval(interval);
reject(new Error('Timed out'));
}, options.timeout);
}
});
}
module.exports = pWaitFor;
module.exports.default = pWaitFor;

View file

@ -0,0 +1,25 @@
function serializeError(error) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack
};
}
return error;
}
function deserializeError(errorData) {
if (errorData && typeof errorData === 'object') {
const error = new Error(errorData.message);
error.name = errorData.name;
error.stack = errorData.stack;
return error;
}
return errorData;
}
module.exports = {
serializeError,
deserializeError
};

View file

@ -0,0 +1,7 @@
function stripAnsi(string) {
// Simple mock that just returns the input string
return string;
}
module.exports = stripAnsi;
module.exports.default = stripAnsi;

57
src/__mocks__/vscode.js Normal file
View file

@ -0,0 +1,57 @@
const vscode = {
window: {
showInformationMessage: jest.fn(),
showErrorMessage: jest.fn(),
createTextEditorDecorationType: jest.fn().mockReturnValue({
dispose: jest.fn()
})
},
workspace: {
onDidSaveTextDocument: jest.fn()
},
Disposable: class {
dispose() {}
},
Uri: {
file: (path) => ({
fsPath: path,
scheme: 'file',
authority: '',
path: path,
query: '',
fragment: '',
with: jest.fn(),
toJSON: jest.fn()
})
},
EventEmitter: class {
constructor() {
this.event = jest.fn();
this.fire = jest.fn();
}
},
ConfigurationTarget: {
Global: 1,
Workspace: 2,
WorkspaceFolder: 3
},
Position: class {
constructor(line, character) {
this.line = line;
this.character = character;
}
},
Range: class {
constructor(startLine, startCharacter, endLine, endCharacter) {
this.start = new vscode.Position(startLine, startCharacter);
this.end = new vscode.Position(endLine, endCharacter);
}
},
ThemeColor: class {
constructor(id) {
this.id = id;
}
}
};
module.exports = vscode;

View file

@ -99,6 +99,7 @@ export class Cline {
apiConfiguration: ApiConfiguration,
customInstructions?: string,
diffEnabled?: boolean,
debugDiffEnabled?: boolean,
isInteractiveMode?: boolean,
browserPort?: string,
task?: string,
@ -115,7 +116,7 @@ export class Cline {
this.browserPort = browserPort ?? "7333"
this.customInstructions = customInstructions
if (diffEnabled && this.api.getModel().id) {
this.diffStrategy = getDiffStrategy(this.api.getModel().id)
this.diffStrategy = getDiffStrategy(this.api.getModel().id, debugDiffEnabled)
}
if (historyItem) {
this.taskId = historyItem.id
@ -1263,20 +1264,30 @@ export class Cline {
const originalContent = await fs.readFile(absolutePath, "utf-8")
// Apply the diff to the original content
let newContent = this.diffStrategy?.applyDiff(originalContent, diffContent) ?? false
if (newContent === false) {
const diffResult = this.diffStrategy?.applyDiff(
originalContent,
diffContent,
parseInt(block.params.start_line ?? ''),
parseInt(block.params.end_line ?? '')
) ?? {
success: false,
error: "No diff strategy available"
}
if (!diffResult.success) {
this.consecutiveMistakeCount++
await this.say("error", `Unable to apply diff to file - contents are out of sync: ${absolutePath}`)
pushToolResult(`Error applying diff to file: ${absolutePath} - contents are out of sync. Try re-reading the relevant lines of the file and applying the diff again.`)
const errorDetails = diffResult.details ? `\n\nDetails:\n${JSON.stringify(diffResult.details, null, 2)}` : ''
await this.say("error", `Unable to apply diff to file: ${absolutePath}\n${diffResult.error}${errorDetails}`)
pushToolResult(`Error applying diff to file: ${absolutePath}\n${diffResult.error}${errorDetails}`)
break
}
const newContent = diffResult.content
this.consecutiveMistakeCount = 0
// Show diff view before asking for approval
this.diffViewProvider.editType = "modify"
await this.diffViewProvider.open(relPath);
await this.diffViewProvider.update(newContent, true);
await this.diffViewProvider.update(diffResult.content, true);
await this.diffViewProvider.scrollToFirstDiff();
const completeMessage = JSON.stringify({

View file

@ -279,6 +279,7 @@ describe('Cline', () => {
mockApiConfig,
'custom instructions',
false,
false,
true, // isInteractiveMode
'7333', // browserPort
'test task'

View file

@ -44,6 +44,8 @@ export const toolParamNames = [
"question",
"result",
"diff",
"start_line",
"end_line",
] as const
export type ToolParamName = (typeof toolParamNames)[number]

View file

@ -6,10 +6,10 @@ import { SearchReplaceDiffStrategy } from './strategies/search-replace'
* @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus')
* @returns The appropriate diff strategy for the model
*/
export function getDiffStrategy(model: string): DiffStrategy {
// For now, return SearchReplaceDiffStrategy for all models
export function getDiffStrategy(model: string, debugEnabled?: boolean): DiffStrategy {
// For now, return SearchReplaceDiffStrategy for all models (with a fuzzy threshold of 0.9)
// This architecture allows for future optimizations based on model capabilities
return new SearchReplaceDiffStrategy()
return new SearchReplaceDiffStrategy(0.9, debugEnabled)
}
export type { DiffStrategy }

File diff suppressed because it is too large Load diff

View file

@ -59,7 +59,10 @@ function calculateTotal(items: number[]): number {
export { calculateTotal };`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result).toBe(expected)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(expected)
}
})
it('should successfully apply a diff adding a new method', () => {
@ -93,7 +96,10 @@ export { calculateTotal };`
}`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result).toBe(expected)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(expected)
}
})
it('should successfully apply a diff modifying imports', () => {
@ -128,7 +134,10 @@ function App() {
}`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result).toBe(expected)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(expected)
}
})
it('should successfully apply a diff with multiple hunks', () => {
@ -190,7 +199,10 @@ async function processFile(path: string) {
export { processFile };`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result).toBe(expected)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(expected)
}
})
it('should handle empty original content', () => {
@ -207,7 +219,10 @@ export { processFile };`
}\n`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result).toBe(expected)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(expected)
}
})
})
})

View file

@ -1,110 +1,309 @@
import { DiffStrategy } from "../types"
import { DiffStrategy, DiffResult } from "../types"
import { addLineNumbers } from "../../../integrations/misc/extract-text"
const BUFFER_LINES = 5; // Number of extra context lines to show before and after matches
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];
// Initialize matrix
for (let i = 0; i <= a.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= b.length; j++) {
matrix[0][j] = j;
}
// Fill matrix
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
if (a[i-1] === b[j-1]) {
matrix[i][j] = matrix[i-1][j-1];
} else {
matrix[i][j] = Math.min(
matrix[i-1][j-1] + 1, // substitution
matrix[i][j-1] + 1, // insertion
matrix[i-1][j] + 1 // deletion
);
}
}
}
return matrix[a.length][b.length];
}
function getSimilarity(original: string, search: string): number {
if (original === '' || search === '') {
return 1;
}
// Normalize strings by removing extra whitespace but preserve case
const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim();
const normalizedOriginal = normalizeStr(original);
const normalizedSearch = normalizeStr(search);
if (normalizedOriginal === normalizedSearch) { return 1; }
// Calculate Levenshtein distance
const distance = levenshteinDistance(normalizedOriginal, normalizedSearch);
// Calculate similarity ratio (0 to 1, where 1 is exact match)
const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length);
return 1 - (distance / maxLength);
}
export class SearchReplaceDiffStrategy implements DiffStrategy {
private fuzzyThreshold: number;
public debugEnabled: boolean;
constructor(fuzzyThreshold?: number, debugEnabled?: boolean) {
// Default to exact matching (1.0) unless fuzzy threshold specified
this.fuzzyThreshold = fuzzyThreshold ?? 1.0;
this.debugEnabled = debugEnabled ?? false;
}
getToolDescription(cwd: string): string {
return `## apply_diff
Description: Request to replace existing code using search and replace blocks.
Description: Request to replace existing code using a search and replace block.
This tool allows for precise, surgical replaces to files by specifying exactly what content to search for and what to replace it with.
Only use this tool when you need to replace/fix existing code.
The tool will maintain proper indentation and formatting while making changes.
Only a single operation is allowed per tool use.
The SEARCH section must exactly match existing content including whitespace and indentation.
If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
Parameters:
- path: (required) The path of the file to modify (relative to the current working directory ${cwd})
- diff: (required) The search/replace blocks defining the changes.
- diff: (required) The search/replace block defining the changes.
- start_line: (required) The line number where the search block starts (inclusive).
- end_line: (required) The line number where the search block ends (inclusive).
Format:
1. First line must be the file path
2. Followed by search/replace blocks:
\`\`\`
<<<<<<< SEARCH
[exact content to find including whitespace]
=======
[new content to replace with]
>>>>>>> REPLACE
\`\`\`
Diff format:
\`\`\`
<<<<<<< SEARCH
[exact content to find including whitespace]
=======
[new content to replace with]
>>>>>>> REPLACE
\`\`\`
Example:
Original file:
\`\`\`
def calculate_total(items):
total = 0
for item in items:
total += item
return total
1 | def calculate_total(items):
2 | total = 0
3 | for item in items:
4 | total += item
5 | return total
\`\`\`
Search/Replace content:
1. Search/replace a specific chunk of code:
\`\`\`
main.py
<apply_diff>
<path>File path here</path>
<diff>
<<<<<<< SEARCH
def calculate_total(items):
total = 0
for item in items:
total += item
return total
=======
def calculate_total(items):
"""Calculate total with 10% markup"""
return sum(item * 1.1 for item in items)
>>>>>>> REPLACE
</diff>
<start_line>2</start_line>
<end_line>5</end_line>
</apply_diff>
\`\`\`
Usage:
Result:
\`\`\`
1 | def calculate_total(items):
2 | """Calculate total with 10% markup"""
3 | return sum(item * 1.1 for item in items)
\`\`\`
2. Insert code at a specific line (start_line and end_line must be the same, and the content gets inserted before whatever is currently at that line):
\`\`\`
<apply_diff>
<path>File path here</path>
<diff>
Your search/replace content here
<<<<<<< SEARCH
=======
"""TODO: Write a test for this"""
>>>>>>> REPLACE
</diff>
</apply_diff>`
<start_line>2</start_line>
<end_line>2</end_line>
</apply_diff>
\`\`\`
Result:
\`\`\`
1 | def calculate_total(items):
2 | """TODO: Write a test for this"""
3 | """Calculate total with 10% markup"""
4 | return sum(item * 1.1 for item in items)
\`\`\`
3. Delete code at a specific line range:
\`\`\`
<apply_diff>
<path>File path here</path>
<diff>
<<<<<<< SEARCH
total = 0
for item in items:
total += item
return total
=======
>>>>>>> REPLACE
</diff>
<start_line>2</start_line>
<end_line>5</end_line>
</apply_diff>
\`\`\`
Result:
\`\`\`
1 | def calculate_total(items):
\`\`\`
`
}
applyDiff(originalContent: string, diffContent: string): string | false {
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult {
// Extract the search and replace blocks
const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/);
const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/);
if (!match) {
return false;
const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include both SEARCH and REPLACE sections with correct markers` : '';
return {
success: false,
error: `Invalid diff format - missing required SEARCH/REPLACE sections${debugInfo}`
};
}
const [_, searchContent, replaceContent] = match;
let [_, searchContent, replaceContent] = match;
// Detect line ending from original content
const lineEnding = originalContent.includes('\r\n') ? '\r\n' : '\n';
// Strip line numbers from search and replace content if every line starts with a line number
const hasLineNumbers = (content: string) => {
const lines = content.split(/\r?\n/);
return lines.length > 0 && lines.every(line => /^\s*\d+\s+\|(?!\|)/.test(line));
};
if (hasLineNumbers(searchContent) && hasLineNumbers(replaceContent)) {
const stripLineNumbers = (content: string) => {
return content.replace(/^\s*\d+\s+\|(?!\|)/gm, '');
};
searchContent = stripLineNumbers(searchContent);
replaceContent = stripLineNumbers(replaceContent);
}
// Split content into lines, handling both \n and \r\n
const searchLines = searchContent.split(/\r?\n/);
const replaceLines = replaceContent.split(/\r?\n/);
const searchLines = searchContent === '' ? [] : searchContent.split(/\r?\n/);
const replaceLines = replaceContent === '' ? [] : replaceContent.split(/\r?\n/);
const originalLines = originalContent.split(/\r?\n/);
// Find the search content in the original
// First try exact line range if provided
let matchIndex = -1;
let bestMatchScore = 0;
let bestMatchContent = "";
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
let found = true;
if (startLine && endLine) {
// Convert to 0-based index
const exactStartIndex = startLine - 1;
const exactEndIndex = endLine - 1;
if (exactStartIndex < 0 || exactEndIndex > originalLines.length || exactStartIndex > exactEndIndex) {
const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${originalLines.length}` : '';
// Log detailed debug information
console.log('Invalid Line Range Debug:', {
requestedRange: { start: startLine, end: endLine },
fileBounds: { start: 1, end: originalLines.length }
});
return {
success: false,
error: `Line range ${startLine}-${endLine} is invalid (file has ${originalLines.length} lines)${debugInfo}`,
};
}
// Check exact range first
const originalChunk = originalLines.slice(exactStartIndex, exactEndIndex + 1).join('\n');
const searchChunk = searchLines.join('\n');
for (let j = 0; j < searchLines.length; j++) {
const originalLine = originalLines[i + j];
const searchLine = searchLines[j];
// Compare lines after removing leading/trailing whitespace
if (originalLine.trim() !== searchLine.trim()) {
found = false;
break;
const similarity = getSimilarity(originalChunk, searchChunk);
if (similarity >= this.fuzzyThreshold) {
matchIndex = exactStartIndex;
bestMatchScore = similarity;
bestMatchContent = originalChunk;
}
}
// If no match found in exact range, try expanded range
if (matchIndex === -1) {
let searchStartIndex = 0;
let searchEndIndex = originalLines.length;
if (startLine || endLine) {
// Convert to 0-based index and add buffer
if (startLine) {
searchStartIndex = Math.max(0, startLine - (BUFFER_LINES + 1));
}
if (endLine) {
searchEndIndex = Math.min(originalLines.length, endLine + BUFFER_LINES);
}
}
if (found) {
matchIndex = i;
break;
// Find the search content in the expanded range using fuzzy matching
for (let i = searchStartIndex; i <= searchEndIndex - searchLines.length; i++) {
// Join the lines and calculate overall similarity
const originalChunk = originalLines.slice(i, i + searchLines.length).join('\n');
const searchChunk = searchLines.join('\n');
const similarity = getSimilarity(originalChunk, searchChunk);
if (similarity > bestMatchScore) {
bestMatchScore = similarity;
matchIndex = i;
bestMatchContent = originalChunk;
}
}
}
if (matchIndex === -1) {
return false;
// Require similarity to meet threshold
if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) {
const searchChunk = searchLines.join('\n');
const originalContentSection = startLine !== undefined && endLine !== undefined
? `\n\nOriginal Content:\n${addLineNumbers(
originalLines.slice(
Math.max(0, startLine - 1 - BUFFER_LINES),
Math.min(originalLines.length, endLine + BUFFER_LINES)
).join('\n'),
Math.max(1, startLine - BUFFER_LINES)
)}`
: `\n\nOriginal Content:\n${addLineNumbers(originalLines.join('\n'))}`;
const bestMatchSection = bestMatchContent
? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}`
: `\n\nBest Match Found:\n(no match)`;
const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine && endLine ? `lines ${startLine}-${endLine}` : 'start to end'}\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}` : '';
const lineRange = startLine || endLine ?
` at ${startLine ? `start: ${startLine}` : 'start'} to ${endLine ? `end: ${endLine}` : 'end'}` : '';
return {
success: false,
error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)${debugInfo}`
};
}
// Get the matched lines from the original content
const matchedLines = originalLines.slice(matchIndex, matchIndex + searchLines.length);
@ -113,55 +312,45 @@ Your search/replace content here
const match = line.match(/^[\t ]*/);
return match ? match[0] : '';
});
// Get the exact indentation of each line in the search block
const searchIndents = searchLines.map(line => {
const match = line.match(/^[\t ]*/);
return match ? match[0] : '';
});
// Apply the replacement while preserving exact indentation
const indentedReplace = replaceLines.map((line, i) => {
// Get the corresponding original and search indentations
const originalIndent = originalIndents[Math.min(i, originalIndents.length - 1)];
const searchIndent = searchIndents[Math.min(i, searchIndents.length - 1)];
const indentedReplaceLines = replaceLines.map((line, i) => {
// Get the matched line's exact indentation
const matchedIndent = originalIndents[0] || '';
// Get the current line's indentation
// Get the current line's indentation relative to the search content
const currentIndentMatch = line.match(/^[\t ]*/);
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : '';
const searchBaseIndent = searchIndents[0] || '';
// Get the corresponding search line's indentation
const searchLineIndex = Math.min(i, searchLines.length - 1);
const searchLineIndent = searchIndents[searchLineIndex];
// Get the corresponding original line's indentation
const originalLineIndex = Math.min(i, originalIndents.length - 1);
const originalLineIndent = originalIndents[originalLineIndex];
// If this line has the same indentation as its corresponding search line,
// use the original indentation
if (currentIndent === searchLineIndent) {
return originalLineIndent + line.trim();
}
// Otherwise, preserve the original indentation structure
const indentChar = originalLineIndent.charAt(0) || '\t';
const indentLevel = Math.floor(originalLineIndent.length / indentChar.length);
// Calculate the relative indentation from the search line
const searchLevel = Math.floor(searchLineIndent.length / indentChar.length);
const currentLevel = Math.floor(currentIndent.length / indentChar.length);
const relativeLevel = currentLevel - searchLevel;
// Apply the relative indentation to the original level
const targetLevel = Math.max(0, indentLevel + relativeLevel);
return indentChar.repeat(targetLevel) + line.trim();
// Calculate the relative indentation level
const searchBaseLevel = searchBaseIndent.length;
const currentLevel = currentIndent.length;
const relativeLevel = currentLevel - searchBaseLevel;
// If relative level is negative, remove indentation from matched indent
// If positive, add to matched indent
const finalIndent = relativeLevel < 0
? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel))
: matchedIndent + currentIndent.slice(searchBaseLevel);
return finalIndent + line.trim();
});
// Construct the final content
const beforeMatch = originalLines.slice(0, matchIndex);
const afterMatch = originalLines.slice(matchIndex + searchLines.length);
return [...beforeMatch, ...indentedReplace, ...afterMatch].join(lineEnding);
const finalContent = [...beforeMatch, ...indentedReplaceLines, ...afterMatch].join(lineEnding);
return {
success: true,
content: finalContent
};
}
}
}

View file

@ -1,5 +1,5 @@
import { applyPatch } from "diff"
import { DiffStrategy } from "../types"
import { DiffStrategy, DiffResult } from "../types"
export class UnifiedDiffStrategy implements DiffStrategy {
getToolDescription(cwd: string): string {
@ -108,7 +108,30 @@ Your diff here
</apply_diff>`
}
applyDiff(originalContent: string, diffContent: string): string | false {
return applyPatch(originalContent, diffContent) as string | false
applyDiff(originalContent: string, diffContent: string): DiffResult {
try {
const result = applyPatch(originalContent, diffContent)
if (result === false) {
return {
success: false,
error: "Failed to apply unified diff - patch rejected",
details: {
searchContent: diffContent
}
}
}
return {
success: true,
content: result
}
} catch (error) {
return {
success: false,
error: `Error applying unified diff: ${error.message}`,
details: {
searchContent: diffContent
}
}
}
}
}

View file

@ -1,7 +1,23 @@
/**
* Interface for implementing different diff strategies
*/
export type DiffResult =
| { success: true; content: string }
| { success: false; error: string; details?: {
similarity?: number;
threshold?: number;
matchedRange?: { start: number; end: number };
searchContent?: string;
bestMatch?: string;
}};
export interface DiffStrategy {
/**
* Whether to enable detailed debug logging
*/
debugEnabled?: boolean;
/**
* Get the tool description for this diff strategy
* @param cwd The current working directory
@ -13,7 +29,9 @@ export interface DiffStrategy {
* Apply a diff to the original content
* @param originalContent The original file content
* @param diffContent The diff content in the strategy's format
* @returns The new content after applying the diff, or false if the diff could not be applied
* @param startLine Optional line number where the search block starts. If not provided, searches the entire file.
* @param endLine Optional line number where the search block ends. If not provided, searches the entire file.
* @returns A DiffResult object containing either the successful result or error details
*/
applyDiff(originalContent: string, diffContent: string): string | false
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult
}

View file

@ -636,6 +636,8 @@ npm run build
5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object.
IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and alwaysAllow=[].
\`\`\`json
{
"mcpServers": {
@ -664,7 +666,7 @@ The user may ask to add tools or resources that may make sense to add to an exis
.getServers()
.map((server) => server.name)
.join(", ") || "(None running currently)"
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file to make changes to the files.
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file ${diffStrategy ? "or apply_diff " : ""}to make changes to the files.
However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server.
@ -702,10 +704,9 @@ RULES
- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory && then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) && (command, in this case npm install)\`.
- When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes.
- When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser.
${diffStrategy ? "- Prefer to use apply_diff over write_to_file when making changes to existing files, particularly when editing files more than 200 lines of code, as it allows you to apply specific modifications based on a set of changes provided in a diff. This is particularly useful when you need to make targeted edits or updates to a file without overwriting the entire content." : ""}
${diffStrategy ? "- You should use apply_diff instead of write_to_file when making changes to existing files since it is much faster and easier to apply a diff than to write the entire file again. Only use write_to_file to edit files when apply_diff has failed repeatedly to apply the diff." : "- When you want to modify a file, use the write_to_file tool directly with the desired content. You do not need to display the content before using the tool."}
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.
- When you want to modify a file, use the write_to_file tool directly with the desired content. You do not need to display the content before using the tool.
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves.
- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you.

View file

@ -22,7 +22,7 @@ import { Cline } from "../Cline"
import { openMention } from "../mentions"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { playSound, setSoundEnabled } from "../../utils/sound"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@ -66,9 +66,12 @@ type GlobalStateKey =
| "openRouterUseMiddleOutTransform"
| "allowedCommands"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "isInteractiveMode"
| "browserPort"
| "debugDiffEnabled"
| "alwaysAllowMcp"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
@ -137,6 +140,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("Resolving webview view")
this.view = webviewView
// Initialize sound enabled state
this.getState().then(({ soundEnabled }) => {
setSoundEnabled(soundEnabled ?? false)
})
webviewView.webview.options = {
// Allow scripts in the webview
enableScripts: true,
@ -214,6 +222,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled,
isInteractiveMode,
browserPort,
debugDiffEnabled,
} = await this.getState()
this.cline = new Cline(
@ -221,6 +230,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customInstructions,
diffEnabled,
debugDiffEnabled,
isInteractiveMode,
browserPort,
task,
@ -234,8 +244,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customInstructions,
diffEnabled,
debugDiffEnabled,
isInteractiveMode,
browserPort,
browserPort
} = await this.getState()
this.cline = new Cline(
@ -243,6 +254,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customInstructions,
diffEnabled,
debugDiffEnabled,
isInteractiveMode,
browserPort,
undefined,
@ -466,6 +478,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("alwaysAllowBrowser", message.bool ?? undefined)
await this.postStateToWebview()
break
case "alwaysAllowMcp":
await this.updateGlobalState("alwaysAllowMcp", message.bool)
await this.postStateToWebview()
break
case "askResponse":
this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
break
@ -560,6 +576,29 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "toggleToolAlwaysAllow": {
try {
await this.mcpHub?.toggleToolAlwaysAllow(
message.serverName!,
message.toolName!,
message.alwaysAllow!
)
} catch (error) {
console.error(`Failed to toggle auto-approve for tool ${message.toolName}:`, error)
}
break
}
case "toggleMcpServer": {
try {
await this.mcpHub?.toggleServerDisabled(
message.serverName!,
message.disabled!
)
} catch (error) {
console.error(`Failed to toggle MCP server ${message.serverName}:`, error)
}
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":
@ -574,6 +613,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
setSoundEnabled(soundEnabled) // Add this line to update the sound utility
await this.postStateToWebview()
break
case "soundVolume":
const soundVolume = message.value ?? 0.5
await this.updateGlobalState("soundVolume", soundVolume)
setSoundVolume(soundVolume)
await this.postStateToWebview()
break
case "diffEnabled":
const diffEnabled = message.bool ?? true
await this.updateGlobalState("diffEnabled", diffEnabled)
@ -587,6 +632,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("browserPort", message.text ?? "7333")
await this.postStateToWebview()
break
case "debugDiffEnabled":
const debugDiffEnabled = message.bool ?? false
await this.updateGlobalState("debugDiffEnabled", debugDiffEnabled)
await this.postStateToWebview()
break
}
},
null,
@ -910,11 +960,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
alwaysAllowMcp,
soundEnabled,
diffEnabled,
debugDiffEnabled,
taskHistory,
isInteractiveMode,
browserPort,
soundVolume,
} = await this.getState()
const allowedCommands = vscode.workspace
@ -929,6 +982,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowWrite: alwaysAllowWrite ?? false,
alwaysAllowExecute: alwaysAllowExecute ?? false,
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
alwaysAllowMcp: alwaysAllowMcp ?? false,
uriScheme: vscode.env.uriScheme,
clineMessages: this.cline?.clineMessages || [],
taskHistory: (taskHistory || [])
@ -936,10 +990,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
.sort((a, b) => b.ts - a.ts),
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? false,
debugDiffEnabled: debugDiffEnabled ?? false,
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
allowedCommands,
isInteractiveMode: isInteractiveMode ?? false,
browserPort: browserPort ?? "7333",
soundVolume: soundVolume ?? 0.5,
}
}
@ -1027,12 +1083,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
alwaysAllowMcp,
taskHistory,
allowedCommands,
soundEnabled,
diffEnabled,
isInteractiveMode,
browserPort,
debugDiffEnabled,
soundVolume,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@ -1065,12 +1124,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowMcp") 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>,
this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
this.getGlobalState("isInteractiveMode") as Promise<boolean | undefined>,
this.getGlobalState("browserPort") as Promise<string | undefined>
this.getGlobalState("browserPort") as Promise<string | undefined>,
this.getGlobalState("debugDiffEnabled") as Promise<boolean | undefined>,
this.getGlobalState("soundVolume") as Promise<number | undefined>,
])
let apiProvider: ApiProvider
@ -1121,12 +1183,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowWrite: alwaysAllowWrite ?? false,
alwaysAllowExecute: alwaysAllowExecute ?? false,
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
alwaysAllowMcp: alwaysAllowMcp ?? false,
taskHistory,
allowedCommands,
soundEnabled,
diffEnabled,
isInteractiveMode: isInteractiveMode ?? false,
browserPort: browserPort ?? "7333"
browserPort: browserPort ?? "7333",
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? false,
debugDiffEnabled: debugDiffEnabled ?? false,
soundVolume,
}
}

View file

@ -0,0 +1,66 @@
import { detectCodeOmission } from '../detect-omission'
describe('detectCodeOmission', () => {
const originalContent = `function example() {
// Some code
const x = 1;
const y = 2;
return x + y;
}`
it('should detect square bracket line range omission', () => {
const newContent = `[Previous content from line 1-305 remains exactly the same]
const z = 3;`
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
})
it('should detect single-line comment omission', () => {
const newContent = `// Lines 1-50 remain unchanged
const z = 3;`
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
})
it('should detect multi-line comment omission', () => {
const newContent = `/* Previous content remains the same */
const z = 3;`
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
})
it('should detect HTML-style comment omission', () => {
const newContent = `<!-- Existing content unchanged -->
const z = 3;`
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
})
it('should detect JSX-style comment omission', () => {
const newContent = `{/* Rest of the code remains the same */}
const z = 3;`
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
})
it('should detect Python-style comment omission', () => {
const newContent = `# Previous content remains unchanged
const z = 3;`
expect(detectCodeOmission(originalContent, newContent)).toBe(true)
})
it('should not detect regular comments without omission keywords', () => {
const newContent = `// Adding new functionality
const z = 3;`
expect(detectCodeOmission(originalContent, newContent)).toBe(false)
})
it('should not detect when comment is part of original content', () => {
const originalWithComment = `// Content remains unchanged
${originalContent}`
const newContent = `// Content remains unchanged
const z = 3;`
expect(detectCodeOmission(originalWithComment, newContent)).toBe(false)
})
it('should not detect code that happens to contain omission keywords', () => {
const newContent = `const remains = 'some value';
const unchanged = true;`
expect(detectCodeOmission(originalContent, newContent)).toBe(false)
})
})

View file

@ -7,7 +7,7 @@
export function detectCodeOmission(originalFileContent: string, newFileContent: string): boolean {
const originalLines = originalFileContent.split("\n")
const newLines = newFileContent.split("\n")
const omissionKeywords = ["remain", "remains", "unchanged", "rest", "previous", "existing", "..."]
const omissionKeywords = ["remain", "remains", "unchanged", "rest", "previous", "existing", "content", "same", "..."]
const commentPatterns = [
/^\s*\/\//, // Single-line comment for most languages
@ -15,6 +15,7 @@ export function detectCodeOmission(originalFileContent: string, newFileContent:
/^\s*\/\*/, // Multi-line comment opening
/^\s*{\s*\/\*/, // JSX comment opening
/^\s*<!--/, // HTML comment opening
/^\s*\[/, // Square bracket notation
]
for (const line of newLines) {

View file

@ -0,0 +1,32 @@
import { addLineNumbers } from '../extract-text';
describe('addLineNumbers', () => {
it('should add line numbers starting from 1 by default', () => {
const input = 'line 1\nline 2\nline 3';
const expected = '1 | line 1\n2 | line 2\n3 | line 3';
expect(addLineNumbers(input)).toBe(expected);
});
it('should add line numbers starting from specified line number', () => {
const input = 'line 1\nline 2\nline 3';
const expected = '10 | line 1\n11 | line 2\n12 | line 3';
expect(addLineNumbers(input, 10)).toBe(expected);
});
it('should handle empty content', () => {
expect(addLineNumbers('')).toBe('1 | ');
expect(addLineNumbers('', 5)).toBe('5 | ');
});
it('should handle single line content', () => {
expect(addLineNumbers('single line')).toBe('1 | single line');
expect(addLineNumbers('single line', 42)).toBe('42 | single line');
});
it('should pad line numbers based on the highest line number', () => {
const input = 'line 1\nline 2';
// When starting from 99, highest line will be 100, so needs 3 spaces padding
const expected = ' 99 | line 1\n100 | line 2';
expect(addLineNumbers(input, 99)).toBe(expected);
});
});

View file

@ -53,15 +53,12 @@ async function extractTextFromIPYNB(filePath: string): Promise<string> {
return addLineNumbers(extractedText)
}
export function addLineNumbers(content: string): string {
export function addLineNumbers(content: string, startLine: number = 1): string {
const lines = content.split('\n')
const maxLineNumberWidth = String(lines.length).length
const maxLineNumberWidth = String(startLine + lines.length - 1).length
return lines
.map((line, index) => {
const lineNumber = String(index + 1).padStart(maxLineNumberWidth, ' ')
const lineNumber = String(startLine + index).padStart(maxLineNumberWidth, ' ')
return `${lineNumber} | ${line}`
}).join('\n')
}
}

View file

@ -33,14 +33,18 @@ export type McpConnection = {
}
// StdioServerParameters
const AlwaysAllowSchema = z.array(z.string()).default([])
const StdioConfigSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
alwaysAllow: AlwaysAllowSchema.optional(),
disabled: z.boolean().optional()
})
const McpSettingsSchema = z.object({
mcpServers: z.record(StdioConfigSchema),
mcpServers: z.record(StdioConfigSchema)
})
export class McpHub {
@ -58,7 +62,10 @@ export class McpHub {
}
getServers(): McpServer[] {
return this.connections.map((conn) => conn.server)
// Only return enabled servers
return this.connections
.filter((conn) => !conn.server.disabled)
.map((conn) => conn.server)
}
async getMcpServersPath(): Promise<string> {
@ -114,9 +121,7 @@ export class McpHub {
return
}
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(result.data.mcpServers || {})
vscode.window.showInformationMessage("MCP servers updated")
} catch (error) {
console.error("Failed to process MCP settings change:", error)
}
@ -199,11 +204,13 @@ export class McpHub {
}
// valid schema
const parsedConfig = StdioConfigSchema.parse(config)
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "connecting",
disabled: parsedConfig.disabled,
},
client,
transport,
@ -285,7 +292,21 @@ export class McpHub {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
return response?.tools || []
// Get always allow settings
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const alwaysAllowConfig = config.mcpServers[serverName]?.alwaysAllow || []
// Mark tools as always allowed based on settings
const tools = (response?.tools || []).map(tool => ({
...tool,
alwaysAllow: alwaysAllowConfig.includes(tool.name)
}))
console.log(`[MCP] Fetched tools for ${serverName}:`, tools)
return tools
} catch (error) {
// console.error(`Failed to fetch tools for ${serverName}:`, error)
return []
@ -449,13 +470,89 @@ export class McpHub {
})
}
// Using server
// Public methods for server management
public async toggleServerDisabled(serverName: string, disabled: boolean): Promise<void> {
let settingsPath: string
try {
settingsPath = await this.getMcpSettingsFilePath()
// Ensure the settings file exists and is accessible
try {
await fs.access(settingsPath)
} catch (error) {
console.error('Settings file not accessible:', error)
throw new Error('Settings file not accessible')
}
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
// Validate the config structure
if (!config || typeof config !== 'object') {
throw new Error('Invalid config structure')
}
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
config.mcpServers = {}
}
if (config.mcpServers[serverName]) {
// Create a new server config object to ensure clean structure
const serverConfig = {
...config.mcpServers[serverName],
disabled
}
// Ensure required fields exist
if (!serverConfig.alwaysAllow) {
serverConfig.alwaysAllow = []
}
config.mcpServers[serverName] = serverConfig
// Write the entire config back
const updatedConfig = {
mcpServers: config.mcpServers
}
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
const connection = this.connections.find(conn => conn.server.name === serverName)
if (connection) {
try {
connection.server.disabled = disabled
// Only refresh capabilities if connected
if (connection.server.status === "connected") {
connection.server.tools = await this.fetchToolsList(serverName)
connection.server.resources = await this.fetchResourcesList(serverName)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName)
}
} catch (error) {
console.error(`Failed to refresh capabilities for ${serverName}:`, error)
}
}
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
console.error("Failed to update server disabled state:", error)
if (error instanceof Error) {
console.error("Error details:", error.message, error.stack)
}
vscode.window.showErrorMessage(`Failed to update server state: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
}
async readResource(serverName: string, uri: string): Promise<McpResourceResponse> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`No connection found for server: ${serverName}`)
}
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled`)
}
return await connection.client.request(
{
method: "resources/read",
@ -478,6 +575,10 @@ export class McpHub {
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
)
}
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
}
return await connection.client.request(
{
method: "tools/call",
@ -490,6 +591,45 @@ export class McpHub {
)
}
async toggleToolAlwaysAllow(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
// Initialize alwaysAllow if it doesn't exist
if (!config.mcpServers[serverName].alwaysAllow) {
config.mcpServers[serverName].alwaysAllow = []
}
const alwaysAllow = config.mcpServers[serverName].alwaysAllow
const toolIndex = alwaysAllow.indexOf(toolName)
if (shouldAllow && toolIndex === -1) {
// Add tool to always allow list
alwaysAllow.push(toolName)
} else if (!shouldAllow && toolIndex !== -1) {
// Remove tool from always allow list
alwaysAllow.splice(toolIndex, 1)
}
// Write updated config back to file
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
// Update the tools list to reflect the change
const connection = this.connections.find(conn => conn.server.name === serverName)
if (connection) {
connection.server.tools = await this.fetchToolsList(serverName)
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
console.error("Failed to update always allow settings:", error)
vscode.window.showErrorMessage("Failed to update always allow settings")
throw error // Re-throw to ensure the error is properly handled
}
}
async dispose(): Promise<void> {
this.removeAllFileWatchers()
for (const connection of this.connections) {

View file

@ -0,0 +1,290 @@
import type { McpHub as McpHubType } from '../McpHub'
import type { ClineProvider } from '../../../core/webview/ClineProvider'
import type { ExtensionContext, Uri } from 'vscode'
import type { McpConnection } from '../McpHub'
const vscode = require('vscode')
const fs = require('fs/promises')
const { McpHub } = require('../McpHub')
jest.mock('vscode')
jest.mock('fs/promises')
jest.mock('../../../core/webview/ClineProvider')
describe('McpHub', () => {
let mcpHub: McpHubType
let mockProvider: Partial<ClineProvider>
const mockSettingsPath = '/mock/settings/path/cline_mcp_settings.json'
beforeEach(() => {
jest.clearAllMocks()
const mockUri: Uri = {
scheme: 'file',
authority: '',
path: '/test/path',
query: '',
fragment: '',
fsPath: '/test/path',
with: jest.fn(),
toJSON: jest.fn()
}
mockProvider = {
ensureSettingsDirectoryExists: jest.fn().mockResolvedValue('/mock/settings/path'),
ensureMcpServersDirectoryExists: jest.fn().mockResolvedValue('/mock/settings/path'),
postMessageToWebview: jest.fn(),
context: {
subscriptions: [],
workspaceState: {} as any,
globalState: {} as any,
secrets: {} as any,
extensionUri: mockUri,
extensionPath: '/test/path',
storagePath: '/test/storage',
globalStoragePath: '/test/global-storage',
environmentVariableCollection: {} as any,
extension: {
id: 'test-extension',
extensionUri: mockUri,
extensionPath: '/test/path',
extensionKind: 1,
isActive: true,
packageJSON: {
version: '1.0.0'
},
activate: jest.fn(),
exports: undefined
} as any,
asAbsolutePath: (path: string) => path,
storageUri: mockUri,
globalStorageUri: mockUri,
logUri: mockUri,
extensionMode: 1,
logPath: '/test/path',
languageModelAccessInformation: {} as any
} as ExtensionContext
}
// Mock fs.readFile for initial settings
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js'],
alwaysAllow: ['allowed-tool']
}
}
}))
mcpHub = new McpHub(mockProvider as ClineProvider)
})
describe('toggleToolAlwaysAllow', () => {
it('should add tool to always allow list when enabling', async () => {
const mockConfig = {
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js'],
alwaysAllow: []
}
}
}
// Mock reading initial config
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
await mcpHub.toggleToolAlwaysAllow('test-server', 'new-tool', true)
// Verify the config was updated correctly
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
const writtenConfig = JSON.parse(writeCall[1])
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toContain('new-tool')
})
it('should remove tool from always allow list when disabling', async () => {
const mockConfig = {
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js'],
alwaysAllow: ['existing-tool']
}
}
}
// Mock reading initial config
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
await mcpHub.toggleToolAlwaysAllow('test-server', 'existing-tool', false)
// Verify the config was updated correctly
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
const writtenConfig = JSON.parse(writeCall[1])
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).not.toContain('existing-tool')
})
it('should initialize alwaysAllow if it does not exist', async () => {
const mockConfig = {
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js']
}
}
}
// Mock reading initial config
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
await mcpHub.toggleToolAlwaysAllow('test-server', 'new-tool', true)
// Verify the config was updated with initialized alwaysAllow
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
const writtenConfig = JSON.parse(writeCall[1])
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toBeDefined()
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toContain('new-tool')
})
})
describe('server disabled state', () => {
it('should toggle server disabled state', async () => {
const mockConfig = {
mcpServers: {
'test-server': {
command: 'node',
args: ['test.js'],
disabled: false
}
}
}
// Mock reading initial config
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
await mcpHub.toggleServerDisabled('test-server', true)
// Verify the config was updated correctly
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
const writtenConfig = JSON.parse(writeCall[1])
expect(writtenConfig.mcpServers['test-server'].disabled).toBe(true)
})
it('should filter out disabled servers from getServers', () => {
const mockConnections: McpConnection[] = [
{
server: {
name: 'enabled-server',
config: '{}',
status: 'connected',
disabled: false
},
client: {} as any,
transport: {} as any
},
{
server: {
name: 'disabled-server',
config: '{}',
status: 'connected',
disabled: true
},
client: {} as any,
transport: {} as any
}
]
mcpHub.connections = mockConnections
const servers = mcpHub.getServers()
expect(servers.length).toBe(1)
expect(servers[0].name).toBe('enabled-server')
})
it('should prevent calling tools on disabled servers', async () => {
const mockConnection: McpConnection = {
server: {
name: 'disabled-server',
config: '{}',
status: 'connected',
disabled: true
},
client: {
request: jest.fn().mockResolvedValue({ result: 'success' })
} as any,
transport: {} as any
}
mcpHub.connections = [mockConnection]
await expect(mcpHub.callTool('disabled-server', 'some-tool', {}))
.rejects
.toThrow('Server "disabled-server" is disabled and cannot be used')
})
it('should prevent reading resources from disabled servers', async () => {
const mockConnection: McpConnection = {
server: {
name: 'disabled-server',
config: '{}',
status: 'connected',
disabled: true
},
client: {
request: jest.fn()
} as any,
transport: {} as any
}
mcpHub.connections = [mockConnection]
await expect(mcpHub.readResource('disabled-server', 'some/uri'))
.rejects
.toThrow('Server "disabled-server" is disabled')
})
})
describe('callTool', () => {
it('should execute tool successfully', async () => {
// Mock the connection with a minimal client implementation
const mockConnection: McpConnection = {
server: {
name: 'test-server',
config: JSON.stringify({}),
status: 'connected' as const
},
client: {
request: jest.fn().mockResolvedValue({ result: 'success' })
} as any,
transport: {
start: jest.fn(),
close: jest.fn(),
stderr: { on: jest.fn() }
} as any
}
mcpHub.connections = [mockConnection]
await mcpHub.callTool('test-server', 'some-tool', {})
// Verify the request was made with correct parameters
expect(mockConnection.client.request).toHaveBeenCalledWith(
{
method: 'tools/call',
params: {
name: 'some-tool',
arguments: {}
}
},
expect.any(Object)
)
})
it('should throw error if server not found', async () => {
await expect(mcpHub.callTool('non-existent-server', 'some-tool', {}))
.rejects
.toThrow('No connection found for server: non-existent-server')
})
})
})

View file

@ -47,12 +47,15 @@ export interface ExtensionState {
alwaysAllowWrite?: boolean
alwaysAllowExecute?: boolean
alwaysAllowBrowser?: boolean
alwaysAllowMcp?: boolean
uriScheme?: string
allowedCommands?: string[]
soundEnabled?: boolean
soundVolume?: number
diffEnabled?: boolean
isInteractiveMode?: boolean
browserPort?: string
debugDiffEnabled?: boolean
}
export interface ClineMessage {

View file

@ -29,20 +29,31 @@ export interface WebviewMessage {
| "cancelTask"
| "refreshOpenRouterModels"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "playSound"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "isInteractiveMode"
| "browserPort"
| "debugDiffEnabled"
| "openMcpSettings"
| "restartMcpServer"
| "toggleToolAlwaysAllow"
| "toggleMcpServer"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
apiConfiguration?: ApiConfiguration
images?: string[]
bool?: boolean
value?: number
commands?: string[]
audioType?: AudioType
// For toggleToolAutoApprove
serverName?: string
toolName?: string
alwaysAllow?: boolean
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"

View file

@ -6,12 +6,14 @@ export type McpServer = {
tools?: McpTool[]
resources?: McpResource[]
resourceTemplates?: McpResourceTemplate[]
disabled?: boolean
}
export type McpTool = {
name: string
description?: string
inputSchema?: object
alwaysAllow?: boolean
}
export type McpResource = {

View file

@ -21,6 +21,7 @@ export const isWAV = (filepath: string): boolean => {
}
let isSoundEnabled = false
let volume = .5
/**
* Set sound configuration
@ -30,6 +31,14 @@ export const setSoundEnabled = (enabled: boolean): void => {
isSoundEnabled = enabled
}
/**
* Set sound volume
* @param volume number
*/
export const setSoundVolume = (newVolume: number): void => {
volume = newVolume
}
/**
* Play a sound file
* @param filepath string
@ -54,11 +63,9 @@ export const playSound = (filepath: string): void => {
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")
}
const sound = require("sound-play")
sound.play(filepath, volume).catch(() => {
throw new Error("Failed to play sound effect")
})
lastPlayedTime = currentTime

View file

@ -20,6 +20,6 @@
"useDefineForClassFields": true,
"useUnknownInCatchVariables": false
},
"include": ["src/**/*", "scripts/**/*"],
"include": ["src/**/*", "scripts/**/*", ".changeset/**/*"],
"exclude": ["node_modules", ".vscode-test", "webview-ui"]
}

View file

@ -34,7 +34,8 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/vscode-webview": "^1.57.5"
"@types/vscode-webview": "^1.57.5",
"eslint": "^8.57.0"
}
},
"node_modules/@adobe/css-tools": {

View file

@ -30,8 +30,9 @@
"scripts": {
"start": "react-scripts start",
"build": "node ./scripts/build-react-no-split.js",
"test": "react-scripts test",
"eject": "react-scripts eject"
"test": "react-scripts test --watchAll=false",
"eject": "react-scripts eject",
"lint": "eslint src --ext ts,tsx"
},
"eslintConfig": {
"extends": [
@ -53,11 +54,12 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/vscode-webview": "^1.57.5"
"@types/vscode-webview": "^1.57.5",
"eslint": "^8.57.0"
},
"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)/)"
"/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|@vscode/webview-ui-toolkit|@microsoft/fast-react-wrapper|@microsoft/fast-element|@microsoft/fast-foundation|@microsoft/fast-web-utilities|exenv-es6)/)"
],
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"

View file

@ -813,14 +813,19 @@ export const ChatRowContent = ({
{useMcpServer.type === "use_mcp_tool" && (
<>
<McpToolRow
tool={{
name: useMcpServer.toolName || "",
description:
server?.tools?.find((tool) => tool.name === useMcpServer.toolName)
?.description || "",
}}
/>
<div onClick={(e) => e.stopPropagation()}>
<McpToolRow
tool={{
name: useMcpServer.toolName || "",
description:
server?.tools?.find((tool) => tool.name === useMcpServer.toolName)
?.description || "",
alwaysAllow: server?.tools?.find((tool) => tool.name === useMcpServer.toolName)
?.alwaysAllow || false,
}}
serverName={useMcpServer.serverName}
/>
</div>
{useMcpServer.arguments && useMcpServer.arguments !== "{}" && (
<div style={{ marginTop: "8px" }}>
<div

View file

@ -11,6 +11,7 @@ import {
ClineSayTool,
ExtensionMessage,
} from "../../../../src/shared/ExtensionMessage"
import { McpServer, McpTool } from "../../../../src/shared/mcp"
import { findLast } from "../../../../src/shared/array"
import { combineApiRequests } from "../../../../src/shared/combineApiRequests"
import { combineCommandSequences } from "../../../../src/shared/combineCommandSequences"
@ -36,7 +37,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, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, allowedCommands } = useExtensionState()
const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, 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)
@ -63,7 +64,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
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
@ -74,12 +74,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
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.
@ -90,7 +84,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const isPartial = lastMessage.partial === true
switch (lastMessage.ask) {
case "api_req_failed":
playSoundOnMessage("progress_loop")
playSound("progress_loop")
setTextAreaDisabled(true)
setClineAsk("api_req_failed")
setEnableButtons(true)
@ -98,7 +92,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Start New Task")
break
case "mistake_limit_reached":
playSoundOnMessage("progress_loop")
playSound("progress_loop")
setTextAreaDisabled(false)
setClineAsk("mistake_limit_reached")
setEnableButtons(true)
@ -106,7 +100,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Start New Task")
break
case "followup":
playSoundOnMessage("notification")
setTextAreaDisabled(isPartial)
setClineAsk("followup")
setEnableButtons(isPartial)
@ -114,7 +107,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// setSecondaryButtonText(undefined)
break
case "tool":
playSoundOnMessage("notification")
if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial)
setClineAsk("tool")
setEnableButtons(!isPartial)
@ -133,7 +128,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
break
case "browser_action_launch":
playSoundOnMessage("notification")
if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial)
setClineAsk("browser_action_launch")
setEnableButtons(!isPartial)
@ -141,7 +138,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Reject")
break
case "command":
playSoundOnMessage("notification")
if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial)
setClineAsk("command")
setEnableButtons(!isPartial)
@ -149,7 +148,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Reject")
break
case "command_output":
playSoundOnMessage("notification")
setTextAreaDisabled(false)
setClineAsk("command_output")
setEnableButtons(true)
@ -165,7 +163,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")
playSound("celebration")
setTextAreaDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
@ -173,7 +171,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText(undefined)
break
case "resume_task":
playSoundOnMessage("notification")
setTextAreaDisabled(false)
setClineAsk("resume_task")
setEnableButtons(true)
@ -182,7 +179,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")
playSound("celebration")
setTextAreaDisabled(false)
setClineAsk("resume_completed_task")
setEnableButtons(true)
@ -481,36 +478,122 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
return true
})
}, [modifiedMessages])
useEffect(() => {
if (isStreaming) {
// Set to true once any request has started
setHasStarted(true)
const isReadOnlyToolAction = useCallback((message: ClineMessage | undefined) => {
if (message?.type === "ask") {
if (!message.text) {
return true
}
const tool = JSON.parse(message.text)
return ["readFile", "listFiles", "listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool)
}
return false
}, [])
const isWriteToolAction = useCallback((message: ClineMessage | undefined) => {
if (message?.type === "ask") {
if (!message.text) {
return true
}
const tool = JSON.parse(message.text)
return ["editedExistingFile", "appliedDiff", "newFileCreated"].includes(tool.tool)
}
return false
}, [])
const isMcpToolAlwaysAllowed = useCallback((message: ClineMessage | undefined) => {
if (message?.type === "ask" && message.ask === "use_mcp_server") {
if (!message.text) {
return true
}
const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string }
if (mcpServerUse.type === "use_mcp_tool") {
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
return tool?.alwaysAllow || false
}
}
return false
}, [mcpServers])
const isAllowedCommand = useCallback((message: ClineMessage | undefined) => {
if (message?.type === "ask") {
const command = message.text
if (!command) {
return true
}
// 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
}, [allowedCommands])
const isAutoApproved = useCallback(
(message: ClineMessage | undefined) => {
if (!message || message.type !== "ask") return false
return (
(alwaysAllowBrowser && message.ask === "browser_action_launch") ||
(alwaysAllowReadOnly && message.ask === "tool" && isReadOnlyToolAction(message)) ||
(alwaysAllowWrite && message.ask === "tool" && isWriteToolAction(message)) ||
(alwaysAllowExecute && message.ask === "command" && isAllowedCommand(message)) ||
(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message))
)
},
[
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowMcp,
isReadOnlyToolAction,
isWriteToolAction,
isAllowedCommand,
isMcpToolAlwaysAllowed
]
)
useEffect(() => {
// 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
// Don't play sounds for auto-approved actions
if (!isAutoApproved(lastMessage)) {
switch (lastMessage.ask) {
case "api_req_failed":
case "mistake_limit_reached":
playSound("progress_loop")
break
case "followup":
if (!lastMessage.partial) {
playSound("notification")
}
break
case "tool":
case "browser_action_launch":
case "resume_task":
case "use_mcp_server":
playSound("notification")
break
case "completion_result":
case "resume_completed_task":
playSound("celebration")
break
}
}
}
}
// Update previous value
setWasStreaming(isStreaming)
}, [isStreaming, lastMessage, wasStreaming])
}, [isStreaming, lastMessage, wasStreaming, isAutoApproved])
const isBrowserSessionMessage = (message: ClineMessage): boolean => {
// which of visible messages are browser session messages, see above
@ -749,50 +832,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// 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", "appliedDiff", "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())
) {
if (isAutoApproved(lastMessage)) {
handlePrimaryButtonClick()
}
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, messages, allowedCommands])
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, messages, allowedCommands, mcpServers, isAutoApproved, lastMessage])
return (
<div

View file

@ -547,3 +547,247 @@ describe('ChatView - Auto Approval Tests', () => {
})
})
})
describe('ChatView - Sound Playing Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('does not play sound for auto-approved browser actions', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task and streaming
mockPostMessage({
alwaysAllowBrowser: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'say',
say: 'api_req_started',
ts: Date.now() - 1000,
text: JSON.stringify({}),
partial: true
}
]
})
// Then send the browser action ask message (streaming finished)
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
}
]
})
// Verify no sound was played
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'playSound',
audioType: expect.any(String)
})
})
it('plays notification sound for non-auto-approved browser actions', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task and streaming
mockPostMessage({
alwaysAllowBrowser: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'say',
say: 'api_req_started',
ts: Date.now() - 1000,
text: JSON.stringify({}),
partial: true
}
]
})
// Then send the browser action ask message (streaming finished)
mockPostMessage({
alwaysAllowBrowser: false,
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
}
]
})
// Verify notification sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'playSound',
audioType: 'notification'
})
})
})
it('plays celebration sound for completion results', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task and streaming
mockPostMessage({
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'say',
say: 'api_req_started',
ts: Date.now() - 1000,
text: JSON.stringify({}),
partial: true
}
]
})
// Then send the completion result message (streaming finished)
mockPostMessage({
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'completion_result',
ts: Date.now(),
text: 'Task completed successfully',
partial: false
}
]
})
// Verify celebration sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'playSound',
audioType: 'celebration'
})
})
})
it('plays progress_loop sound for api failures', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task and streaming
mockPostMessage({
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'say',
say: 'api_req_started',
ts: Date.now() - 1000,
text: JSON.stringify({}),
partial: true
}
]
})
// Then send the api failure message (streaming finished)
mockPostMessage({
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'api_req_failed',
ts: Date.now(),
text: 'API request failed',
partial: false
}
]
})
// Verify progress_loop sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'playSound',
audioType: 'progress_loop'
})
})
})
})

View file

@ -2,7 +2,7 @@ import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@v
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { Virtuoso } from "react-virtuoso"
import { memo, useMemo, useState, useEffect } from "react"
import React, { memo, useMemo, useState, useEffect } from "react"
import Fuse, { FuseResult } from "fuse.js"
import { formatLargeNumber } from "../../utils/format"
@ -82,30 +82,28 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
const taskHistorySearchResults = useMemo(() => {
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
results.sort((a, b) => {
// First apply search if needed
const searchResults = searchQuery ? results : presentableTasks;
// Then sort the results
return [...searchResults].sort((a, b) => {
switch (sortOption) {
case "oldest":
return a.ts - b.ts
return (a.ts || 0) - (b.ts || 0);
case "mostExpensive":
return (b.totalCost || 0) - (a.totalCost || 0)
return (b.totalCost || 0) - (a.totalCost || 0);
case "mostTokens":
return (
(b.tokensIn || 0) +
(b.tokensOut || 0) +
(b.cacheWrites || 0) +
(b.cacheReads || 0) -
((a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0))
)
const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0);
const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0);
return bTokens - aTokens;
case "mostRelevant":
// NOTE: you must never sort directly on object since it will cause members to be reordered
return searchQuery ? 0 : b.ts - a.ts // Keep fuse order if searching, otherwise sort by newest
// Keep fuse order if searching, otherwise sort by newest
return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0);
case "newest":
default:
return b.ts - a.ts
return (b.ts || 0) - (a.ts || 0);
}
})
return results
});
}, [presentableTasks, searchQuery, fuse, sortOption])
return (
@ -227,9 +225,16 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
overflowY: "scroll",
}}
data={taskHistorySearchResults}
data-testid="virtuoso-container"
components={{
List: React.forwardRef((props, ref) => (
<div {...props} ref={ref} data-testid="virtuoso-item-list" />
))
}}
itemContent={(index, item) => (
<div
key={item.id}
data-testid={`task-item-${item.id}`}
className="history-item"
style={{
cursor: "pointer",
@ -263,23 +268,23 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{formatDate(item.ts)}
</span>
<div style={{ display: "flex", gap: "4px" }}>
<VSCodeButton
appearance="icon"
title="Copy Prompt"
className="copy-button"
onClick={(e) => handleCopyTask(e, item.task)}>
<span className="codicon codicon-copy"></span>
</VSCodeButton>
<VSCodeButton
appearance="icon"
title="Delete Task"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}
className="delete-button">
<span className="codicon codicon-trash"></span>
</VSCodeButton>
<button
title="Copy Prompt"
className="copy-button"
data-appearance="icon"
onClick={(e) => handleCopyTask(e, item.task)}>
<span className="codicon codicon-copy"></span>
</button>
<button
title="Delete Task"
className="delete-button"
data-appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}>
<span className="codicon codicon-trash"></span>
</button>
</div>
</div>
<div
@ -298,6 +303,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
/>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<div
data-testid="tokens-container"
style={{
display: "flex",
justifyContent: "space-between",
@ -318,6 +324,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
Tokens:
</span>
<span
data-testid="tokens-in"
style={{
display: "flex",
alignItems: "center",
@ -335,6 +342,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{formatLargeNumber(item.tokensIn || 0)}
</span>
<span
data-testid="tokens-out"
style={{
display: "flex",
alignItems: "center",
@ -357,6 +365,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{!!item.cacheWrites && (
<div
data-testid="cache-container"
style={{
display: "flex",
alignItems: "center",
@ -371,6 +380,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
Cache:
</span>
<span
data-testid="cache-writes"
style={{
display: "flex",
alignItems: "center",
@ -388,6 +398,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
+{formatLargeNumber(item.cacheWrites || 0)}
</span>
<span
data-testid="cache-reads"
style={{
display: "flex",
alignItems: "center",
@ -499,31 +510,48 @@ export const highlight = (
if (regions.length === 0) {
return inputText
}
// Sort and merge overlapping regions
const mergedRegions = mergeRegions(regions)
let content = ""
let nextUnhighlightedRegionStartingIndex = 0
mergedRegions.forEach((region) => {
const start = region[0]
const end = region[1]
const lastRegionNextIndex = end + 1
content += [
inputText.substring(nextUnhighlightedRegionStartingIndex, start),
`<span class="${highlightClassName}">`,
inputText.substring(start, lastRegionNextIndex),
"</span>",
].join("")
nextUnhighlightedRegionStartingIndex = lastRegionNextIndex
// Convert regions to a list of parts with their highlight status
const parts: { text: string; highlight: boolean }[] = []
let lastIndex = 0
mergedRegions.forEach(([start, end]) => {
// Add non-highlighted text before this region
if (start > lastIndex) {
parts.push({
text: inputText.substring(lastIndex, start),
highlight: false
})
}
// Add highlighted text
parts.push({
text: inputText.substring(start, end + 1),
highlight: true
})
lastIndex = end + 1
})
content += inputText.substring(nextUnhighlightedRegionStartingIndex)
return content
// Add any remaining text
if (lastIndex < inputText.length) {
parts.push({
text: inputText.substring(lastIndex),
highlight: false
})
}
// Build final string
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('')
}
return fuseSearchResult

View file

@ -1,362 +1,232 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import HistoryView from '../HistoryView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { useExtensionState } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import { highlight } from '../HistoryView'
import { FuseResult } from 'fuse.js'
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn(),
},
// Mock dependencies
jest.mock('../../../context/ExtensionStateContext')
jest.mock('../../../utils/vscode')
jest.mock('react-virtuoso', () => ({
Virtuoso: ({ data, itemContent }: any) => (
<div data-testid="virtuoso-container">
{data.map((item: any, index: number) => (
<div key={item.id} data-testid={`virtuoso-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
),
}))
interface VSCodeButtonProps {
children: React.ReactNode;
onClick?: (e: any) => void;
appearance?: string;
className?: string;
}
interface VSCodeTextFieldProps {
value?: string;
onInput?: (e: { target: { value: string } }) => void;
placeholder?: string;
style?: React.CSSProperties;
}
interface VSCodeRadioGroupProps {
children?: React.ReactNode;
value?: string;
onChange?: (e: { target: { value: string } }) => void;
style?: React.CSSProperties;
}
interface VSCodeRadioProps {
value: string;
children: React.ReactNode;
disabled?: boolean;
style?: React.CSSProperties;
}
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: function MockVSCodeButton({
children,
onClick,
appearance,
className
}: VSCodeButtonProps) {
return (
<button
onClick={onClick}
data-appearance={appearance}
className={className}
>
{children}
</button>
)
const mockTaskHistory = [
{
id: '1',
task: 'Test task 1',
ts: new Date('2022-02-16T00:00:00').getTime(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
},
VSCodeTextField: function MockVSCodeTextField({
value,
onInput,
placeholder,
style
}: VSCodeTextFieldProps) {
return (
<input
type="text"
value={value}
onChange={(e) => onInput?.({ target: { value: e.target.value } })}
placeholder={placeholder}
style={style}
/>
)
{
id: '2',
task: 'Test task 2',
ts: new Date('2022-02-17T00:00:00').getTime(),
tokensIn: 200,
tokensOut: 100,
cacheWrites: 50,
cacheReads: 25,
},
VSCodeRadioGroup: function MockVSCodeRadioGroup({
children,
value,
onChange,
style
}: VSCodeRadioGroupProps) {
return (
<div style={style} role="radiogroup" data-current-value={value}>
{children}
</div>
)
},
VSCodeRadio: function MockVSCodeRadio({
value,
children,
disabled,
style
}: VSCodeRadioProps) {
return (
<label style={style}>
<input
type="radio"
value={value}
disabled={disabled}
data-testid={`radio-${value}`}
/>
{children}
</label>
)
}
}))
// Mock window.navigator.clipboard
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
})
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage({
type: 'state',
state: {
version: '1.0.0',
taskHistory: [],
...state
}
}, '*')
}
]
describe('HistoryView', () => {
const mockOnDone = jest.fn()
const sampleHistory = [
{
id: '1',
task: 'First task',
ts: Date.now() - 3000,
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002
},
{
id: '2',
task: 'Second task',
ts: Date.now() - 2000,
tokensIn: 200,
tokensOut: 100,
totalCost: 0.004
},
{
id: '3',
task: 'Third task',
ts: Date.now() - 1000,
tokensIn: 300,
tokensOut: 150,
totalCost: 0.006
}
]
beforeEach(() => {
// Reset all mocks before each test
jest.clearAllMocks()
jest.useFakeTimers()
// Mock useExtensionState implementation
;(useExtensionState as jest.Mock).mockReturnValue({
taskHistory: mockTaskHistory,
})
})
it('renders history items in correct order', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
mockPostMessage({ taskHistory: sampleHistory })
const historyItems = screen.getAllByText(/task/i)
expect(historyItems).toHaveLength(3)
expect(historyItems[0]).toHaveTextContent('Third task')
expect(historyItems[1]).toHaveTextContent('Second task')
expect(historyItems[2]).toHaveTextContent('First task')
afterEach(() => {
jest.useRealTimers()
})
it('handles sorting by different criteria', async () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
it('renders history items correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
// Check if both tasks are rendered
expect(screen.getByTestId('virtuoso-item-1')).toBeInTheDocument()
expect(screen.getByTestId('virtuoso-item-2')).toBeInTheDocument()
expect(screen.getByText('Test task 1')).toBeInTheDocument()
expect(screen.getByText('Test task 2')).toBeInTheDocument()
})
// Test oldest sort
const oldestRadio = screen.getByTestId('radio-oldest')
it('handles search functionality', async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Get search input and radio group
const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
const radioGroup = screen.getByRole('radiogroup')
// Type in search
await userEvent.type(searchInput, 'task 1')
// Check if sort option automatically changes to "Most Relevant"
const mostRelevantRadio = within(radioGroup).getByLabelText('Most Relevant')
expect(mostRelevantRadio).not.toBeDisabled()
// Click and wait for radio update
fireEvent.click(mostRelevantRadio)
// Wait for radio button to be checked
const updatedRadio = await within(radioGroup).findByRole('radio', { name: 'Most Relevant', checked: true })
expect(updatedRadio).toBeInTheDocument()
})
it('handles sort options correctly', async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
const radioGroup = screen.getByRole('radiogroup')
// Test changing sort options
const oldestRadio = within(radioGroup).getByLabelText('Oldest')
fireEvent.click(oldestRadio)
let historyItems = screen.getAllByText(/task/i)
expect(historyItems[0]).toHaveTextContent('First task')
expect(historyItems[2]).toHaveTextContent('Third task')
// Wait for oldest radio to be checked
const checkedOldestRadio = await within(radioGroup).findByRole('radio', { name: 'Oldest', checked: true })
expect(checkedOldestRadio).toBeInTheDocument()
// Test most expensive sort
const expensiveRadio = screen.getByTestId('radio-mostExpensive')
fireEvent.click(expensiveRadio)
const mostExpensiveRadio = within(radioGroup).getByLabelText('Most Expensive')
fireEvent.click(mostExpensiveRadio)
historyItems = screen.getAllByText(/task/i)
expect(historyItems[0]).toHaveTextContent('Third task')
expect(historyItems[2]).toHaveTextContent('First task')
// Test most tokens sort
const tokensRadio = screen.getByTestId('radio-mostTokens')
fireEvent.click(tokensRadio)
historyItems = screen.getAllByText(/task/i)
expect(historyItems[0]).toHaveTextContent('Third task')
expect(historyItems[2]).toHaveTextContent('First task')
// Wait for most expensive radio to be checked
const checkedExpensiveRadio = await within(radioGroup).findByRole('radio', { name: 'Most Expensive', checked: true })
expect(checkedExpensiveRadio).toBeInTheDocument()
})
it('handles search functionality and auto-switches to most relevant sort', async () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
it('handles task selection', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
// Click on first task
fireEvent.click(screen.getByText('Test task 1'))
const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
fireEvent.change(searchInput, { target: { value: 'First' } })
const historyItems = screen.getAllByText(/task/i)
expect(historyItems).toHaveLength(1)
expect(historyItems[0]).toHaveTextContent('First task')
// Verify sort switched to Most Relevant
const radioGroup = screen.getByRole('radiogroup')
expect(radioGroup.getAttribute('data-current-value')).toBe('mostRelevant')
// Clear search and verify sort reverts
fireEvent.change(searchInput, { target: { value: '' } })
expect(radioGroup.getAttribute('data-current-value')).toBe('newest')
})
it('handles copy functionality and shows/hides modal', async () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
mockPostMessage({ taskHistory: sampleHistory })
const copyButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('copy-button'))
fireEvent.click(copyButtons[0])
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Third task')
// Verify modal appears
await waitFor(() => {
expect(screen.getByText('Prompt Copied to Clipboard')).toBeInTheDocument()
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'showTaskWithId',
text: '1',
})
// Verify modal disappears
await waitFor(() => {
expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
}, { timeout: 2500 })
})
it('handles delete functionality', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
it('handles task deletion', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
const deleteButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('delete-button'))
// Find and hover over first task
const taskContainer = screen.getByTestId('virtuoso-item-1')
fireEvent.mouseEnter(taskContainer)
fireEvent.click(deleteButtons[0])
const deleteButton = within(taskContainer).getByTitle('Delete Task')
fireEvent.click(deleteButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'deleteTaskWithId',
text: '3'
text: '1',
})
})
it('handles task copying', async () => {
const mockClipboard = {
writeText: jest.fn().mockResolvedValue(undefined),
}
Object.assign(navigator, { clipboard: mockClipboard })
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over first task
const taskContainer = screen.getByTestId('virtuoso-item-1')
fireEvent.mouseEnter(taskContainer)
const copyButton = within(taskContainer).getByTitle('Copy Prompt')
await userEvent.click(copyButton)
// Verify clipboard API was called
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test task 1')
// Wait for copy modal to appear
const copyModal = await screen.findByText('Prompt Copied to Clipboard')
expect(copyModal).toBeInTheDocument()
// Fast-forward timers and wait for modal to disappear
jest.advanceTimersByTime(2000)
await waitFor(() => {
expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
})
})
it('formats dates correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container and check date format
const taskContainer = screen.getByTestId('virtuoso-item-1')
const dateElement = within(taskContainer).getByText((content) => {
return content.includes('FEBRUARY 16') && content.includes('12:00 AM')
})
expect(dateElement).toBeInTheDocument()
})
it('displays token counts correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container
const taskContainer = screen.getByTestId('virtuoso-item-1')
// Find token counts within the task container
const tokensContainer = within(taskContainer).getByTestId('tokens-container')
expect(within(tokensContainer).getByTestId('tokens-in')).toHaveTextContent('100')
expect(within(tokensContainer).getByTestId('tokens-out')).toHaveTextContent('50')
})
it('displays cache information when available', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find second task container
const taskContainer = screen.getByTestId('virtuoso-item-2')
// Find cache info within the task container
const cacheContainer = within(taskContainer).getByTestId('cache-container')
expect(within(cacheContainer).getByTestId('cache-writes')).toHaveTextContent('+50')
expect(within(cacheContainer).getByTestId('cache-reads')).toHaveTextContent('25')
})
it('handles export functionality', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
const exportButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('export-button'))
// Find and hover over second task
const taskContainer = screen.getByTestId('virtuoso-item-2')
fireEvent.mouseEnter(taskContainer)
fireEvent.click(exportButtons[0])
const exportButton = within(taskContainer).getByText('EXPORT')
fireEvent.click(exportButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'exportTaskWithId',
text: '3'
text: '2',
})
})
it('calls onDone when Done button is clicked', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
expect(mockOnDone).toHaveBeenCalled()
})
describe('highlight function', () => {
it('correctly highlights search matches', () => {
const testData = [{
item: { text: 'Hello world' },
matches: [{ key: 'text', value: 'Hello world', indices: [[0, 4]] }],
refIndex: 0
}] as FuseResult<any>[]
const result = highlight(testData)
expect(result[0].text).toBe('<span class="history-item-highlight">Hello</span> world')
})
it('handles multiple matches', () => {
const testData = [{
item: { text: 'Hello world Hello' },
matches: [{
key: 'text',
value: 'Hello world Hello',
indices: [[0, 4], [11, 15]]
}],
refIndex: 0
}] as FuseResult<any>[]
const result = highlight(testData)
expect(result[0].text).toBe(
'<span class="history-item-highlight">Hello</span> world ' +
'<span class="history-item-highlight">Hello</span>'
)
})
it('handles overlapping matches', () => {
const testData = [{
item: { text: 'Hello' },
matches: [{
key: 'text',
value: 'Hello',
indices: [[0, 2], [1, 4]]
}],
refIndex: 0
}] as FuseResult<any>[]
const result = highlight(testData)
expect(result[0].text).toBe('<span class="history-item-highlight">Hello</span>')
})
})
})
})

View file

@ -1,19 +1,47 @@
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { McpTool } from "../../../../src/shared/mcp"
import { vscode } from "../../utils/vscode"
type McpToolRowProps = {
tool: McpTool
serverName?: string
alwaysAllowMcp?: boolean
}
const McpToolRow = ({ tool }: McpToolRowProps) => {
const McpToolRow = ({ tool, serverName, alwaysAllowMcp }: McpToolRowProps) => {
const handleAlwaysAllowChange = () => {
if (!serverName) return;
vscode.postMessage({
type: "toggleToolAlwaysAllow",
serverName,
toolName: tool.name,
alwaysAllow: !tool.alwaysAllow
});
}
return (
<div
key={tool.name}
style={{
padding: "3px 0",
}}>
<div style={{ display: "flex" }}>
<span className="codicon codicon-symbol-method" style={{ marginRight: "6px" }}></span>
<span style={{ fontWeight: 500 }}>{tool.name}</span>
<div
data-testid="tool-row-container"
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", alignItems: "center" }}>
<span className="codicon codicon-symbol-method" style={{ marginRight: "6px" }}></span>
<span style={{ fontWeight: 500 }}>{tool.name}</span>
</div>
{serverName && alwaysAllowMcp && (
<VSCodeCheckbox
checked={tool.alwaysAllow}
onChange={handleAlwaysAllowChange}
data-tool={tool.name}>
Always allow
</VSCodeCheckbox>
)}
</div>
{tool.description && (
<div

View file

@ -17,7 +17,7 @@ type McpViewProps = {
}
const McpView = ({ onDone }: McpViewProps) => {
const { mcpServers: servers } = useExtensionState()
const { mcpServers: servers, alwaysAllowMcp } = useExtensionState()
// const [servers, setServers] = useState<McpServer[]>([
// // Add some mock servers for testing
// {
@ -126,7 +126,7 @@ const McpView = ({ onDone }: McpViewProps) => {
{servers.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{servers.map((server) => (
<ServerRow key={server.name} server={server} />
<ServerRow key={server.name} server={server} alwaysAllowMcp={alwaysAllowMcp} />
))}
</div>
)}
@ -152,7 +152,7 @@ const McpView = ({ onDone }: McpViewProps) => {
}
// Server Row Component
const ServerRow = ({ server }: { server: McpServer }) => {
const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer, alwaysAllowMcp?: boolean }) => {
const [isExpanded, setIsExpanded] = useState(false)
const getStatusColor = () => {
@ -189,6 +189,7 @@ const ServerRow = ({ server }: { server: McpServer }) => {
background: "var(--vscode-textCodeBlock-background)",
cursor: server.error ? "default" : "pointer",
borderRadius: isExpanded || server.error ? "4px 4px 0 0" : "4px",
opacity: server.disabled ? 0.6 : 1,
}}
onClick={handleRowClick}>
{!server.error && (
@ -198,6 +199,55 @@ const ServerRow = ({ server }: { server: McpServer }) => {
/>
)}
<span style={{ flex: 1 }}>{server.name}</span>
<div
style={{ display: "flex", alignItems: "center", marginRight: "8px" }}
onClick={(e) => e.stopPropagation()}>
<div
role="switch"
aria-checked={!server.disabled}
tabIndex={0}
style={{
width: "20px",
height: "10px",
backgroundColor: server.disabled ?
"var(--vscode-titleBar-inactiveForeground)" :
"var(--vscode-button-background)",
borderRadius: "5px",
position: "relative",
cursor: "pointer",
transition: "background-color 0.2s",
opacity: server.disabled ? 0.4 : 0.8,
}}
onClick={() => {
vscode.postMessage({
type: "toggleMcpServer",
serverName: server.name,
disabled: !server.disabled
});
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
vscode.postMessage({
type: "toggleMcpServer",
serverName: server.name,
disabled: !server.disabled
});
}
}}
>
<div style={{
width: "6px",
height: "6px",
backgroundColor: "var(--vscode-titleBar-activeForeground)",
borderRadius: "50%",
position: "absolute",
top: "2px",
left: server.disabled ? "2px" : "12px",
transition: "left 0.2s",
}} />
</div>
</div>
<div
style={{
width: "8px",
@ -256,7 +306,12 @@ const ServerRow = ({ server }: { server: McpServer }) => {
<div
style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
{server.tools.map((tool) => (
<McpToolRow key={tool.name} tool={tool} />
<McpToolRow
key={tool.name}
tool={tool}
serverName={server.name}
alwaysAllowMcp={alwaysAllowMcp}
/>
))}
</div>
) : (

View file

@ -0,0 +1,132 @@
import React from 'react'
import { render, fireEvent, screen } from '@testing-library/react'
import McpToolRow from '../McpToolRow'
import { vscode } from '../../../utils/vscode'
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn()
}
}))
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeCheckbox: function MockVSCodeCheckbox({
children,
checked,
onChange
}: {
children?: React.ReactNode;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<label>
<input
type="checkbox"
checked={checked}
onChange={onChange}
/>
{children}
</label>
)
}
}))
describe('McpToolRow', () => {
const mockTool = {
name: 'test-tool',
description: 'A test tool',
alwaysAllow: false
}
beforeEach(() => {
jest.clearAllMocks()
})
it('renders tool name and description', () => {
render(<McpToolRow tool={mockTool} />)
expect(screen.getByText('test-tool')).toBeInTheDocument()
expect(screen.getByText('A test tool')).toBeInTheDocument()
})
it('does not show always allow checkbox when serverName is not provided', () => {
render(<McpToolRow tool={mockTool} />)
expect(screen.queryByText('Always allow')).not.toBeInTheDocument()
})
it('shows always allow checkbox when serverName and alwaysAllowMcp are provided', () => {
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
expect(screen.getByText('Always allow')).toBeInTheDocument()
})
it('sends message to toggle always allow when checkbox is clicked', () => {
render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
const checkbox = screen.getByRole('checkbox')
fireEvent.click(checkbox)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'toggleToolAlwaysAllow',
serverName: 'test-server',
toolName: 'test-tool',
alwaysAllow: true
})
})
it('reflects always allow state in checkbox', () => {
const alwaysAllowedTool = {
...mockTool,
alwaysAllow: true
}
render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" alwaysAllowMcp={true} />)
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
expect(checkbox.checked).toBe(true)
})
it('prevents event propagation when clicking the checkbox', () => {
const mockOnClick = jest.fn()
render(
<div onClick={mockOnClick}>
<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />
</div>
)
const container = screen.getByTestId('tool-row-container')
fireEvent.click(container)
expect(mockOnClick).not.toHaveBeenCalled()
})
it('displays input schema parameters when provided', () => {
const toolWithSchema = {
...mockTool,
inputSchema: {
type: 'object',
properties: {
param1: {
type: 'string',
description: 'First parameter'
},
param2: {
type: 'number',
description: 'Second parameter'
}
},
required: ['param1']
}
}
render(<McpToolRow tool={toolWithSchema} serverName="test-server" />)
expect(screen.getByText('Parameters')).toBeInTheDocument()
expect(screen.getByText('param1')).toBeInTheDocument()
expect(screen.getByText('param2')).toBeInTheDocument()
expect(screen.getByText('First parameter')).toBeInTheDocument()
expect(screen.getByText('Second parameter')).toBeInTheDocument()
})
})

View file

@ -25,10 +25,16 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysAllowExecute,
alwaysAllowBrowser,
setAlwaysAllowBrowser,
alwaysAllowMcp,
setAlwaysAllowMcp,
soundEnabled,
setSoundEnabled,
soundVolume,
setSoundVolume,
diffEnabled,
setDiffEnabled,
debugDiffEnabled,
setDebugDiffEnabled,
openRouterModels,
setAllowedCommands,
allowedCommands,
@ -48,17 +54,23 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult)
if (!apiValidationResult && !modelIdValidationResult) {
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
vscode.postMessage({
type: "apiConfiguration",
apiConfiguration
})
vscode.postMessage({ type: "customInstructions", text: customInstructions })
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
vscode.postMessage({ type: "soundVolume", value: soundVolume })
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
vscode.postMessage({ type: "isInteractiveMode", bool: isInteractiveMode })
vscode.postMessage({ type: "browserPort", text: browserPort })
vscode.postMessage({ type: "debugDiffEnabled", bool: debugDiffEnabled })
onDone()
}
}
@ -148,6 +160,20 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox checked={diffEnabled} onChange={(e: any) => setDiffEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable editing through diffs</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will be able to edit files more quickly and will automatically reject truncated full-file writes. Works best with the latest Claude 3.5 Sonnet model.
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowReadOnly}
@ -165,158 +191,183 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowWrite}
onChange={(e: any) => setAlwaysAllowWrite(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve write operations</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
padding: "8px",
border: "1px solid var(--vscode-errorBorder)",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}>
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.
<div style={{ marginBottom: 5, border: "2px solid var(--vscode-errorForeground)", borderRadius: "4px", padding: "10px" }}>
<h4 style={{ fontWeight: 500, margin: "0 0 10px 0", color: "var(--vscode-errorForeground)" }}> High-Risk Auto-Approve Settings</h4>
<p style={{ fontSize: "12px", marginBottom: 15, color: "var(--vscode-descriptionForeground)" }}>
The following settings allow Cline to automatically perform potentially dangerous operations without requiring approval.
Enable these settings only if you fully trust the AI and understand the associated security risks.
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowBrowser}
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>
<VSCodeCheckbox
checked={alwaysAllowWrite}
onChange={(e: any) => setAlwaysAllowWrite(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve write operations</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Automatically create and edit files without requiring approval
</p>
</div>
)}
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowBrowser}
onChange={(e: any) => setAlwaysAllowBrowser(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Automatically perform browser actions without requiring approval<br/>
Note: Only applies when the model supports computer use
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowMcp}
onChange={(e: any) => {
setAlwaysAllowMcp(e.target.checked)
vscode.postMessage({ type: "alwaysAllowMcp", bool: e.target.checked })
}}>
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
Enable auto-approval of individual MCP tools in the MCP Servers view (requires both this setting and the tool's individual "Always allow" checkbox)
</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", color: "var(--vscode-descriptionForeground)" }}>
Automatically execute allowed terminal commands without requiring approval
</p>
{alwaysAllowExecute && (
<div style={{ marginTop: 10 }}>
<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)}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCommand()
}
}}
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>
<div style={{ marginBottom: 5 }}>
<h4 style={{ fontWeight: 500, marginBottom: 10 }}>Experimental Features</h4>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox checked={diffEnabled} onChange={(e: any) => setDiffEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable editing through diffs</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will be able to apply diffs to make changes to files and will automatically reject truncated full-file edits.
</p>
<div style={{ marginBottom: 10 }}>
<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 play sound effects for notifications and events.
</p>
</div>
{soundEnabled && (
<div style={{ marginLeft: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ fontWeight: "500", minWidth: '50px' }}>Volume</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={soundVolume ?? 0.5}
onChange={(e) => setSoundVolume(parseFloat(e.target.value))}
style={{
flexGrow: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
}}
/>
<span style={{ minWidth: '35px', textAlign: 'left' }}>
{Math.round((soundVolume ?? 0.5) * 100)}%
</span>
</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 checked={debugDiffEnabled} onChange={(e: any) => setDebugDiffEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Debug diff operations</span>
</VSCodeCheckbox>
<p
style={{
@ -324,7 +375,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will play sound effects for notifications and events.
When enabled, Cline will show detailed debug information when applying diffs fails.
</p>
</div>
</div>

View file

@ -40,7 +40,7 @@ jest.mock('@vscode/webview-ui-toolkit/react', () => ({
/>
),
VSCodeTextArea: () => <textarea />,
VSCodeLink: () => <a />,
VSCodeLink: ({ children, href }: any) => <a href={href || '#'}>{children}</a>,
VSCodeDropdown: ({ children, value, onChange }: any) => (
<select value={value} onChange={onChange}>
{children}
@ -104,6 +104,9 @@ describe('SettingsView - Sound Settings', () => {
name: /Enable sound effects/i
})
expect(soundCheckbox).not.toBeChecked()
// Volume slider should not be visible when sound is disabled
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
})
it('toggles sound setting and sends message to VSCode', () => {
@ -128,6 +131,50 @@ describe('SettingsView - Sound Settings', () => {
})
)
})
it('shows volume slider when sound is enabled', () => {
renderSettingsView()
// Enable sound
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
fireEvent.click(soundCheckbox)
// Volume slider should be visible
const volumeSlider = screen.getByRole('slider')
expect(volumeSlider).toBeInTheDocument()
expect(volumeSlider).toHaveValue('0.5') // Default value
})
it('updates volume and sends message to VSCode when slider changes', () => {
renderSettingsView()
// Enable sound
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
fireEvent.click(soundCheckbox)
// Change volume
const volumeSlider = screen.getByRole('slider')
fireEvent.change(volumeSlider, { target: { value: '0.75' } })
// Verify volume display updates
expect(screen.getByText('75%')).toBeInTheDocument()
// Click Done to save settings
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
// Verify message sent to VSCode
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'soundVolume',
value: 0.75
})
)
})
})
describe('SettingsView - Allowed Commands', () => {

View file

@ -25,12 +25,15 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowWrite: (value: boolean) => void
setAlwaysAllowExecute: (value: boolean) => void
setAlwaysAllowBrowser: (value: boolean) => void
setAlwaysAllowMcp: (value: boolean) => void
setShowAnnouncement: (value: boolean) => void
setAllowedCommands: (value: string[]) => void
setSoundEnabled: (value: boolean) => void
setSoundVolume: (value: number) => void
setDiffEnabled: (value: boolean) => void
setInteractiveBrowserMode: (value: boolean) => void
setBrowserPort: (value: string) => void
setDebugDiffEnabled: (value: boolean) => void
}
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@ -43,9 +46,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
shouldShowAnnouncement: false,
allowedCommands: [],
soundEnabled: false,
soundVolume: 0.5,
diffEnabled: false,
isInteractiveMode: false,
browserPort: "7333",
debugDiffEnabled: false,
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@ -132,18 +137,28 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
openRouterModels,
mcpServers,
filePaths,
setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })),
soundVolume: state.soundVolume,
setApiConfiguration: (value) => setState((prevState) => ({
...prevState,
apiConfiguration: value
})),
setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })),
setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })),
setAlwaysAllowWrite: (value) => setState((prevState) => ({ ...prevState, alwaysAllowWrite: value })),
setAlwaysAllowExecute: (value) => setState((prevState) => ({ ...prevState, alwaysAllowExecute: value })),
setAlwaysAllowBrowser: (value) => setState((prevState) => ({ ...prevState, alwaysAllowBrowser: value })),
setAlwaysAllowMcp: (value) => setState((prevState) => ({ ...prevState, alwaysAllowMcp: value })),
setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
setInteractiveBrowserMode: (value) => setState((prevState) => ({ ...prevState, isInteractiveMode: value })),
setBrowserPort: (value) => setState((prevState) => ({ ...prevState, browserPort: value })),
setDebugDiffEnabled: (value) => setState((prevState) => ({
...prevState,
debugDiffEnabled: value
})),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -1,5 +1,16 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom"
import '@testing-library/jest-dom';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});