mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge branch 'main' of https://github.com/RooVetGit/Roo-Cline into feature/pupeteer-updates
This commit is contained in:
commit
a1b48facb1
66 changed files with 3931 additions and 1291 deletions
20
.changeset/changelog-config.ts
Normal file
20
.changeset/changelog-config.ts
Normal 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;
|
||||
|
|
@ -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": [],
|
||||
|
|
|
|||
5
.changeset/eighty-nails-peel.md
Normal file
5
.changeset/eighty-nails-peel.md
Normal 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
3
.changeset/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
5
.changeset/shaggy-moons-dance.md
Normal file
5
.changeset/shaggy-moons-dance.md
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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!
|
||||
15
.github/actions/ai-release-notes/action.yml
vendored
15
.github/actions/ai-release-notes/action.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
215
.github/workflows/changeset-ai-releases.yml
vendored
215
.github/workflows/changeset-ai-releases.yml
vendored
|
|
@ -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
91
.github/workflows/changeset-release.yml
vendored
Normal 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 }}
|
||||
2
.github/workflows/code-qa.yml
vendored
2
.github/workflows/code-qa.yml
vendored
|
|
@ -69,4 +69,4 @@ jobs:
|
|||
run: npm run install:all
|
||||
|
||||
- name: Run unit tests
|
||||
run: npx jest
|
||||
run: npm test
|
||||
31
.github/workflows/temp-marketplace-publish.yml
vendored
Normal file
31
.github/workflows/temp-marketplace-publish.yml
vendored
Normal 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
3
.gitignore
vendored
|
|
@ -10,5 +10,4 @@ bin/
|
|||
roo-cline-*.vsix
|
||||
|
||||
# Local prompts and rules
|
||||
prompts
|
||||
.clinerules
|
||||
/local-prompts
|
||||
|
|
|
|||
34
CHANGELOG.md
34
CHANGELOG.md
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -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. It’s 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
11
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
17
src/__mocks__/@modelcontextprotocol/sdk/client/index.js
Normal file
17
src/__mocks__/@modelcontextprotocol/sdk/client/index.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
class Client {
|
||||
constructor() {
|
||||
this.request = jest.fn()
|
||||
}
|
||||
|
||||
connect() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
close() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Client
|
||||
}
|
||||
22
src/__mocks__/@modelcontextprotocol/sdk/client/stdio.js
Normal file
22
src/__mocks__/@modelcontextprotocol/sdk/client/stdio.js
Normal 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
|
||||
}
|
||||
24
src/__mocks__/@modelcontextprotocol/sdk/index.js
Normal file
24
src/__mocks__/@modelcontextprotocol/sdk/index.js
Normal 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
|
||||
}
|
||||
51
src/__mocks__/@modelcontextprotocol/sdk/types.js
Normal file
51
src/__mocks__/@modelcontextprotocol/sdk/types.js
Normal 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
17
src/__mocks__/McpHub.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
12
src/__mocks__/default-shell.js
Normal file
12
src/__mocks__/default-shell.js
Normal 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
6
src/__mocks__/delay.js
Normal 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
10
src/__mocks__/globby.js
Normal 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
6
src/__mocks__/os-name.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
function osName() {
|
||||
return 'macOS';
|
||||
}
|
||||
|
||||
module.exports = osName;
|
||||
module.exports.default = osName;
|
||||
20
src/__mocks__/p-wait-for.js
Normal file
20
src/__mocks__/p-wait-for.js
Normal 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;
|
||||
25
src/__mocks__/serialize-error.js
Normal file
25
src/__mocks__/serialize-error.js
Normal 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
|
||||
};
|
||||
7
src/__mocks__/strip-ansi.js
Normal file
7
src/__mocks__/strip-ansi.js
Normal 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
57
src/__mocks__/vscode.js
Normal 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;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ describe('Cline', () => {
|
|||
mockApiConfig,
|
||||
'custom instructions',
|
||||
false,
|
||||
false,
|
||||
true, // isInteractiveMode
|
||||
'7333', // browserPort
|
||||
'test task'
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export const toolParamNames = [
|
|||
"question",
|
||||
"result",
|
||||
"diff",
|
||||
"start_line",
|
||||
"end_line",
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
66
src/integrations/editor/__tests__/detect-omission.test.ts
Normal file
66
src/integrations/editor/__tests__/detect-omission.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
32
src/integrations/misc/__tests__/extract-text.test.ts
Normal file
32
src/integrations/misc/__tests__/extract-text.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
290
src/services/mcp/__tests__/McpHub.test.ts
Normal file
290
src/services/mcp/__tests__/McpHub.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,6 +20,6 @@
|
|||
"useDefineForClassFields": true,
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["src/**/*", "scripts/**/*"],
|
||||
"include": ["src/**/*", "scripts/**/*", ".changeset/**/*"],
|
||||
"exclude": ["node_modules", ".vscode-test", "webview-ui"]
|
||||
}
|
||||
|
|
|
|||
3
webview-ui/package-lock.json
generated
3
webview-ui/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
132
webview-ui/src/components/mcp/__tests__/McpToolRow.test.tsx
Normal file
132
webview-ui/src/components/mcp/__tests__/McpToolRow.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue