diff --git a/.changeset/changelog-config.ts b/.changeset/changelog-config.ts new file mode 100644 index 0000000000..0ad2388732 --- /dev/null +++ b/.changeset/changelog-config.ts @@ -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; \ No newline at end of file diff --git a/.changeset/config.json b/.changeset/config.json index c8fca743d8..0d15d8dd98 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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": [], diff --git a/.changeset/eighty-nails-peel.md b/.changeset/eighty-nails-peel.md new file mode 100644 index 0000000000..1810d531c1 --- /dev/null +++ b/.changeset/eighty-nails-peel.md @@ -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. diff --git a/.changeset/package.json b/.changeset/package.json new file mode 100644 index 0000000000..aead43de36 --- /dev/null +++ b/.changeset/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} \ No newline at end of file diff --git a/.changeset/shaggy-moons-dance.md b/.changeset/shaggy-moons-dance.md new file mode 100644 index 0000000000..e8de22b351 --- /dev/null +++ b/.changeset/shaggy-moons-dance.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Fix lint errors and change npm run lint to also run on webview-ui diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..e9033a41c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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! diff --git a/.github/actions/ai-release-notes/action.yml b/.github/actions/ai-release-notes/action.yml index 294e64500c..575e49c97c 100644 --- a/.github/actions/ai-release-notes/action.yml +++ b/.github/actions/ai-release-notes/action.yml @@ -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" diff --git a/.github/workflows/changeset-ai-releases.yml b/.github/workflows/changeset-ai-releases.yml deleted file mode 100644 index cf22faa20e..0000000000 --- a/.github/workflows/changeset-ai-releases.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml new file mode 100644 index 0000000000..2214187a2c --- /dev/null +++ b/.github/workflows/changeset-release.yml @@ -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 }} diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index 3ee8d15d9b..85b5208d52 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -69,4 +69,4 @@ jobs: run: npm run install:all - name: Run unit tests - run: npx jest \ No newline at end of file + run: npm test \ No newline at end of file diff --git a/.github/workflows/temp-marketplace-publish.yml b/.github/workflows/temp-marketplace-publish.yml new file mode 100644 index 0000000000..a98aca38e6 --- /dev/null +++ b/.github/workflows/temp-marketplace-publish.yml @@ -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" diff --git a/.gitignore b/.gitignore index 734145844e..ad38c8b367 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,4 @@ bin/ roo-cline-*.vsix # Local prompts and rules -prompts -.clinerules +/local-prompts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9899c18ef7..f7db7df978 100644 --- a/CHANGELOG.md +++ b/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] diff --git a/README.md b/README.md index 202a129fca..d16b88c313 100644 --- a/README.md +++ b/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 diff --git a/jest.config.js b/jest.config.js index dbca14c8d5..b6012c0506 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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$': '/node_modules/@types/vscode/index.d.ts' + '^vscode$': '/src/__mocks__/vscode.js', + '@modelcontextprotocol/sdk$': '/src/__mocks__/@modelcontextprotocol/sdk/index.js', + '@modelcontextprotocol/sdk/(.*)': '/src/__mocks__/@modelcontextprotocol/sdk/$1', + '^delay$': '/src/__mocks__/delay.js', + '^p-wait-for$': '/src/__mocks__/p-wait-for.js', + '^globby$': '/src/__mocks__/globby.js', + '^serialize-error$': '/src/__mocks__/serialize-error.js', + '^strip-ansi$': '/src/__mocks__/strip-ansi.js', + '^default-shell$': '/src/__mocks__/default-shell.js', + '^os-name$': '/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 } } }; diff --git a/package-lock.json b/package-lock.json index 59247d2ef5..135389d8da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 03a87d28e7..fb02761ec2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__mocks__/@modelcontextprotocol/sdk/client/index.js b/src/__mocks__/@modelcontextprotocol/sdk/client/index.js new file mode 100644 index 0000000000..6ed5825645 --- /dev/null +++ b/src/__mocks__/@modelcontextprotocol/sdk/client/index.js @@ -0,0 +1,17 @@ +class Client { + constructor() { + this.request = jest.fn() + } + + connect() { + return Promise.resolve() + } + + close() { + return Promise.resolve() + } +} + +module.exports = { + Client +} \ No newline at end of file diff --git a/src/__mocks__/@modelcontextprotocol/sdk/client/stdio.js b/src/__mocks__/@modelcontextprotocol/sdk/client/stdio.js new file mode 100644 index 0000000000..afa42ad522 --- /dev/null +++ b/src/__mocks__/@modelcontextprotocol/sdk/client/stdio.js @@ -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 +} \ No newline at end of file diff --git a/src/__mocks__/@modelcontextprotocol/sdk/index.js b/src/__mocks__/@modelcontextprotocol/sdk/index.js new file mode 100644 index 0000000000..c6e43e6b68 --- /dev/null +++ b/src/__mocks__/@modelcontextprotocol/sdk/index.js @@ -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 +} \ No newline at end of file diff --git a/src/__mocks__/@modelcontextprotocol/sdk/types.js b/src/__mocks__/@modelcontextprotocol/sdk/types.js new file mode 100644 index 0000000000..a2b3ea1588 --- /dev/null +++ b/src/__mocks__/@modelcontextprotocol/sdk/types.js @@ -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 +} \ No newline at end of file diff --git a/src/__mocks__/McpHub.ts b/src/__mocks__/McpHub.ts new file mode 100644 index 0000000000..d39b2d7e6c --- /dev/null +++ b/src/__mocks__/McpHub.ts @@ -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 { + return Promise.resolve() + } + + async callTool(serverName: string, toolName: string, toolArguments?: Record): Promise { + return Promise.resolve({ result: 'success' }) + } +} \ No newline at end of file diff --git a/src/__mocks__/default-shell.js b/src/__mocks__/default-shell.js new file mode 100644 index 0000000000..f03e4fbe48 --- /dev/null +++ b/src/__mocks__/default-shell.js @@ -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; \ No newline at end of file diff --git a/src/__mocks__/delay.js b/src/__mocks__/delay.js new file mode 100644 index 0000000000..9ecb36127d --- /dev/null +++ b/src/__mocks__/delay.js @@ -0,0 +1,6 @@ +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +module.exports = delay; +module.exports.default = delay; \ No newline at end of file diff --git a/src/__mocks__/globby.js b/src/__mocks__/globby.js new file mode 100644 index 0000000000..2584cd1bef --- /dev/null +++ b/src/__mocks__/globby.js @@ -0,0 +1,10 @@ +function globby(patterns, options) { + return Promise.resolve([]); +} + +globby.sync = function(patterns, options) { + return []; +}; + +module.exports = globby; +module.exports.default = globby; \ No newline at end of file diff --git a/src/__mocks__/os-name.js b/src/__mocks__/os-name.js new file mode 100644 index 0000000000..e760ff3893 --- /dev/null +++ b/src/__mocks__/os-name.js @@ -0,0 +1,6 @@ +function osName() { + return 'macOS'; +} + +module.exports = osName; +module.exports.default = osName; \ No newline at end of file diff --git a/src/__mocks__/p-wait-for.js b/src/__mocks__/p-wait-for.js new file mode 100644 index 0000000000..f1e6a6821d --- /dev/null +++ b/src/__mocks__/p-wait-for.js @@ -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; \ No newline at end of file diff --git a/src/__mocks__/serialize-error.js b/src/__mocks__/serialize-error.js new file mode 100644 index 0000000000..bf01dc1daa --- /dev/null +++ b/src/__mocks__/serialize-error.js @@ -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 +}; \ No newline at end of file diff --git a/src/__mocks__/strip-ansi.js b/src/__mocks__/strip-ansi.js new file mode 100644 index 0000000000..bf7aff9e7a --- /dev/null +++ b/src/__mocks__/strip-ansi.js @@ -0,0 +1,7 @@ +function stripAnsi(string) { + // Simple mock that just returns the input string + return string; +} + +module.exports = stripAnsi; +module.exports.default = stripAnsi; \ No newline at end of file diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js new file mode 100644 index 0000000000..23f3ae52a0 --- /dev/null +++ b/src/__mocks__/vscode.js @@ -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; \ No newline at end of file diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 9de2cc4ecd..f32105f404 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -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({ diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index ef9cebbf8d..f26fe9058e 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -279,6 +279,7 @@ describe('Cline', () => { mockApiConfig, 'custom instructions', false, + false, true, // isInteractiveMode '7333', // browserPort 'test task' diff --git a/src/core/assistant-message/index.ts b/src/core/assistant-message/index.ts index d10967c0d0..241f8c7fe6 100644 --- a/src/core/assistant-message/index.ts +++ b/src/core/assistant-message/index.ts @@ -44,6 +44,8 @@ export const toolParamNames = [ "question", "result", "diff", + "start_line", + "end_line", ] as const export type ToolParamName = (typeof toolParamNames)[number] diff --git a/src/core/diff/DiffStrategy.ts b/src/core/diff/DiffStrategy.ts index 6562520b7d..c35ea83c95 100644 --- a/src/core/diff/DiffStrategy.ts +++ b/src/core/diff/DiffStrategy.ts @@ -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 } diff --git a/src/core/diff/strategies/__tests__/search-replace.test.ts b/src/core/diff/strategies/__tests__/search-replace.test.ts index 1ad32fe3aa..f96aa17d81 100644 --- a/src/core/diff/strategies/__tests__/search-replace.test.ts +++ b/src/core/diff/strategies/__tests__/search-replace.test.ts @@ -1,18 +1,15 @@ import { SearchReplaceDiffStrategy } from '../search-replace' describe('SearchReplaceDiffStrategy', () => { - let strategy: SearchReplaceDiffStrategy + describe('exact matching', () => { + let strategy: SearchReplaceDiffStrategy - beforeEach(() => { - strategy = new SearchReplaceDiffStrategy() - }) + beforeEach(() => { + strategy = new SearchReplaceDiffStrategy() // Default 1.0 threshold for exact matching + }) - describe('applyDiff', () => { it('should replace matching content', () => { - const originalContent = `function hello() { - console.log("hello") -} -` + const originalContent = 'function hello() {\n console.log("hello")\n}\n' const diffContent = `test.ts <<<<<<< SEARCH function hello() { @@ -25,19 +22,14 @@ function hello() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe(`function hello() { - console.log("hello world") -} -`) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function hello() {\n console.log("hello world")\n}\n') + } }) it('should match content with different surrounding whitespace', () => { - const originalContent = ` -function example() { - return 42; -} - -` + const originalContent = '\nfunction example() {\n return 42;\n}\n\n' const diffContent = `test.ts <<<<<<< SEARCH function example() { @@ -50,19 +42,14 @@ function example() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe(` -function example() { - return 43; -} - -`) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('\nfunction example() {\n return 43;\n}\n\n') + } }) it('should match content with different indentation in search block', () => { - const originalContent = ` function test() { - return true; - } -` + const originalContent = ' function test() {\n return true;\n }\n' const diffContent = `test.ts <<<<<<< SEARCH function test() { @@ -75,10 +62,10 @@ function test() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe(` function test() { - return false; - } -`) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(' function test() {\n return false;\n }\n') + } }) it('should handle tab-based indentation', () => { @@ -95,7 +82,10 @@ function test() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe("function test() {\n\treturn false;\n}\n") + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n\treturn false;\n}\n") + } }) it('should preserve mixed tabs and spaces', () => { @@ -116,7 +106,10 @@ function test() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe("\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}") + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}") + } }) it('should handle additional indentation with tabs', () => { @@ -134,7 +127,10 @@ function test() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}") + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}") + } }) it('should preserve exact indentation characters when adding lines', () => { @@ -153,7 +149,10 @@ function test() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe("\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}") + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}") + } }) it('should handle Windows-style CRLF line endings', () => { @@ -170,14 +169,14 @@ function test() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe("function test() {\r\n return false;\r\n}\r\n") + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") + } }) it('should return false if search content does not match', () => { - const originalContent = `function hello() { - console.log("hello") -} -` + const originalContent = 'function hello() {\n console.log("hello")\n}\n' const diffContent = `test.ts <<<<<<< SEARCH function hello() { @@ -190,32 +189,19 @@ function hello() { >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe(false) + expect(result.success).toBe(false) }) it('should return false if diff format is invalid', () => { - const originalContent = `function hello() { - console.log("hello") -} -` - const diffContent = `test.ts -Invalid diff format` + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts\nInvalid diff format` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe(false) + expect(result.success).toBe(false) }) it('should handle multiple lines with proper indentation', () => { - const originalContent = `class Example { - constructor() { - this.value = 0 - } - - getValue() { - return this.value - } -} -` + const originalContent = 'class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n return this.value\n }\n}\n' const diffContent = `test.ts <<<<<<< SEARCH getValue() { @@ -230,18 +216,10 @@ Invalid diff format` >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe(`class Example { - constructor() { - this.value = 0 - } - - getValue() { - // Add logging - console.log("Getting value") - return this.value - } -} -`) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n') + } }) it('should preserve whitespace exactly in the output', () => { @@ -258,11 +236,14 @@ Invalid diff format` >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe(" modified\n still indented\n end\n") + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" modified\n still indented\n end\n") + } }) it('should preserve indentation when adding new lines after existing content', () => { - const originalContent = ` onScroll={() => updateHighlights()}` + const originalContent = ' onScroll={() => updateHighlights()}' const diffContent = `test.ts <<<<<<< SEARCH onScroll={() => updateHighlights()} @@ -275,230 +256,1200 @@ Invalid diff format` >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - expect(result).toBe(` onScroll={() => updateHighlights()} - onDragOver={(e) => { - e.preventDefault() - e.stopPropagation() - }}`) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(' onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}') + } }) - it('should handle complex refactoring with multiple functions', () => { - const originalContent = `export async function extractTextFromFile(filePath: string): Promise { - try { - await fs.access(filePath) - } catch (error) { - throw new Error(\`File not found: \${filePath}\`) - } - const fileExtension = path.extname(filePath).toLowerCase() - switch (fileExtension) { - case ".pdf": - return extractTextFromPDF(filePath) - case ".docx": - return extractTextFromDOCX(filePath) - case ".ipynb": - return extractTextFromIPYNB(filePath) - default: - const isBinary = await isBinaryFile(filePath).catch(() => false) - if (!isBinary) { - return addLineNumbers(await fs.readFile(filePath, "utf8")) - } else { - throw new Error(\`Cannot read text for file type: \${fileExtension}\`) - } - } -} - -export function addLineNumbers(content: string): string { - const lines = content.split('\\n') - const maxLineNumberWidth = String(lines.length).length - return lines - .map((line, index) => { - const lineNumber = String(index + 1).padStart(maxLineNumberWidth, ' ') - return \`\${lineNumber} | \${line}\` - }).join('\\n') -}` - + it('should handle varying indentation levels correctly', () => { + const originalContent = ` +class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +}`.trim(); + const diffContent = `test.ts <<<<<<< SEARCH -export async function extractTextFromFile(filePath: string): Promise { - try { - await fs.access(filePath) - } catch (error) { - throw new Error(\`File not found: \${filePath}\`) - } - const fileExtension = path.extname(filePath).toLowerCase() - switch (fileExtension) { - case ".pdf": - return extractTextFromPDF(filePath) - case ".docx": - return extractTextFromDOCX(filePath) - case ".ipynb": - return extractTextFromIPYNB(filePath) - default: - const isBinary = await isBinaryFile(filePath).catch(() => false) - if (!isBinary) { - return addLineNumbers(await fs.readFile(filePath, "utf8")) - } else { - throw new Error(\`Cannot read text for file type: \${fileExtension}\`) - } - } + class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } + } +======= + class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } + } +>>>>>>> REPLACE`.trim(); + + const result = strategy.applyDiff(originalContent, diffContent); + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(` +class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`.trim()); + } + }) + + it('should handle mixed indentation styles in the same file', () => { + const originalContent = `class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +}`.trim(); + const diffContent = `test.ts +<<<<<<< SEARCH + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +======= + constructor() { + this.value = 1; + if (true) { + this.init(); + this.validate(); + } + } +>>>>>>> REPLACE`; + + const result = strategy.applyDiff(originalContent, diffContent); + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.validate(); + } + } +}`); + } + }) + + it('should handle Python-style significant whitespace', () => { + const originalContent = `def example(): + if condition: + do_something() + for item in items: + process(item) + return True`.trim(); + const diffContent = `test.ts +<<<<<<< SEARCH + if condition: + do_something() + for item in items: + process(item) +======= + if condition: + do_something() + while items: + item = items.pop() + process(item) +>>>>>>> REPLACE`; + + const result = strategy.applyDiff(originalContent, diffContent); + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`def example(): + if condition: + do_something() + while items: + item = items.pop() + process(item) + return True`); + } + }); + + it('should preserve empty lines with indentation', () => { + const originalContent = `function test() { + const x = 1; + + if (x) { + return true; + } +}`.trim(); + const diffContent = `test.ts +<<<<<<< SEARCH + const x = 1; + + if (x) { +======= + const x = 1; + + // Check x + if (x) { +>>>>>>> REPLACE`; + + const result = strategy.applyDiff(originalContent, diffContent); + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + const x = 1; + + // Check x + if (x) { + return true; + } +}`); + } + }); + + it('should handle indentation when replacing entire blocks', () => { + const originalContent = `class Test { + method() { + if (true) { + console.log("test"); + } + } +}`.trim(); + const diffContent = `test.ts +<<<<<<< SEARCH + method() { + if (true) { + console.log("test"); + } + } +======= + method() { + try { + if (true) { + console.log("test"); + } + } catch (e) { + console.error(e); + } + } +>>>>>>> REPLACE`; + + const result = strategy.applyDiff(originalContent, diffContent); + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Test { + method() { + try { + if (true) { + console.log("test"); + } + } catch (e) { + console.error(e); + } + } +}`); + } + }); + + it('should handle negative indentation relative to search content', () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + } + } +}`.trim(); + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); + this.setup(); +======= + this.init(); + this.setup(); +>>>>>>> REPLACE`; + + const result = strategy.applyDiff(originalContent, diffContent); + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + } + } +}`); + } + }); + + it('should handle extreme negative indentation (no indent)', () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + } + } +}`.trim(); + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); +======= +this.init(); +>>>>>>> REPLACE`; + + const result = strategy.applyDiff(originalContent, diffContent); + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { +this.init(); + } + } +}`); + } + }); + + it('should handle mixed indentation changes in replace block', () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`.trim(); + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); + this.setup(); + this.validate(); +======= + this.init(); + this.setup(); + this.validate(); +>>>>>>> REPLACE`; + + const result = strategy.applyDiff(originalContent, diffContent); + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`); + } + }); + }) + + describe('line number stripping', () => { + describe('line number stripping', () => { + let strategy: SearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceDiffStrategy() + }) + + it('should strip line numbers from both search and replace sections', () => { + const originalContent = 'function test() {\n return true;\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { +2 | return false; +3 | } +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function test() {\n return false;\n}\n') + } + }) + + it('should strip line numbers with leading spaces', () => { + const originalContent = 'function test() {\n return true;\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH + 1 | function test() { + 2 | return true; + 3 | } +======= + 1 | function test() { + 2 | return false; + 3 | } +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function test() {\n return false;\n}\n') + } + }) + + it('should not strip when not all lines have numbers in either section', () => { + const originalContent = 'function test() {\n return true;\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { + return false; +3 | } +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it('should preserve content that naturally starts with pipe', () => { + const originalContent = '|header|another|\n|---|---|\n|data|more|\n' + const diffContent = `test.ts +<<<<<<< SEARCH +1 | |header|another| +2 | |---|---| +3 | |data|more| +======= +1 | |header|another| +2 | |---|---| +3 | |data|updated| +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('|header|another|\n|---|---|\n|data|updated|\n') + } + }) + + it('should preserve indentation when stripping line numbers', () => { + const originalContent = ' function test() {\n return true;\n }\n' + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { +2 | return false; +3 | } +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(' function test() {\n return false;\n }\n') + } + }) + + it('should handle different line numbers between sections', () => { + const originalContent = 'function test() {\n return true;\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +10 | function test() { +11 | return true; +12 | } +======= +20 | function test() { +21 | return false; +22 | } +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function test() {\n return false;\n}\n') + } + }) + + it('should not strip content that starts with pipe but no line number', () => { + const originalContent = '| Pipe\n|---|\n| Data\n' + const diffContent = `test.ts +<<<<<<< SEARCH +| Pipe +|---| +| Data +======= +| Pipe +|---| +| Updated +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('| Pipe\n|---|\n| Updated\n') + } + }) + + it('should handle mix of line-numbered and pipe-only content', () => { + const originalContent = '| Pipe\n|---|\n| Data\n' + const diffContent = `test.ts +<<<<<<< SEARCH +| Pipe +|---| +| Data +======= +1 | | Pipe +2 | |---| +3 | | NewData +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('1 | | Pipe\n2 | |---|\n3 | | NewData\n') + } + }) + }) + }); + + describe('insertion/deletion', () => { + let strategy: SearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceDiffStrategy() + }) + + describe('deletion', () => { + it('should delete code when replace block is empty', () => { + const originalContent = `function test() { + console.log("hello"); + // Comment to remove + console.log("world"); +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Comment to remove +======= +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + console.log("hello"); + console.log("world"); +}`) + } + }) + + it('should delete multiple lines when replace block is empty', () => { + const originalContent = `class Example { + constructor() { + // Initialize + this.value = 0; + // Set defaults + this.name = ""; + // End init + } +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Initialize + this.value = 0; + // Set defaults + this.name = ""; + // End init +======= +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + } +}`) + } + }) + + it('should preserve indentation when deleting nested code', () => { + const originalContent = `function outer() { + if (true) { + // Remove this + console.log("test"); + // And this + } + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Remove this + console.log("test"); + // And this +======= +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function outer() { + if (true) { + } + return true; +}`) + } + }) + }) + + describe('insertion', () => { + it('should insert code at specified line when search block is empty', () => { + const originalContent = `function test() { + const x = 1; + return x; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +======= + console.log("Adding log"); +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent, 2, 2) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + console.log("Adding log"); + const x = 1; + return x; +}`) + } + }) + + it('should preserve indentation when inserting at nested location', () => { + const originalContent = `function test() { + if (true) { + const x = 1; + } +}` + const diffContent = `test.ts +<<<<<<< SEARCH +======= + console.log("Before"); + console.log("After"); +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent, 3, 3) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + if (true) { + console.log("Before"); + console.log("After"); + const x = 1; + } +}`) + } + }) + + it('should handle insertion at start of file', () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +======= +// Copyright 2024 +// License: MIT + +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent, 1, 1) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`// Copyright 2024 +// License: MIT + +function test() { + return true; +}`) + } + }) + + it('should handle insertion at end of file', () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +======= + +// End of file +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent, 4, 4) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + return true; } -export function addLineNumbers(content: string): string { - const lines = content.split('\\n') - const maxLineNumberWidth = String(lines.length).length - return lines - .map((line, index) => { - const lineNumber = String(index + 1).padStart(maxLineNumberWidth, ' ') - return \`\${lineNumber} | \${line}\` - }).join('\\n') +// End of file`) + } + }) + + it('should insert at the start of the file if no start_line is provided for insertion', () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +======= +console.log("test"); +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`console.log("test"); +function test() { + return true; +}`) + } + }) + }) + }) + + describe('fuzzy matching', () => { + let strategy: SearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceDiffStrategy(0.9) // 90% similarity threshold + }) + + it('should match content with small differences (>90% similar)', () => { + const originalContent = 'function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function getData() { + const result = fetchData(); + return results.filter(Boolean); } ======= -function extractLineRange(content: string, startLine?: number, endLine?: number): string { - const lines = content.split('\\n') - const start = startLine ? Math.max(1, startLine) : 1 - const end = endLine ? Math.min(lines.length, endLine) : lines.length - - if (start > end || start > lines.length) { - throw new Error(\`Invalid line range: start=\${start}, end=\${end}, total lines=\${lines.length}\`) - } - - return lines.slice(start - 1, end).join('\\n') -} - -export async function extractTextFromFile(filePath: string, startLine?: number, endLine?: number): Promise { - try { - await fs.access(filePath) - } catch (error) { - throw new Error(\`File not found: \${filePath}\`) - } - const fileExtension = path.extname(filePath).toLowerCase() - let content: string - - switch (fileExtension) { - case ".pdf": { - const dataBuffer = await fs.readFile(filePath) - const data = await pdf(dataBuffer) - content = extractLineRange(data.text, startLine, endLine) - break - } - case ".docx": { - const result = await mammoth.extractRawText({ path: filePath }) - content = extractLineRange(result.value, startLine, endLine) - break - } - case ".ipynb": { - const data = await fs.readFile(filePath, "utf8") - const notebook = JSON.parse(data) - let extractedText = "" - - for (const cell of notebook.cells) { - if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) { - extractedText += cell.source.join("\\n") + "\\n" - } - } - content = extractLineRange(extractedText, startLine, endLine) - break - } - default: { - const isBinary = await isBinaryFile(filePath).catch(() => false) - if (!isBinary) { - const fileContent = await fs.readFile(filePath, "utf8") - content = extractLineRange(fileContent, startLine, endLine) - } else { - throw new Error(\`Cannot read text for file type: \${fileExtension}\`) - } - } - } - - return addLineNumbers(content, startLine) -} - -export function addLineNumbers(content: string, startLine: number = 1): string { - const lines = content.split('\\n') - const maxLineNumberWidth = String(startLine + lines.length - 1).length - return lines - .map((line, index) => { - const lineNumber = String(startLine + index).padStart(maxLineNumberWidth, ' ') - return \`\${lineNumber} | \${line}\` - }).join('\\n') +function getData() { + const data = fetchData(); + return data.filter(Boolean); } >>>>>>> REPLACE` const result = strategy.applyDiff(originalContent, diffContent) - const expected = `function extractLineRange(content: string, startLine?: number, endLine?: number): string { - const lines = content.split('\\n') - const start = startLine ? Math.max(1, startLine) : 1 - const end = endLine ? Math.min(lines.length, endLine) : lines.length - - if (start > end || start > lines.length) { - throw new Error(\`Invalid line range: start=\${start}, end=\${end}, total lines=\${lines.length}\`) - } - - return lines.slice(start - 1, end).join('\\n') + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n') + } + }) + + it('should not match when content is too different (<90% similar)', () => { + const originalContent = 'function processUsers(data) {\n return data.map(user => user.name);\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function handleItems(items) { + return items.map(item => item.username); +} +======= +function processData(data) { + return data.map(d => d.value); +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it('should match content with extra whitespace', () => { + const originalContent = 'function sum(a, b) {\n return a + b;\n}' + const diffContent = `test.ts +<<<<<<< SEARCH +function sum(a, b) { + return a + b; +} +======= +function sum(a, b) { + return a + b + 1; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function sum(a, b) {\n return a + b + 1;\n}') + } + }) + }) + + describe('line-constrained search', () => { + let strategy: SearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceDiffStrategy() + }) + + it('should find and replace within specified line range', () => { + const originalContent = ` +function one() { + return 1; } -export async function extractTextFromFile(filePath: string, startLine?: number, endLine?: number): Promise { - try { - await fs.access(filePath) - } catch (error) { - throw new Error(\`File not found: \${filePath}\`) - } - const fileExtension = path.extname(filePath).toLowerCase() - let content: string - - switch (fileExtension) { - case ".pdf": { - const dataBuffer = await fs.readFile(filePath) - const data = await pdf(dataBuffer) - content = extractLineRange(data.text, startLine, endLine) - break - } - case ".docx": { - const result = await mammoth.extractRawText({ path: filePath }) - content = extractLineRange(result.value, startLine, endLine) - break - } - case ".ipynb": { - const data = await fs.readFile(filePath, "utf8") - const notebook = JSON.parse(data) - let extractedText = "" - - for (const cell of notebook.cells) { - if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) { - extractedText += cell.source.join("\\n") + "\\n" - } - } - content = extractLineRange(extractedText, startLine, endLine) - break - } - default: { - const isBinary = await isBinaryFile(filePath).catch(() => false) - if (!isBinary) { - const fileContent = await fs.readFile(filePath, "utf8") - content = extractLineRange(fileContent, startLine, endLine) - } else { - throw new Error(\`Cannot read text for file type: \${fileExtension}\`) - } - } - } - - return addLineNumbers(content, startLine) +function two() { + return 2; } -export function addLineNumbers(content: string, startLine: number = 1): string { - const lines = content.split('\\n') - const maxLineNumberWidth = String(startLine + lines.length - 1).length - return lines - .map((line, index) => { - const lineNumber = String(startLine + index).padStart(maxLineNumberWidth, ' ') - return \`\${lineNumber} | \${line}\` - }).join('\\n') +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function two() { + return 2; +} +======= +function two() { + return "two"; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return "two"; +} + +function three() { + return 3; +}`) + } + }) + + it('should find and replace within buffer zone (5 lines before/after)', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function three() { + return 3; +} +======= +function three() { + return "three"; +} +>>>>>>> REPLACE` + + // Even though we specify lines 5-7, it should still find the match at lines 9-11 + // because it's within the 5-line buffer zone + const result = strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return "three"; +}`) + } + }) + + it('should not find matches outside search range and buffer zone', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} + +function four() { + return 4; +} + +function five() { + return 5; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function five() { + return 5; +} +======= +function five() { + return "five"; +} +>>>>>>> REPLACE` + + // Searching around function two() (lines 5-7) + // function five() is more than 5 lines away, so it shouldn't match + const result = strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(false) + }) + + it('should handle search range at start of file', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function one() { + return 1; +} +======= +function one() { + return "one"; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent, 1, 3) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "one"; +} + +function two() { + return 2; +}`) + } + }) + + it('should handle search range at end of file', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function two() { + return 2; +} +======= +function two() { + return "two"; +} +>>>>>>> REPLACE` + + const result = strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return "two"; +}`) + } + }) + + it('should match specific instance of duplicate code using line numbers', () => { + const originalContent = ` +function processData(data) { + return data.map(x => x * 2); +} + +function unrelatedStuff() { + console.log("hello"); +} + +// Another data processor +function processData(data) { + return data.map(x => x * 2); +} + +function moreStuff() { + console.log("world"); +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function processData(data) { + return data.map(x => x * 2); +} +======= +function processData(data) { + // Add logging + console.log("Processing data..."); + return data.map(x => x * 2); +} +>>>>>>> REPLACE` + + // Target the second instance of processData + const result = strategy.applyDiff(originalContent, diffContent, 10, 12) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function processData(data) { + return data.map(x => x * 2); +} + +function unrelatedStuff() { + console.log("hello"); +} + +// Another data processor +function processData(data) { + // Add logging + console.log("Processing data..."); + return data.map(x => x * 2); +} + +function moreStuff() { + console.log("world"); +}`) + } + }) + + it('should search from start line to end of file when only start_line is provided', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function three() { + return 3; +} +======= +function three() { + return "three"; +} +>>>>>>> REPLACE` + + // Only provide start_line, should search from there to end of file + const result = strategy.applyDiff(originalContent, diffContent, 8) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return "three"; +}`) + } + }) + + it('should search from start of file to end line when only end_line is provided', () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function one() { + return 1; +} +======= +function one() { + return "one"; +} +>>>>>>> REPLACE` + + // Only provide end_line, should search from start of file to there + const result = strategy.applyDiff(originalContent, diffContent, undefined, 4) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "one"; +} + +function two() { + return 2; +} + +function three() { + return 3; +}`) + } + }) + + it('should prioritize exact line match over expanded search', () => { + const originalContent = ` +function one() { + return 1; +} + +function process() { + return "old"; +} + +function process() { + return "old"; +} + +function two() { + return 2; }` - expect(result).toBe(expected) + const diffContent = `test.ts +<<<<<<< SEARCH +function process() { + return "old"; +} +======= +function process() { + return "new"; +} +>>>>>>> REPLACE` + + // Should match the second instance exactly at lines 10-12 + // even though the first instance at 6-8 is within the expanded search range + const result = strategy.applyDiff(originalContent, diffContent, 10, 12) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(` +function one() { + return 1; +} + +function process() { + return "old"; +} + +function process() { + return "new"; +} + +function two() { + return 2; +}`) + } + }) + + it('should fall back to expanded search only if exact match fails', () => { + const originalContent = ` +function one() { + return 1; +} + +function process() { + return "target"; +} + +function two() { + return 2; +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function process() { + return "target"; +} +======= +function process() { + return "updated"; +} +>>>>>>> REPLACE` + + // Specify wrong line numbers (3-5), but content exists at 6-8 + // Should still find and replace it since it's within the expanded range + const result = strategy.applyDiff(originalContent, diffContent, 3, 5) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function process() { + return "updated"; +} + +function two() { + return 2; +}`) + } }) }) describe('getToolDescription', () => { + let strategy: SearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new SearchReplaceDiffStrategy() + }) + it('should include the current working directory', () => { const cwd = '/test/dir' const description = strategy.getToolDescription(cwd) @@ -513,6 +1464,11 @@ export function addLineNumbers(content: string, startLine: number = 1): string { expect(description).toContain('') expect(description).toContain('') }) + + it('should document start_line and end_line parameters', () => { + const description = strategy.getToolDescription('/test') + expect(description).toContain('start_line: (required) The line number where the search block starts (inclusive).') + expect(description).toContain('end_line: (required) The line number where the search block ends (inclusive).') + }) }) }) - diff --git a/src/core/diff/strategies/__tests__/unified.test.ts b/src/core/diff/strategies/__tests__/unified.test.ts index 4e6c449ad0..83a53b2573 100644 --- a/src/core/diff/strategies/__tests__/unified.test.ts +++ b/src/core/diff/strategies/__tests__/unified.test.ts @@ -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) + } }) }) }) diff --git a/src/core/diff/strategies/search-replace.ts b/src/core/diff/strategies/search-replace.ts index 53be5413d0..9153c4ef03 100644 --- a/src/core/diff/strategies/search-replace.ts +++ b/src/core/diff/strategies/search-replace.ts @@ -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 + +File path here + <<<<<<< 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 + +2 +5 + \`\`\` -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): +\`\`\` File path here -Your search/replace content here +<<<<<<< SEARCH +======= + """TODO: Write a test for this""" +>>>>>>> REPLACE -` +2 +2 + +\`\`\` + +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: +\`\`\` + +File path here + +<<<<<<< SEARCH + total = 0 + for item in items: + total += item + return total +======= +>>>>>>> REPLACE + +2 +5 + +\`\`\` + +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 + }; } -} +} \ No newline at end of file diff --git a/src/core/diff/strategies/unified.ts b/src/core/diff/strategies/unified.ts index 9e95e0944b..2f80a61598 100644 --- a/src/core/diff/strategies/unified.ts +++ b/src/core/diff/strategies/unified.ts @@ -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 ` } - 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 + } + } + } } } diff --git a/src/core/diff/types.ts b/src/core/diff/types.ts index f4cdf176aa..a662c479ba 100644 --- a/src/core/diff/types.ts +++ b/src/core/diff/types.ts @@ -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 } diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 2363b16361..361b05add5 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -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. diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 25c0ccd059..b0530f6932 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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, this.getGlobalState("apiModelId") as Promise, @@ -1065,12 +1124,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("alwaysAllowWrite") as Promise, this.getGlobalState("alwaysAllowExecute") as Promise, this.getGlobalState("alwaysAllowBrowser") as Promise, + this.getGlobalState("alwaysAllowMcp") as Promise, this.getGlobalState("taskHistory") as Promise, this.getGlobalState("allowedCommands") as Promise, this.getGlobalState("soundEnabled") as Promise, this.getGlobalState("diffEnabled") as Promise, this.getGlobalState("isInteractiveMode") as Promise, - this.getGlobalState("browserPort") as Promise + this.getGlobalState("browserPort") as Promise, + this.getGlobalState("debugDiffEnabled") as Promise, + this.getGlobalState("soundVolume") as Promise, ]) 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, } } diff --git a/src/integrations/editor/__tests__/detect-omission.test.ts b/src/integrations/editor/__tests__/detect-omission.test.ts new file mode 100644 index 0000000000..4740b1f34f --- /dev/null +++ b/src/integrations/editor/__tests__/detect-omission.test.ts @@ -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 = ` +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) + }) +}) \ No newline at end of file diff --git a/src/integrations/editor/detect-omission.ts b/src/integrations/editor/detect-omission.ts index 565ebd3ace..5cb0f8e419 100644 --- a/src/integrations/editor/detect-omission.ts +++ b/src/integrations/editor/detect-omission.ts @@ -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*