diff --git a/.github/ISSUE_TEMPLATE/1.bug-report.yml b/.github/ISSUE_TEMPLATE/1.bug-report.yml deleted file mode 100644 index 543c22117c2aa889b91fddd9eddd905c09dd0644..0000000000000000000000000000000000000000 --- a/.github/ISSUE_TEMPLATE/1.bug-report.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Report an issue -description: Report an issue with Zed. -type: Bug -body: - - type: markdown - attributes: - value: | - Feature requests should be opened in [discussions](https://github.com/zed-industries/zed/discussions/new/choose). - - Before opening a new issue, please do a [search](https://github.com/zed-industries/zed/issues) of existing issues and :+1: upvote the existing issue instead. This will help us maintain a proper signal-to-noise ratio. - - If you need help with your own project, you can ask a question in our [Discord Support Forums](https://discord.com/invite/zedindustries). - - type: textarea - attributes: - label: Reproduction steps - description: A step-by-step description of how to reproduce the issue from a **clean Zed install**. Any code must be sufficient to reproduce (make sure to include context!). Include code as text, not just as a screenshot. **Issues with insufficient detail may be summarily closed**. - placeholder: | - 1. Start Zed - 2. Click X - 3. Y will happen - validations: - required: true - - type: textarea - attributes: - label: Current vs. Expected behavior - description: | - A clear and concise description of what is the current behavior (screenshots, videos), vs. what you expected the behavior to be. - - **Skipping this/failure to provide complete information will result in the issue being closed.** - placeholder: "Based on my reproduction steps above, when I click X, I expect this to happen, but instead Y happens." - validations: - required: true - - type: textarea - attributes: - label: If applicable, attach your Zed log file to this issue. - description: | - Open the command palette in Zed, then type `zed: open log` to see the last 1000 lines. Or type `zed: reveal log in file manager` in the command palette to reveal the log file itself. - value: | -
Zed.log - - - ```log - - ``` - -
- validations: - required: false - - type: textarea - attributes: - label: If applicable, provide details about your model provider - placeholder: | - - Provider: (Anthropic via ZedPro, Anthropic via API key, Copilot Chat, Mistral, OpenAI, etc.) - - Model Name: (Claude Sonnet 4.5, Gemini 3 Pro, GPT-5) - - Mode: (Agent Panel, Inline Assistant, Terminal Assistant or Text Threads) - - Other details (ACPs, MCPs, other settings, etc.): - validations: - required: false - - type: textarea - attributes: - label: Zed version and system specs - description: | - Open the command palette in Zed, then type “zed: copy system specs into clipboard”. **Skipping this/failure to provide complete information will result in the issue being closed**. - placeholder: | - Zed: v0.215.0 (Zed Nightly bfe141ea79aa4984028934067ba75c48d99136ae) - OS: macOS 15.1 - Memory: 36 GiB - Architecture: aarch64 - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml new file mode 100644 index 0000000000000000000000000000000000000000..cae10f02ec3b1bcb024f0d1f1bce0691a39054b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -0,0 +1,115 @@ +name: Report a bug +description: Report a problem with Zed. +type: Bug +labels: "state:needs triage" +body: + - type: markdown + attributes: + value: | + Is this bug already reported? Upvote to get it noticed faster. [Here's the search](https://github.com/zed-industries/zed/issues). Upvote means giving it a :+1: reaction. + + Feature request? Please open in [discussions](https://github.com/zed-industries/zed/discussions/new/choose) instead. + + Just have a question or need support? Welcome to [Discord Support Forums](https://discord.com/invite/zedindustries). + - type: textarea + attributes: + label: Reproduction steps + description: A step-by-step description of how to reproduce the bug from a **clean Zed install**. The more context you provide, the easier it is to find and fix the problem fast. + placeholder: | + 1. Start Zed + 2. Click X + validations: + required: true + - type: textarea + attributes: + label: Current vs. Expected behavior + description: | + Current behavior (screenshots, videos, etc. are appreciated), vs. what you expected the behavior to be. + + placeholder: | + Current behavior: The icon is blue. Expected behavior: The icon should be red because this is what the setting is documented to do. + validations: + required: true + - type: textarea + id: environment + attributes: + label: Zed version and system specs + description: | + Open the command palette in Zed, then type “zed: copy system specs into clipboard”. + placeholder: | + Zed: v0.215.0 (Zed Nightly bfe141ea79aa4984028934067ba75c48d99136ae) + OS: macOS 15.1 + Memory: 36 GiB + Architecture: aarch64 + validations: + required: true + - type: textarea + attributes: + label: Attach Zed log file + description: | + Open the command palette in Zed, then type `zed: open log` to see the last 1000 lines. Or type `zed: reveal log in file manager` in the command palette to reveal the log file itself. + value: | +
Zed.log + + + ```log + + ``` + +
+ validations: + required: false + - type: textarea + attributes: + label: Relevant Zed settings + description: | + Open the command palette in Zed, then type “zed: open settings file” and copy/paste any relevant (e.g., LSP-specific) settings. + value: | +
settings.json + + + ```json + + ``` + +
+ validations: + required: false + - type: textarea + attributes: + label: Relevant Keymap + description: | + Open the command palette in Zed, then type “zed: open keymap file” and copy/paste the file's contents. + value: | +
keymap.json + + + ```json + + ``` + +
+ validations: + required: false + - type: textarea + attributes: + label: (for AI issues) Model provider details + placeholder: | + - Provider: (Anthropic via ZedPro, Anthropic via API key, Copilot Chat, Mistral, OpenAI, etc.) + - Model Name: (Claude Sonnet 4.5, Gemini 3 Pro, GPT-5) + - Mode: (Agent Panel, Inline Assistant, Terminal Assistant or Text Threads) + - Other details (ACPs, MCPs, other settings, etc.): + validations: + required: false + - type: dropdown + attributes: + label: If you are using WSL on Windows, what flavor of Linux are you using? + multiple: false + options: + - Arch Linux + - Ubuntu + - Fedora + - Mint + - Pop!_OS + - NixOS + - Other diff --git a/.github/ISSUE_TEMPLATE/2.crash-report.yml b/.github/ISSUE_TEMPLATE/11_crash_report.yml similarity index 57% rename from .github/ISSUE_TEMPLATE/2.crash-report.yml rename to .github/ISSUE_TEMPLATE/11_crash_report.yml index 47cedcc3100854060c2cbde2147b754d89afef51..a019848e874fee709a935a54aa68bae813374628 100644 --- a/.github/ISSUE_TEMPLATE/2.crash-report.yml +++ b/.github/ISSUE_TEMPLATE/11_crash_report.yml @@ -1,32 +1,23 @@ name: Report a crash description: Zed is crashing or freezing or hanging. type: Crash +labels: "state:needs triage" body: - type: textarea attributes: label: Reproduction steps - description: A step-by-step description of how to reproduce the crash from a **clean Zed install**. **Be verbose**. **Issues with insufficient detail may be summarily closed**. + description: A step-by-step description of how to reproduce the crash from a **clean Zed install**. The more context you provide, the easier it is to find and fix the problem fast. placeholder: | 1. Start Zed 2. Perform an action 3. Zed crashes validations: required: true - - type: textarea - attributes: - label: Current vs. Expected behavior - description: | - Go into depth about what actions you’re performing in Zed to trigger the crash. If Zed crashes before it loads any windows, make sure to mention that. Again, **be verbose**. - - **Skipping this/failure to provide complete information will result in the issue being closed.** - placeholder: "Based on my reproduction steps above, when I perform said action, I expect this to happen, but instead Zed crashes." - validations: - required: true - type: textarea attributes: label: Zed version and system specs description: | - Open the command palette in Zed, then type “zed: copy system specs into clipboard”. **Skipping this/failure to provide complete information will result in the issue being closed**. + Open the command palette in Zed, then type “zed: copy system specs into clipboard”. placeholder: | Zed: v0.215.0 (Zed Nightly bfe141ea79aa4984028934067ba75c48d99136ae) OS: macOS 15.1 @@ -36,7 +27,7 @@ body: required: true - type: textarea attributes: - label: If applicable, attach your Zed log file to this issue + label: Attach Zed log file description: | Open the command palette in Zed, then type `zed: open log` to see the last 1000 lines. Or type `zed: reveal log in file manager` in the command palette to reveal the log file itself. value: | diff --git a/.github/ISSUE_TEMPLATE/99_other.yml b/.github/ISSUE_TEMPLATE/99_other.yml deleted file mode 100644 index 9383a576b1a7d43ea4bb4e02d911891555518532..0000000000000000000000000000000000000000 --- a/.github/ISSUE_TEMPLATE/99_other.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Other [Staff Only] -description: Zed Staff Only -body: - - type: textarea - attributes: - label: Summary - value: | - - SUMMARY_SENTENCE_HERE - - ### Description - - IF YOU DO NOT WORK FOR ZED INDUSTRIES DO NOT CREATE ISSUES WITH THIS TEMPLATE. - THEY WILL BE AUTO-CLOSED AND MAY RESULT IN YOU BEING BANNED FROM THE ZED ISSUE TRACKER. - - FEATURE REQUESTS / SUPPORT REQUESTS SHOULD BE OPENED AS DISCUSSIONS: - https://github.com/zed-industries/zed/discussions/new/choose - validations: - required: true diff --git a/.github/actions/build_docs/action.yml b/.github/actions/build_docs/action.yml index d2e62d5b22ee49c7dcb9b42085a648098fbdb6bb..1ff271f73ff6b800ec3a94615f31c35a7729bb47 100644 --- a/.github/actions/build_docs/action.yml +++ b/.github/actions/build_docs/action.yml @@ -19,6 +19,18 @@ runs: shell: bash -euxo pipefail {0} run: ./script/linux + - name: Install mold linker + shell: bash -euxo pipefail {0} + run: ./script/install-mold + + - name: Download WASI SDK + shell: bash -euxo pipefail {0} + run: ./script/download-wasi-sdk + + - name: Generate action metadata + shell: bash -euxo pipefail {0} + run: ./script/generate-action-metadata + - name: Check for broken links (in MD) uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1 with: diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index 2e75659de0bdf51f4586ef57770fd54dc4eeb074..21b9a8fe0e184773b35752565da6530bb666c6ec 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -5,13 +5,27 @@ on: release: types: - published + workflow_dispatch: + inputs: + tag_name: + description: tag_name + required: true + type: string + prerelease: + description: prerelease + required: true + type: boolean + body: + description: body + type: string + default: '' jobs: rebuild_releases_page: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: after_release::rebuild_releases_page::refresh_cloud_releases - run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name }} + run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name || inputs.tag_name }} shell: bash -euxo pipefail {0} - name: after_release::rebuild_releases_page::redeploy_zed_dev run: npm exec --yes -- vercel@37 --token="$VERCEL_TOKEN" --scope zed-industries redeploy https://zed.dev @@ -27,7 +41,7 @@ jobs: - id: get-release-url name: after_release::post_to_discord::get_release_url run: | - if [ "${{ github.event.release.prerelease }}" == "true" ]; then + if [ "${{ github.event.release.prerelease || inputs.prerelease }}" == "true" ]; then URL="https://zed.dev/releases/preview" else URL="https://zed.dev/releases/stable" @@ -40,9 +54,9 @@ jobs: uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 with: stringToTruncate: | - 📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! + 📣 Zed [${{ github.event.release.tag_name || inputs.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! - ${{ github.event.release.body }} + ${{ github.event.release.body || inputs.body }} maxLength: 2000 truncationSymbol: '...' - name: after_release::post_to_discord::discord_webhook_action @@ -56,7 +70,7 @@ jobs: - id: set-package-name name: after_release::publish_winget::set_package_name run: | - if ("${{ github.event.release.prerelease }}" -eq "true") { + if ("${{ github.event.release.prerelease || inputs.prerelease }}" -eq "true") { $PACKAGE_NAME = "ZedIndustries.Zed.Preview" } else { $PACKAGE_NAME = "ZedIndustries.Zed" @@ -68,6 +82,7 @@ jobs: uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f with: identifier: ${{ steps.set-package-name.outputs.PACKAGE_NAME }} + release-tag: ${{ github.event.release.tag_name || inputs.tag_name }} max-versions-to-keep: 5 token: ${{ secrets.WINGET_TOKEN }} create_sentry_release: diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml new file mode 100644 index 0000000000000000000000000000000000000000..d3688a722aa107efb3dfb95351404f43c9aece65 --- /dev/null +++ b/.github/workflows/autofix_pr.yml @@ -0,0 +1,128 @@ +# Generated from xtask::workflows::autofix_pr +# Rebuild with `cargo xtask workflows`. +name: autofix_pr +run-name: 'autofix PR #${{ inputs.pr_number }}' +on: + workflow_dispatch: + inputs: + pr_number: + description: pr_number + required: true + type: string + run_clippy: + description: run_clippy + type: boolean + default: 'true' +jobs: + run_autofix: + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: autofix_pr::run_autofix::checkout_pr + run: gh pr checkout ${{ inputs.pr_number }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: autofix_pr::run_autofix::run_prettier_fix + run: ./script/prettier --write + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_cargo_fmt + run: cargo fmt --all + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_clippy_fix + if: ${{ inputs.run_clippy }} + run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged + shell: bash -euxo pipefail {0} + - id: create-patch + name: autofix_pr::run_autofix::create_patch + run: | + if git diff --quiet; then + echo "No changes to commit" + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + git diff > autofix.patch + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + shell: bash -euxo pipefail {0} + - name: upload artifact autofix-patch + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: autofix-patch + path: autofix.patch + if-no-files-found: ignore + retention-days: '1' + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + outputs: + has_changes: ${{ steps.create-patch.outputs.has_changes }} + commit_changes: + needs: + - run_autofix + if: needs.run_autofix.outputs.has_changes == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - id: get-app-token + name: steps::authenticate_as_zippy + uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + - name: steps::checkout_repo_with_token + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + token: ${{ steps.get-app-token.outputs.token }} + - name: autofix_pr::commit_changes::checkout_pr + run: gh pr checkout ${{ inputs.pr_number }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + - name: autofix_pr::download_patch_artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + with: + name: autofix-patch + - name: autofix_pr::commit_changes::apply_patch + run: git apply autofix.patch + shell: bash -euxo pipefail {0} + - name: autofix_pr::commit_changes::commit_and_push + run: | + git commit -am "Autofix" + git push + shell: bash -euxo pipefail {0} + env: + GIT_COMMITTER_NAME: Zed Zippy + GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: Zed Zippy + GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} +concurrency: + group: ${{ github.workflow }}-${{ inputs.pr_number }} + cancel-in-progress: true diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index bc01aae17e7141a2359b162c3de94c1aec7b765c..d4dee5154f2209521f3e9d183c05c118e8861521 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -30,7 +30,7 @@ jobs: with: clean: false - id: get-app-token - name: cherry_pick::run_cherry_pick::authenticate_as_zippy + name: steps::authenticate_as_zippy uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml index 8d991c8eb35a98e642d98e1ab3b1b9d30c2a003c..d73b38320731e0a2f9a52ff863de5095eddb7b6a 100644 --- a/.github/workflows/community_champion_auto_labeler.yml +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -23,20 +23,25 @@ jobs: AidanV akbxr AlvaroParker + amtoaer artemevsevev bajrangCoder bcomnes Be-ing blopker + bnjjj bobbymannino CharlesChen0823 chbk cppcoffee + davidbarsky davewa ddoemonn djsauble + errmayank fantacell findrakecil + FloppyDisco gko huacnlee imumesh18 @@ -51,6 +56,7 @@ jobs: marius851000 mikebronner ognevny + playdohface RemcoSmitsDev romaninsh Simek @@ -58,12 +64,14 @@ jobs: sourcefrog suxiaoshao Takk8IS + thedadams tidely timvermeulen valentinegb versecafe vitallium warrenjokinen + WhySoBad ya7010 Zertsov with: diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index 14c1a0a08338ee513a8269094b41ee404beef726..113e5ed131d1443c5481ff2966fac6a234561a20 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -3,27 +3,38 @@ on: schedule: - cron: "0 8 31 DEC *" workflow_dispatch: + inputs: + debug-only: + description: "Run in dry-run mode (no changes made)" + type: boolean + default: false + operations-per-run: + description: "Max number of issues to process (default: 1000)" + type: number + default: 1000 jobs: stale: if: github.repository_owner == 'zed-industries' runs-on: ubuntu-latest steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: > - Hi there! 👋 - - We're working to clean up our issue tracker by closing older bugs that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and it will be kept open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, it will close automatically in 14 days. + Hi there! + Zed development moves fast and a significant number of bugs become outdated. + If you can reproduce this bug on the latest stable Zed, please let us know by leaving a comment with the Zed version. + If the bug doesn't appear for you anymore, feel free to close the issue yourself; otherwise, the bot will close it in a couple of weeks. Thanks for your help! - close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please open a new issue with a link to this issue." + close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please leave a comment with your Zed version so that we can reopen the issue." days-before-stale: 60 days-before-close: 14 only-issue-types: "Bug,Crash" - operations-per-run: 1000 + operations-per-run: ${{ inputs.operations-per-run || 1000 }} ascending: true enable-statistics: true + debug-only: ${{ inputs.debug-only }} stale-issue-label: "stale" exempt-issue-labels: "never stale" diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml new file mode 100644 index 0000000000000000000000000000000000000000..31676e5c914719a34f8b2e61193475ed107cd2db --- /dev/null +++ b/.github/workflows/extension_bump.yml @@ -0,0 +1,148 @@ +# Generated from xtask::workflows::extension_bump +# Rebuild with `cargo xtask workflows`. +name: extension_bump +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' + CARGO_INCREMENTAL: '0' + ZED_EXTENSION_CLI_SHA: 7cfce605704d41ca247e3f84804bf323f6c6caaf +on: + workflow_call: + inputs: + bump-type: + description: bump-type + type: string + default: patch + force-bump: + description: force-bump + required: true + type: boolean + secrets: + app-id: + description: The app ID used to create the PR + required: true + app-secret: + description: The app secret for the corresponding app ID + required: true +jobs: + check_bump_needed: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - id: compare-versions-check + name: extension_bump::compare_versions + run: | + CURRENT_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)" + PR_PARENT_SHA="${{ github.event.pull_request.head.sha }}" + + if [[ -n "$PR_PARENT_SHA" ]]; then + git checkout "$PR_PARENT_SHA" + elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then + git checkout "$BRANCH_PARENT_SHA" + else + git checkout "$(git log -1 --format=%H)"~1 + fi + + PARENT_COMMIT_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)" + + [[ "$CURRENT_VERSION" == "$PARENT_COMMIT_VERSION" ]] && \ + echo "needs_bump=true" >> "$GITHUB_OUTPUT" || \ + echo "needs_bump=false" >> "$GITHUB_OUTPUT" + + echo "current_version=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} + outputs: + needs_bump: ${{ steps.compare-versions-check.outputs.needs_bump }} + current_version: ${{ steps.compare-versions-check.outputs.current_version }} + timeout-minutes: 1 + bump_extension_version: + needs: + - check_bump_needed + if: |- + (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && + (inputs.force-bump == 'true' || needs.check_bump_needed.outputs.needs_bump == 'true') + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - id: generate-token + name: extension_bump::generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.app-id }} + private-key: ${{ secrets.app-secret }} + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: extension_bump::install_bump_2_version + run: pip install bump2version + shell: bash -euxo pipefail {0} + - id: bump-version + name: extension_bump::bump_version + run: | + OLD_VERSION="${{ needs.check_bump_needed.outputs.current_version }}" + + BUMP_FILES=("extension.toml") + if [[ -f "Cargo.toml" ]]; then + BUMP_FILES+=("Cargo.toml") + fi + + bump2version --verbose --current-version "$OLD_VERSION" --no-configured-files ${{ inputs.bump-type }} "${BUMP_FILES[@]}" + + if [[ -f "Cargo.toml" ]]; then + cargo update --workspace + fi + + NEW_VERSION="$(sed -n 's/version = \"\(.*\)\"/\1/p' < extension.toml)" + + echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} + - name: extension_bump::create_pull_request + uses: peter-evans/create-pull-request@v7 + with: + title: Bump version to ${{ steps.bump-version.outputs.new_version }} + body: This PR bumps the version of this extension to v${{ steps.bump-version.outputs.new_version }} + commit-message: Bump version to v${{ steps.bump-version.outputs.new_version }} + branch: zed-zippy-autobump + committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> + base: main + delete-branch: true + token: ${{ steps.generate-token.outputs.token }} + sign-commits: true + assignees: ${{ github.actor }} + timeout-minutes: 1 + create_version_label: + needs: + - check_bump_needed + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check_bump_needed.outputs.needs_bump == 'false' + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - id: generate-token + name: extension_bump::generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.app-id }} + private-key: ${{ secrets.app-secret }} + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: extension_bump::create_version_tag + uses: actions/github-script@v7 + with: + script: |- + github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/v${{ needs.check_bump_needed.outputs.current_version }}', + sha: context.sha + }) + github-token: ${{ steps.generate-token.outputs.token }} + timeout-minutes: 1 +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/extension_release.yml b/.github/workflows/extension_release.yml new file mode 100644 index 0000000000000000000000000000000000000000..5212a79c3e55637aa932be62aea0a626af545a7c --- /dev/null +++ b/.github/workflows/extension_release.yml @@ -0,0 +1,43 @@ +# Generated from xtask::workflows::extension_release +# Rebuild with `cargo xtask workflows`. +name: extension_release +on: + workflow_call: + secrets: + app-id: + description: The app ID used to create the PR + required: true + app-secret: + description: The app secret for the corresponding app ID + required: true +jobs: + create_release: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - id: generate-token + name: extension_bump::generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.app-id }} + private-key: ${{ secrets.app-secret }} + owner: zed-industries + repositories: extensions + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - id: get-extension-id + name: extension_release::get_extension_id + run: | + EXTENSION_ID="$(sed -n 's/id = \"\(.*\)\"/\1/p' < extension.toml)" + + echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} + - name: extension_release::release_action + uses: huacnlee/zed-extension-action@v2 + with: + extension-name: ${{ steps.get-extension-id.outputs.extension_id }} + push-to: zed-industries/extensions + env: + COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 89289fbea20999ada413ef1801bb428f03c82c6b..9f0917e388c74cffed8f342f7504bc111e6f5147 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -7,12 +7,7 @@ env: CARGO_INCREMENTAL: '0' ZED_EXTENSION_CLI_SHA: 7cfce605704d41ca247e3f84804bf323f6c6caaf on: - workflow_call: - inputs: - run_tests: - description: Whether the workflow should run rust tests - required: true - type: boolean + workflow_call: {} jobs: orchestrate: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -73,12 +68,12 @@ jobs: run: cargo clippy --release --all-targets --all-features -- --deny warnings shell: bash -euxo pipefail {0} - name: steps::cargo_install_nextest - if: inputs.run_tests uses: taiki-e/install-action@nextest - name: steps::cargo_nextest - if: inputs.run_tests - run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + run: cargo nextest run --workspace --no-fail-fast shell: bash -euxo pipefail {0} + env: + NEXTEST_NO_TESTS: warn timeout-minutes: 3 check_extension: needs: @@ -108,7 +103,7 @@ jobs: mkdir -p /tmp/ext-output ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output shell: bash -euxo pipefail {0} - timeout-minutes: 1 + timeout-minutes: 2 tests_pass: needs: - orchestrate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90d105880f94ee428f01746ed627f5c6f7d4e246..ffc2554a55e00a5bdb7bd1ee0bfeebd5667755d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: run: ./script/clear-target-dir-if-larger-than 300 shell: bash -euxo pipefail {0} - name: steps::cargo_nextest - run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + run: cargo nextest run --workspace --no-fail-fast shell: bash -euxo pipefail {0} - name: steps::cleanup_cargo_config if: always() @@ -80,7 +80,7 @@ jobs: run: ./script/clear-target-dir-if-larger-than 250 shell: bash -euxo pipefail {0} - name: steps::cargo_nextest - run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + run: cargo nextest run --workspace --no-fail-fast shell: bash -euxo pipefail {0} - name: steps::cleanup_cargo_config if: always() @@ -112,7 +112,7 @@ jobs: run: ./script/clear-target-dir-if-larger-than.ps1 250 shell: pwsh - name: steps::cargo_nextest - run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + run: cargo nextest run --workspace --no-fail-fast shell: pwsh - name: steps::cleanup_cargo_config if: always() @@ -472,11 +472,17 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') runs-on: namespace-profile-2x4-ubuntu-2404 steps: + - id: get-app-token + name: steps::authenticate_as_zippy + uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false shell: bash -euxo pipefail {0} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} notify_on_failure: needs: - upload_release_assets diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index bb327f2c5527d353c9aad01c3e26edcf5baac78c..d76244175accc3e816cbd7d5dc322d2529a0a236 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -51,7 +51,7 @@ jobs: run: ./script/clear-target-dir-if-larger-than.ps1 250 shell: pwsh - name: steps::cargo_nextest - run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + run: cargo nextest run --workspace --no-fail-fast shell: pwsh - name: steps::cleanup_cargo_config if: always() diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index ddedd38ebedb647f07e162286365f4e6b95f45a2..f56e56ac7f139926085f33d6e97d3dea6e03a4bb 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -13,7 +13,7 @@ jobs: bundle_linux_aarch64: if: |- (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) runs-on: namespace-profile-8x32-ubuntu-2004-arm-m4 env: CARGO_INCREMENTAL: 0 @@ -56,7 +56,7 @@ jobs: bundle_linux_x86_64: if: |- (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) runs-on: namespace-profile-32x64-ubuntu-2004 env: CARGO_INCREMENTAL: 0 @@ -99,7 +99,7 @@ jobs: bundle_mac_aarch64: if: |- (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) runs-on: self-mini-macos env: CARGO_INCREMENTAL: 0 @@ -145,7 +145,7 @@ jobs: bundle_mac_x86_64: if: |- (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) runs-on: self-mini-macos env: CARGO_INCREMENTAL: 0 @@ -191,7 +191,7 @@ jobs: bundle_windows_aarch64: if: |- (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) runs-on: self-32vcpu-windows-2022 env: CARGO_INCREMENTAL: 0 @@ -229,7 +229,7 @@ jobs: bundle_windows_x86_64: if: |- (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) runs-on: self-32vcpu-windows-2022 env: CARGO_INCREMENTAL: 0 diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index e7dcb04f77f3507aa861aee30e152850cb36b600..cdfb51cc5b351d1079369aef3abfa845ca7d0428 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -13,6 +13,14 @@ on: jobs: cron_unit_evals: runs-on: namespace-profile-16x32-ubuntu-2204 + strategy: + matrix: + model: + - anthropic/claude-sonnet-4-5-latest + - anthropic/claude-opus-4-5-latest + - google/gemini-3-pro + - openai/gpt-5 + fail-fast: false steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -49,6 +57,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} + ZED_AGENT_MODEL: ${{ matrix.model }} - name: steps::cleanup_cargo_config if: always() run: | diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 323dd7fd1b52eb43400658470ee7d7c986f219fa..47a84574e7c33fb8a40a90c67cd4f7dadb356978 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -74,9 +74,12 @@ jobs: uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 with: version: '9' - - name: ./script/prettier + - name: steps::prettier run: ./script/prettier shell: bash -euxo pipefail {0} + - name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} - name: ./script/check-todos run: ./script/check-todos shell: bash -euxo pipefail {0} @@ -84,12 +87,9 @@ jobs: run: ./script/check-keymaps shell: bash -euxo pipefail {0} - name: run_tests::check_style::check_for_typos - uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1 + uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 with: config: ./typos.toml - - name: steps::cargo_fmt - run: cargo fmt --all -- --check - shell: bash -euxo pipefail {0} timeout-minutes: 60 run_tests_windows: needs: @@ -117,7 +117,7 @@ jobs: run: ./script/clear-target-dir-if-larger-than.ps1 250 shell: pwsh - name: steps::cargo_nextest - run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + run: cargo nextest run --workspace --no-fail-fast shell: pwsh - name: steps::cleanup_cargo_config if: always() @@ -166,7 +166,7 @@ jobs: run: ./script/clear-target-dir-if-larger-than 250 shell: bash -euxo pipefail {0} - name: steps::cargo_nextest - run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + run: cargo nextest run --workspace --no-fail-fast shell: bash -euxo pipefail {0} - name: steps::cleanup_cargo_config if: always() @@ -200,7 +200,7 @@ jobs: run: ./script/clear-target-dir-if-larger-than 300 shell: bash -euxo pipefail {0} - name: steps::cargo_nextest - run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + run: cargo nextest run --workspace --no-fail-fast shell: bash -euxo pipefail {0} - name: steps::cleanup_cargo_config if: always() @@ -353,6 +353,9 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk shell: bash -euxo pipefail {0} + - name: ./script/generate-action-metadata + run: ./script/generate-action-metadata + shell: bash -euxo pipefail {0} - name: run_tests::check_docs::install_mdbook uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 with: @@ -497,6 +500,8 @@ jobs: env: GIT_AUTHOR_NAME: Protobuf Action GIT_AUTHOR_EMAIL: ci@zed.dev + GIT_COMMITTER_NAME: Protobuf Action + GIT_COMMITTER_EMAIL: ci@zed.dev steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -520,6 +525,7 @@ jobs: uses: bufbuild/buf-setup-action@v1 with: version: v1.29.0 + github_token: ${{ secrets.GITHUB_TOKEN }} - name: run_tests::check_postgres_and_protobuf_migrations::bufbuild_breaking_action uses: bufbuild/buf-breaking-action@v1 with: diff --git a/.gitignore b/.gitignore index 2a91a65b6eaef906681bf3f6e315de07b094c4b1..c71417c32bff76af9d4c9c67661556e1625c9d15 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .DS_Store .blob_store .build +.claude/settings.local.json .envrc .flatpak-builder .idea @@ -35,7 +36,11 @@ DerivedData/ Packages xcuserdata/ +crates/docs_preprocessor/actions.json # Don't commit any secrets to the repo. .env .env.secret.toml + +# `nix build` output +/result diff --git a/.mailmap b/.mailmap index db4632d6ca34346d3e8fa289222d7f310b7bdfe5..1e956c52cf76589fc016e1410122ccd94e4818ae 100644 --- a/.mailmap +++ b/.mailmap @@ -141,6 +141,9 @@ Uladzislau Kaminski Uladzislau Kaminski Vitaly Slobodin Vitaly Slobodin +Yara +Yara +Yara Will Bradley Will Bradley WindSoilder diff --git a/.rules b/.rules index 82d15eb9e88299ee7c7fe6c717b2da2646e676a7..7c98c65d7e0eaf3ed0d57898dbd8acee28a220ae 100644 --- a/.rules +++ b/.rules @@ -26,6 +26,12 @@ }); ``` +# Timers in tests + +* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`: + - Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher. + - Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping. + # GPUI GPUI is a UI framework which also provides primitives for state and concurrency management. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cbac4af2b57f0350fa9f5665e110e0d6e7f6341..f7aceadce18788ae2b8bb9d0fe4b5f16225e70d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,15 +15,17 @@ with the community to improve the product in ways we haven't thought of (or had In particular we love PRs that are: -- Fixes to existing bugs and issues. -- Small enhancements to existing features, particularly to make them work for more people. +- Fixing or extending the docs. +- Fixing bugs. +- Small enhancements to existing features to make them work for more people (making things work on more platforms/modes/whatever). - Small extra features, like keybindings or actions you miss from other editors or extensions. -- Work towards shipping larger features on our roadmap. +- Part of a Community Program like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541). If you're looking for concrete ideas: -- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community. -- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed. +- [Curated board of issues](https://github.com/orgs/zed-industries/projects/69) suitable for everyone from first-time contributors to seasoned community champions. +- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible). +- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search). ## Sending changes @@ -37,9 +39,17 @@ like, sorry). Although we will take a look, we tend to only merge about half the PRs that are submitted. If you'd like your PR to have the best chance of being merged: -- Include a clear description of what you're solving, and why it's important to you. -- Include tests. -- If it changes the UI, attach screenshots or screen recordings. +- Make sure the change is **desired**: we're always happy to accept bugfixes, + but features should be confirmed with us first if you aim to avoid wasted + effort. If there isn't already a GitHub issue for your feature with staff + confirmation that we want it, start with a GitHub discussion rather than a PR. +- Include a clear description of **what you're solving**, and why it's important. +- Include **tests**. +- If it changes the UI, attach **screenshots** or screen recordings. +- Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two + features and a refactoring on top of that. +- Keep AI assistance under your judgement and responsibility: it's unlikely + we'll merge a vibe-coded PR that the author doesn't understand. The internal advice for reviewers is as follows: @@ -50,10 +60,9 @@ The internal advice for reviewers is as follows: If you need more feedback from us: the best way is to be responsive to Github comments, or to offer up time to pair with us. -If you are making a larger change, or need advice on how to finish the change -you're making, please open the PR early. We would love to help you get -things right, and it's often easier to see how to solve a problem before the -diff gets too big. +If you need help deciding how to fix a bug, or finish implementing a feature +that we've agreed we want, please open a PR early so we can discuss how to make +the change with code in hand. ## Things we will (probably) not merge @@ -61,11 +70,11 @@ Although there are few hard and fast rules, typically we don't merge: - Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions). - New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs. +- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. - Giant refactorings. - Non-trivial changes with no tests. - Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much. -- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. -- Anything that seems completely AI generated. +- Anything that seems AI-generated without understanding the output. ## Bird's-eye view of Zed diff --git a/Cargo.lock b/Cargo.lock index 63734b552d7475eacdb2ee3eac66371f7c029d28..f9acd6989be8734b6c5b528435fccea62d10f027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,7 @@ dependencies = [ "terminal", "ui", "url", + "urlencoding", "util", "uuid", "watch", @@ -110,6 +111,15 @@ dependencies = [ "workspace", ] +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli 0.31.1", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -159,6 +169,7 @@ dependencies = [ "derive_more 0.99.20", "editor", "env_logger 0.11.8", + "eval_utils", "fs", "futures 0.3.31", "git", @@ -210,14 +221,14 @@ dependencies = [ "worktree", "zed_env_vars", "zlog", - "zstd 0.11.2+zstd.1.5.2", + "zstd", ] [[package]] name = "agent-client-protocol" -version = "0.7.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525705e39c11cd73f7bc784e3681a9386aa30c8d0630808d3dc2237eb4f9cb1b" +checksum = "d3e527d7dfe0f334313d42d1d9318f0a79665f6f21c440d0798f230a77a7ed6c" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -226,22 +237,22 @@ dependencies = [ "derive_more 2.0.1", "futures 0.3.31", "log", - "parking_lot", "serde", "serde_json", ] [[package]] name = "agent-client-protocol-schema" -version = "0.6.2" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecf16c18fea41282d6bbadd1549a06be6836bddb1893f44a6235f340fa24e2af" +checksum = "6903a00e8ac822f9bacac59a1932754d7387c72ebb7c9c7439ad021505591da4" dependencies = [ "anyhow", "derive_more 2.0.1", "schemars", "serde", "serde_json", + "strum 0.27.2", ] [[package]] @@ -290,6 +301,7 @@ dependencies = [ name = "agent_settings" version = "0.1.0" dependencies = [ + "agent-client-protocol", "anyhow", "cloud_llm_client", "collections", @@ -328,6 +340,7 @@ dependencies = [ "buffer_diff", "chrono", "client", + "clock", "cloud_llm_client", "collections", "command_palette_hooks", @@ -335,6 +348,7 @@ dependencies = [ "context_server", "db", "editor", + "eval_utils", "extension", "extension_host", "feature_flags", @@ -343,6 +357,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", + "gpui_tokio", "html_to_markdown", "http_client", "image", @@ -370,6 +385,7 @@ dependencies = [ "proto", "rand 0.9.2", "release_channel", + "reqwest_client", "rope", "rules_library", "schemars", @@ -383,7 +399,6 @@ dependencies = [ "streaming_diff", "task", "telemetry", - "telemetry_events", "terminal", "terminal_view", "text", @@ -396,11 +411,43 @@ dependencies = [ "unindent", "url", "util", + "uuid", "watch", "workspace", "zed_actions", ] +[[package]] +name = "agent_ui_v2" +version = "0.1.0" +dependencies = [ + "agent", + "agent_servers", + "agent_settings", + "agent_ui", + "anyhow", + "assistant_text_thread", + "chrono", + "db", + "editor", + "feature_flags", + "fs", + "fuzzy", + "gpui", + "menu", + "project", + "prompt_store", + "serde", + "serde_json", + "settings", + "text", + "time", + "time_format", + "ui", + "util", + "workspace", +] + [[package]] name = "ahash" version = "0.7.8" @@ -676,21 +723,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "argminmax" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65" -dependencies = [ - "num-traits", -] - -[[package]] -name = "array-init-cursor" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3" - [[package]] name = "arraydeque" version = "0.5.1" @@ -761,7 +793,7 @@ dependencies = [ "url", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.9", + "wayland-protocols", "zbus", ] @@ -844,7 +876,6 @@ dependencies = [ "fs", "futures 0.3.31", "fuzzy", - "globset", "gpui", "html_to_markdown", "http_client", @@ -884,6 +915,7 @@ dependencies = [ "fuzzy", "gpui", "indoc", + "itertools 0.14.0", "language", "language_model", "log", @@ -902,7 +934,7 @@ dependencies = [ "settings", "smallvec", "smol", - "telemetry_events", + "telemetry", "text", "ui", "unindent", @@ -1273,15 +1305,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atoi_simd" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a49e05797ca52e312a0c658938b7d00693ef037799ef7187678f212d7684cf" -dependencies = [ - "debug_unsafe", -] - [[package]] name = "atomic" version = "0.5.3" @@ -1379,6 +1402,7 @@ dependencies = [ "http_client", "markdown_preview", "release_channel", + "semver", "serde", "serde_json", "smol", @@ -1417,9 +1441,9 @@ dependencies = [ [[package]] name = "aws-config" -version = "1.8.8" +version = "1.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37cf2b6af2a95a20e266782b4f76f1a5e12bf412a9db2de9c1e9123b9d8c0ad8" +checksum = "1856b1b48b65f71a4dd940b1c0931f9a7b646d4a924b9828ffefc1454714668a" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1483,9 +1507,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.12" +version = "1.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa006bb32360ed90ac51203feafb9d02e3d21046e1fd3a450a404b90ea73e5d" +checksum = "9f2402da1a5e16868ba98725e5d73f26b8116eaa892e56f2cd0bf5eec7985f70" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -1508,9 +1532,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.109.0" +version = "1.112.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfdfd941dcb253c17bf70baddbf1e5b22f19e29d313d2e049bad4b1dadb2011" +checksum = "c06c037e6823696d752702ec2bad758d3cf95d1b92b712c8ac7e93824b5e2391" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1590,9 +1614,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.86.0" +version = "1.88.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0abbfab841446cce6e87af853a3ba2cc1bc9afcd3f3550dd556c43d434c86d" +checksum = "d05b276777560aa9a196dbba2e3aada4d8006d3d7eeb3ba7fe0c317227d933c4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1612,9 +1636,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.88.0" +version = "1.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a68d675582afea0e94d38b6ca9c5aaae4ca14f1d36faa6edb19b42e687e70d7" +checksum = "f9be14d6d9cd761fac3fd234a0f47f7ed6c0df62d83c0eeb7012750e4732879b" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1634,9 +1658,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.88.0" +version = "1.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d30990923f4f675523c51eb1c0dec9b752fb267b36a61e83cbc219c9d86da715" +checksum = "98a862d704c817d865c8740b62d8bbeb5adcb30965e93b471df8a5bcefa20a80" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1657,9 +1681,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.3.5" +version = "1.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffc03068fbb9c8dd5ce1c6fb240678a5cffb86fb2b7b1985c999c4b83c8df68" +checksum = "c35452ec3f001e1f2f6db107b6373f1f48f05ec63ba2c5c9fa91f07dad32af11" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -1716,9 +1740,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.12" +version = "0.60.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9656b85088f8d9dc7ad40f9a6c7228e1e8447cdf4b046c87e152e0805dea02fa" +checksum = "e29a304f8319781a39808847efb39561351b1bb76e933da7aa90232673638658" dependencies = [ "aws-smithy-types", "bytes 1.10.1", @@ -1727,9 +1751,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.62.4" +version = "0.62.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3feafd437c763db26aa04e0cc7591185d0961e64c61885bece0fb9d50ceac671" +checksum = "445d5d720c99eed0b4aa674ed00d835d9b1427dd73e04adaf2f94c6b2d6f9fca" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -1737,6 +1761,7 @@ dependencies = [ "bytes 1.10.1", "bytes-utils", "futures-core", + "futures-util", "http 0.2.12", "http 1.3.1", "http-body 0.4.6", @@ -1748,9 +1773,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1053b5e587e6fa40ce5a79ea27957b04ba660baa02b28b7436f64850152234f1" +checksum = "623254723e8dfd535f566ee7b2381645f8981da086b5c4aa26c0c41582bb1d2c" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -1778,9 +1803,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.61.6" +version = "0.61.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff418fc8ec5cadf8173b10125f05c2e7e1d46771406187b2c878557d4503390" +checksum = "2db31f727935fc63c6eeae8b37b438847639ec330a9161ece694efba257e0c54" dependencies = [ "aws-smithy-types", ] @@ -1806,9 +1831,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.9.3" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ab99739082da5347660c556689256438defae3bcefd66c52b095905730e404" +checksum = "0bbe9d018d646b96c7be063dd07987849862b0e6d07c778aad7d93d1be6c1ef0" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1830,9 +1855,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3683c5b152d2ad753607179ed71988e8cfd52964443b4f74fd8e552d0bbfeb46" +checksum = "ec7204f9fd94749a7c53b26da1b961b4ac36bf070ef1e0b94bb09f79d4f6c193" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -1847,9 +1872,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.3.3" +version = "1.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f5b3a7486f6690ba25952cabf1e7d75e34d69eaff5081904a47bc79074d6457" +checksum = "25f535879a207fce0db74b679cfc3e91a3159c8144d717d55f5832aea9eef46e" dependencies = [ "base64-simd", "bytes 1.10.1", @@ -1873,18 +1898,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.11" +version = "0.60.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c34127e8c624bc2999f3b657e749c1393bedc9cd97b92a804db8ced4d2e163" +checksum = "eab77cdd036b11056d2a30a7af7b775789fb024bf216acc13884c6c97752ae56" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.9" +version = "1.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2fd329bf0e901ff3f60425691410c69094dc2a1f34b331f37bfc4e9ac1565a1" +checksum = "d79fb68e3d7fe5d4833ea34dc87d2e97d26d3086cb3da660bb6b1f76d98680b6" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -1983,7 +2008,7 @@ version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ - "addr2line", + "addr2line 0.25.1", "cfg-if", "libc", "miniz_oxide", @@ -2064,26 +2089,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - [[package]] name = "bindgen" version = "0.71.1" @@ -2124,30 +2129,15 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec 0.8.0", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bit-vec" version = "0.8.0" @@ -2251,19 +2241,6 @@ dependencies = [ "profiling", ] -[[package]] -name = "blake3" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq 0.3.1", -] - [[package]] name = "block" version = "0.1.6" @@ -2326,9 +2303,9 @@ dependencies = [ [[package]] name = "borrow-or-share" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "borsh" @@ -2353,12 +2330,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "boxcar" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" - [[package]] name = "breadcrumbs" version = "0.1.0" @@ -2421,6 +2392,7 @@ dependencies = [ "rand 0.9.2", "rope", "serde_json", + "settings", "sum_tree", "text", "unindent", @@ -2524,9 +2496,6 @@ name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" -dependencies = [ - "serde", -] [[package]] name = "bytes-utils" @@ -2813,15 +2782,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -2851,9 +2811,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -2936,6 +2896,17 @@ dependencies = [ "util", ] +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "chrono" version = "0.4.42" @@ -2950,16 +2921,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "chrono-tz" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" -dependencies = [ - "chrono", - "phf 0.12.1", -] - [[package]] name = "chunked_transfer" version = "1.5.0" @@ -3202,26 +3163,11 @@ dependencies = [ "uuid", ] -[[package]] -name = "cloud_zeta2_prompt" -version = "0.1.0" -dependencies = [ - "anyhow", - "cloud_llm_client", - "indoc", - "ordered-float 2.10.1", - "rustc-hash 2.1.1", - "schemars", - "serde", - "serde_json", - "strum 0.27.2", -] - [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" dependencies = [ "cc", ] @@ -3322,8 +3268,8 @@ name = "codestral" version = "0.1.0" dependencies = [ "anyhow", - "edit_prediction", "edit_prediction_context", + "edit_prediction_types", "futures 0.3.31", "gpui", "http_client", @@ -3513,17 +3459,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "comfy-table" -version = "7.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" -dependencies = [ - "crossterm", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "command-fds" version = "0.3.2" @@ -3577,21 +3512,6 @@ dependencies = [ "workspace", ] -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "serde", - "static_assertions", -] - [[package]] name = "component" version = "0.1.0" @@ -3697,12 +3617,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "context_server" version = "0.1.0" @@ -3721,8 +3635,10 @@ dependencies = [ "serde", "serde_json", "settings", + "slotmap", "smol", "tempfile", + "terminal", "url", "util", ] @@ -3755,7 +3671,7 @@ dependencies = [ "command_palette_hooks", "ctor", "dirs 4.0.0", - "edit_prediction", + "edit_prediction_types", "editor", "fs", "futures 0.3.31", @@ -3780,6 +3696,7 @@ dependencies = [ "task", "theme", "ui", + "url", "util", "workspace", "zlog", @@ -4030,20 +3947,38 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.120.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5023e06632d8f351c2891793ccccfe4aef957954904392434038745fb6f1f68" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.120.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c4012b4c8c1f6eb05c0a0a540e3e1ee992631af51aa2bbb3e712903ce4fd65" +dependencies = [ + "cranelift-srcgen", +] + [[package]] name = "cranelift-bforest" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4" +checksum = "4d6d883b4942ef3a7104096b8bc6f2d1a41393f159ac8de12aed27b25d67f895" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34" +checksum = "db7b2ee9eec6ca8a716d900d5264d678fb2c290c58c46c8da7f94ee268175d17" dependencies = [ "serde", "serde_derive", @@ -4051,11 +3986,12 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e" +checksum = "aeda0892577afdce1ac2e9a983a55f8c5b87a59334e1f79d8f735a2d7ba4f4b4" dependencies = [ "bumpalo", + "cranelift-assembler-x64", "cranelift-bforest", "cranelift-bitset", "cranelift-codegen-meta", @@ -4064,9 +4000,10 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli 0.31.1", - "hashbrown 0.14.5", + "hashbrown 0.15.5", "log", "postcard", + "pulley-interpreter", "regalloc2", "rustc-hash 2.1.1", "serde", @@ -4078,33 +4015,36 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8" +checksum = "e461480d87f920c2787422463313326f67664e68108c14788ba1676f5edfcd15" dependencies = [ + "cranelift-assembler-x64-meta", "cranelift-codegen-shared", + "cranelift-srcgen", + "pulley-interpreter", ] [[package]] name = "cranelift-codegen-shared" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb" +checksum = "976584d09f200c6c84c4b9ff7af64fc9ad0cb64dffa5780991edd3fe143a30a1" [[package]] name = "cranelift-control" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef" +checksum = "46d43d70f4e17c545aa88dbf4c84d4200755d27c6e3272ebe4de65802fa6a955" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323" +checksum = "d75418674520cb400c8772bfd6e11a62736c78fc1b6e418195696841d1bf91f1" dependencies = [ "cranelift-bitset", "serde", @@ -4113,9 +4053,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57" +checksum = "3c8b1a91c86687a344f3c52dd6dfb6e50db0dfa7f2e9c7711b060b3623e1fdeb" dependencies = [ "cranelift-codegen", "log", @@ -4125,21 +4065,27 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d" +checksum = "711baa4e3432d4129295b39ec2b4040cc1b558874ba0a37d08e832e857db7285" [[package]] name = "cranelift-native" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" +checksum = "41c83e8666e3bcc5ffeaf6f01f356f0e1f9dcd69ce5511a1efd7ca5722001a3f" dependencies = [ "cranelift-codegen", "libc", "target-lexicon 0.13.3", ] +[[package]] +name = "cranelift-srcgen" +version = "0.120.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e3f4d783a55c64266d17dc67d2708852235732a100fc40dd9f1051adc64d7b" + [[package]] name = "crash-context" version = "0.6.3" @@ -4168,7 +4114,7 @@ dependencies = [ name = "crashes" version = "0.1.0" dependencies = [ - "bincode 1.3.3", + "bincode", "cfg-if", "crash-handler", "extension_host", @@ -4181,7 +4127,8 @@ dependencies = [ "serde_json", "smol", "system_specs", - "zstd 0.11.2+zstd.1.5.2", + "windows 0.61.3", + "zstd", ] [[package]] @@ -4326,29 +4273,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.9.4", - "crossterm_winapi", - "document-features", - "parking_lot", - "rustix 1.1.2", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crunchy" version = "0.2.4" @@ -4703,12 +4627,6 @@ dependencies = [ "util", ] -[[package]] -name = "debug_unsafe" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85d3cef41d236720ed453e102153a53e4cc3d2fde848c0078a50cf249e8e3e5b" - [[package]] name = "debugger_tools" version = "0.1.0" @@ -4741,6 +4659,7 @@ dependencies = [ "db", "debugger_tools", "editor", + "feature_flags", "file_icons", "futures 0.3.31", "fuzzy", @@ -5102,8 +5021,6 @@ name = "docs_preprocessor" version = "0.1.0" dependencies = [ "anyhow", - "command_palette", - "gpui", "mdbook", "regex", "serde", @@ -5112,19 +5029,9 @@ dependencies = [ "task", "theme", "util", - "zed", "zlog", ] -[[package]] -name = "document-features" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" -dependencies = [ - "litrs", -] - [[package]] name = "documented" version = "0.9.2" @@ -5274,79 +5181,188 @@ dependencies = [ name = "edit_prediction" version = "0.1.0" dependencies = [ - "client", - "gpui", - "language", -] - -[[package]] -name = "edit_prediction_button" -version = "0.1.0" -dependencies = [ + "ai_onboarding", "anyhow", + "arrayvec", + "brotli", "client", + "clock", + "cloud_api_types", "cloud_llm_client", - "codestral", + "collections", "copilot", - "edit_prediction", - "editor", + "ctor", + "db", + "edit_prediction_context", + "edit_prediction_types", "feature_flags", "fs", "futures 0.3.31", "gpui", "indoc", + "itertools 0.14.0", "language", + "language_model", + "log", "lsp", - "paths", + "menu", + "open_ai", + "parking_lot", + "postage", + "pretty_assertions", "project", + "pulldown-cmark 0.12.2", + "rand 0.9.2", "regex", + "release_channel", + "semver", + "serde", "serde_json", "settings", - "supermaven", + "strum 0.27.2", "telemetry", - "theme", + "telemetry_events", + "thiserror 2.0.17", "ui", + "util", + "uuid", "workspace", + "worktree", "zed_actions", - "zeta", - "zeta2", + "zeta_prompt", + "zlog", ] [[package]] -name = "edit_prediction_context" +name = "edit_prediction_cli" version = "0.1.0" dependencies = [ + "anthropic", "anyhow", - "arrayvec", + "chrono", "clap", + "client", "cloud_llm_client", "collections", + "debug_adapter_extension", + "dirs 4.0.0", + "edit_prediction", + "extension", + "fs", "futures 0.3.31", "gpui", - "hashbrown 0.15.5", + "gpui_tokio", + "http_client", "indoc", - "itertools 0.14.0", "language", + "language_extension", + "language_model", + "language_models", + "languages", + "libc", "log", - "ordered-float 2.10.1", - "postage", + "node_runtime", + "paths", "pretty_assertions", "project", - "regex", + "prompt_store", + "release_channel", + "reqwest_client", "serde", "serde_json", "settings", - "slotmap", - "strum 0.27.2", + "shellexpand 2.1.2", + "smol", + "sqlez", + "sqlez_macros", + "terminal_view", + "util", + "wasmtime", + "watch", + "zeta_prompt", +] + +[[package]] +name = "edit_prediction_context" +version = "0.1.0" +dependencies = [ + "anyhow", + "cloud_llm_client", + "collections", + "env_logger 0.11.8", + "futures 0.3.31", + "gpui", + "indoc", + "language", + "log", + "lsp", + "parking_lot", + "pretty_assertions", + "project", + "serde", + "serde_json", + "settings", + "smallvec", "text", "tree-sitter", - "tree-sitter-c", - "tree-sitter-cpp", - "tree-sitter-go", "util", + "zeta_prompt", "zlog", ] +[[package]] +name = "edit_prediction_types" +version = "0.1.0" +dependencies = [ + "client", + "gpui", + "language", + "text", +] + +[[package]] +name = "edit_prediction_ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "buffer_diff", + "client", + "cloud_llm_client", + "codestral", + "command_palette_hooks", + "copilot", + "edit_prediction", + "edit_prediction_types", + "editor", + "feature_flags", + "fs", + "futures 0.3.31", + "git", + "gpui", + "indoc", + "language", + "log", + "lsp", + "markdown", + "menu", + "multi_buffer", + "paths", + "project", + "regex", + "serde_json", + "settings", + "supermaven", + "telemetry", + "text", + "theme", + "time", + "ui", + "util", + "workspace", + "zed_actions", + "zeta_prompt", +] + [[package]] name = "editor" version = "0.1.0" @@ -5363,8 +5379,9 @@ dependencies = [ "ctor", "dap", "db", - "edit_prediction", + "edit_prediction_types", "emojis", + "feature_flags", "file_icons", "fs", "futures 0.3.31", @@ -5406,9 +5423,11 @@ dependencies = [ "text", "theme", "time", + "tracing", "tree-sitter-bash", "tree-sitter-c", "tree-sitter-html", + "tree-sitter-md", "tree-sitter-python", "tree-sitter-rust", "tree-sitter-typescript", @@ -5424,6 +5443,7 @@ dependencies = [ "workspace", "zed_actions", "zlog", + "ztracing", ] [[package]] @@ -5699,12 +5719,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ethnum" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" - [[package]] name = "euclid" version = "0.22.11" @@ -5769,6 +5783,15 @@ dependencies = [ "watch", ] +[[package]] +name = "eval_utils" +version = "0.1.0" +dependencies = [ + "gpui", + "serde", + "smol", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -5849,6 +5872,7 @@ dependencies = [ "gpui", "heck 0.5.0", "http_client", + "indoc", "language", "log", "lsp", @@ -5859,6 +5883,7 @@ dependencies = [ "serde", "serde_json", "task", + "tempfile", "toml 0.8.23", "url", "util", @@ -5980,40 +6005,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fancy-regex" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" -dependencies = [ - "bit-set 0.5.3", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fancy-regex" -version = "0.14.0" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set 0.8.0", + "bit-set", "regex-automata", "regex-syntax", ] -[[package]] -name = "fast-float2" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" - [[package]] name = "fast-srgb8" version = "1.0.0" @@ -6172,9 +6174,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fixedbitset" @@ -6189,7 +6191,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" dependencies = [ "crc32fast", - "libz-rs-sys", "miniz_oxide", ] @@ -6225,9 +6226,9 @@ checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" [[package]] name = "fluent-uri" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" dependencies = [ "borrow-or-share", "ref-cast", @@ -6406,6 +6407,7 @@ dependencies = [ "git", "gpui", "ignore", + "is_executable", "libc", "log", "notify 8.2.0", @@ -6445,16 +6447,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fs4" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" -dependencies = [ - "rustix 1.1.2", - "windows-sys 0.59.0", -] - [[package]] name = "fs_benchmarks" version = "0.1.0" @@ -6915,6 +6907,20 @@ dependencies = [ "seq-macro", ] +[[package]] +name = "generator" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -6965,7 +6971,7 @@ dependencies = [ [[package]] name = "gh-workflow" version = "0.8.0" -source = "git+https://github.com/zed-industries/gh-workflow?rev=3eaa84abca0778eb54272f45a312cb24f9a0b435#3eaa84abca0778eb54272f45a312cb24f9a0b435" +source = "git+https://github.com/zed-industries/gh-workflow?rev=09acfdf2bd5c1d6254abefd609c808ff73547b2c#09acfdf2bd5c1d6254abefd609c808ff73547b2c" dependencies = [ "async-trait", "derive_more 2.0.1", @@ -6982,7 +6988,7 @@ dependencies = [ [[package]] name = "gh-workflow-macros" version = "0.8.0" -source = "git+https://github.com/zed-industries/gh-workflow?rev=3eaa84abca0778eb54272f45a312cb24f9a0b435#3eaa84abca0778eb54272f45a312cb24f9a0b435" +source = "git+https://github.com/zed-industries/gh-workflow?rev=09acfdf2bd5c1d6254abefd609c808ff73547b2c#09acfdf2bd5c1d6254abefd609c808ff73547b2c" dependencies = [ "heck 0.5.0", "quote", @@ -7076,6 +7082,7 @@ dependencies = [ "gpui", "http_client", "indoc", + "itertools 0.14.0", "pretty_assertions", "regex", "serde", @@ -7105,6 +7112,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "git", + "git_hosting_providers", "gpui", "indoc", "itertools 0.14.0", @@ -7120,17 +7128,21 @@ dependencies = [ "picker", "pretty_assertions", "project", + "prompt_store", + "rand 0.9.2", "recent_projects", "remote", "schemars", "serde", "serde_json", "settings", + "smol", "strum 0.27.2", "telemetry", "theme", "time", "time_format", + "tracing", "ui", "unindent", "util", @@ -7140,6 +7152,7 @@ dependencies = [ "zed_actions", "zeroize", "zlog", + "ztracing", ] [[package]] @@ -7309,6 +7322,7 @@ dependencies = [ "libc", "log", "lyon", + "mach2 0.5.0", "media", "metal", "naga", @@ -7353,7 +7367,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-cursor", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", "windows 0.61.3", @@ -7516,10 +7530,20 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "rayon", "serde", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.8.4" @@ -7617,7 +7641,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d" dependencies = [ - "bincode 1.3.3", + "bincode", "byteorder", "heed-traits", "serde", @@ -7807,7 +7831,6 @@ dependencies = [ "tempfile", "url", "util", - "zed-reqwest", ] [[package]] @@ -8207,7 +8230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -8377,7 +8400,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fb8251fb7bcd9ccd3725ed8deae9fe7db8e586495c9eb5b0c52e6233e5e75ea" dependencies = [ - "bincode 1.3.3", + "bincode", "crossbeam-channel", "fnv", "lazy_static", @@ -8436,6 +8459,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -8600,21 +8632,21 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.30.0" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" +checksum = "73c9ffb2b5c56d58030e1b532d8e8389da94590515f118cf35b5cb68e4764a7e" dependencies = [ "ahash 0.8.12", - "base64 0.22.1", "bytecount", + "data-encoding", "email_address", - "fancy-regex 0.14.0", + "fancy-regex", "fraction", + "getrandom 0.3.4", "idna", "itoa", "num-cmp", "num-traits", - "once_cell", "percent-encoding", "referencing", "regex", @@ -8622,6 +8654,7 @@ dependencies = [ "reqwest 0.12.24", "serde", "serde_json", + "unicode-general-category", "uuid-simd", ] @@ -8772,6 +8805,7 @@ dependencies = [ "ctor", "diffy", "ec4rs", + "encoding_rs", "fs", "futures 0.3.31", "fuzzy", @@ -8790,6 +8824,7 @@ dependencies = [ "regex", "rpc", "schemars", + "semver", "serde", "serde_json", "settings", @@ -8852,6 +8887,7 @@ dependencies = [ "cloud_api_types", "cloud_llm_client", "collections", + "credentials_provider", "futures 0.3.31", "gpui", "http_client", @@ -8867,9 +8903,9 @@ dependencies = [ "serde_json", "settings", "smol", - "telemetry_events", "thiserror 2.0.17", "util", + "zed_env_vars", ] [[package]] @@ -8893,6 +8929,8 @@ dependencies = [ "credentials_provider", "deepseek", "editor", + "extension", + "extension_host", "fs", "futures 0.3.31", "google_ai", @@ -8926,7 +8964,6 @@ dependencies = [ "util", "vercel", "x_ai", - "zed_env_vars", ] [[package]] @@ -9002,6 +9039,7 @@ dependencies = [ "chrono", "collections", "futures 0.3.31", + "globset", "gpui", "http_client", "itertools 0.14.0", @@ -9023,11 +9061,14 @@ dependencies = [ "regex", "rope", "rust-embed", + "semver", "serde", "serde_json", "serde_json_lenient", "settings", + "smallvec", "smol", + "snippet", "task", "terminal", "text", @@ -9208,15 +9249,6 @@ dependencies = [ "webrtc-sys", ] -[[package]] -name = "libz-rs-sys" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" -dependencies = [ - "zlib-rs", -] - [[package]] name = "libz-sys" version = "1.1.22" @@ -9279,12 +9311,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "litrs" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" - [[package]] name = "livekit" version = "0.7.8" @@ -9455,7 +9481,20 @@ dependencies = [ ] [[package]] -name = "loop9" +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loop9" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" @@ -9576,25 +9615,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "lz4" -version = "1.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" -dependencies = [ - "lz4-sys", -] - -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "mac" version = "0.1.1" @@ -10143,9 +10163,11 @@ dependencies = [ "sum_tree", "text", "theme", + "tracing", "tree-sitter", "util", "zlog", + "ztracing", ] [[package]] @@ -10167,7 +10189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", - "bit-set 0.8.0", + "bit-set", "bitflags 2.9.4", "cfg_aliases 0.2.1", "codespan-reporting 0.12.0", @@ -10457,15 +10479,6 @@ name = "notify-types" version = "2.0.0" source = "git+https://github.com/zed-industries/notify.git?rev=b4588b2e5aee68f4c0e100f140e808cbce7b1419#b4588b2e5aee68f4c0e100f140e808cbce7b1419" -[[package]] -name = "now" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89e9874397a1f0a52fc1f197a8effd9735223cb2390e9dcc83ac6cd02923d0" -dependencies = [ - "chrono", -] - [[package]] name = "ntapi" version = "0.4.1" @@ -10861,41 +10874,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "object_store" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c1be0c6c22ec0817cdc77d3842f721a17fd30ab6965001415b5402a74e6b740" -dependencies = [ - "async-trait", - "base64 0.22.1", - "bytes 1.10.1", - "chrono", - "form_urlencoded", - "futures 0.3.31", - "http 1.3.1", - "http-body-util", - "humantime", - "hyper 1.7.0", - "itertools 0.14.0", - "parking_lot", - "percent-encoding", - "quick-xml 0.38.3", - "rand 0.9.2", - "reqwest 0.12.24", - "ring", - "serde", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.17", - "tokio", - "tracing", - "url", - "walkdir", - "wasm-bindgen-futures", - "web-time", -] - [[package]] name = "ollama" version = "0.1.0" @@ -10920,7 +10898,6 @@ dependencies = [ "documented", "fs", "fuzzy", - "git", "gpui", "menu", "notifications", @@ -11530,7 +11507,7 @@ dependencies = [ [[package]] name = "pet" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "clap", "env_logger 0.10.2", @@ -11555,6 +11532,7 @@ dependencies = [ "pet-python-utils", "pet-reporter", "pet-telemetry", + "pet-uv", "pet-venv", "pet-virtualenv", "pet-virtualenvwrapper", @@ -11567,7 +11545,7 @@ dependencies = [ [[package]] name = "pet-conda" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11586,7 +11564,7 @@ dependencies = [ [[package]] name = "pet-core" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "clap", "lazy_static", @@ -11601,7 +11579,7 @@ dependencies = [ [[package]] name = "pet-env-var-path" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11617,7 +11595,7 @@ dependencies = [ [[package]] name = "pet-fs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11626,7 +11604,7 @@ dependencies = [ [[package]] name = "pet-global-virtualenvs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11639,7 +11617,7 @@ dependencies = [ [[package]] name = "pet-homebrew" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11657,7 +11635,7 @@ dependencies = [ [[package]] name = "pet-jsonrpc" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "log", @@ -11670,7 +11648,7 @@ dependencies = [ [[package]] name = "pet-linux-global-python" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11683,7 +11661,7 @@ dependencies = [ [[package]] name = "pet-mac-commandlinetools" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11696,7 +11674,7 @@ dependencies = [ [[package]] name = "pet-mac-python-org" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11709,7 +11687,7 @@ dependencies = [ [[package]] name = "pet-mac-xcode" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11722,7 +11700,7 @@ dependencies = [ [[package]] name = "pet-pipenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11735,7 +11713,7 @@ dependencies = [ [[package]] name = "pet-pixi" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11747,7 +11725,7 @@ dependencies = [ [[package]] name = "pet-poetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "base64 0.22.1", "lazy_static", @@ -11768,7 +11746,7 @@ dependencies = [ [[package]] name = "pet-pyenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11786,7 +11764,7 @@ dependencies = [ [[package]] name = "pet-python-utils" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11803,7 +11781,7 @@ dependencies = [ [[package]] name = "pet-reporter" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "log", @@ -11817,7 +11795,7 @@ dependencies = [ [[package]] name = "pet-telemetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11829,10 +11807,22 @@ dependencies = [ "regex", ] +[[package]] +name = "pet-uv" +version = "0.1.0" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" +dependencies = [ + "log", + "pet-core", + "pet-python-utils", + "serde", + "toml 0.9.8", +] + [[package]] name = "pet-venv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11844,7 +11834,7 @@ dependencies = [ [[package]] name = "pet-virtualenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11856,7 +11846,7 @@ dependencies = [ [[package]] name = "pet-virtualenvwrapper" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "log", "msvc_spectre_libs", @@ -11869,7 +11859,7 @@ dependencies = [ [[package]] name = "pet-windows-registry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -11887,7 +11877,7 @@ dependencies = [ [[package]] name = "pet-windows-store" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da#1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" dependencies = [ "lazy_static", "log", @@ -12123,16 +12113,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "planus" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3daf8e3d4b712abe1d690838f6e29fb76b76ea19589c4afa39ec30e12f62af71" -dependencies = [ - "array-init-cursor", - "hashbrown 0.15.5", -] - [[package]] name = "plist" version = "1.8.0" @@ -12201,646 +12181,132 @@ dependencies = [ ] [[package]] -name = "polars" -version = "0.51.0" +name = "polling" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5f7feb5d56b954e691dff22a8b2d78d77433dcc93c35fe21c3777fdc121b697" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "getrandom 0.2.16", - "getrandom 0.3.4", - "polars-arrow", - "polars-core", - "polars-error", - "polars-io", - "polars-lazy", - "polars-ops", - "polars-parquet", - "polars-sql", - "polars-time", - "polars-utils", - "version_check", + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] -name = "polars-arrow" -version = "0.51.0" +name = "pollster" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b4fed2343961b3eea3db2cee165540c3e1ad9d5782350cc55a9e76cf440148" -dependencies = [ - "atoi_simd", - "bitflags 2.9.4", - "bytemuck", - "chrono", - "chrono-tz", - "dyn-clone", - "either", - "ethnum", - "getrandom 0.2.16", - "getrandom 0.3.4", - "hashbrown 0.15.5", - "itoa", - "lz4", - "num-traits", - "polars-arrow-format", - "polars-error", - "polars-schema", - "polars-utils", - "serde", - "simdutf8", - "streaming-iterator", - "strum_macros 0.27.2", - "version_check", - "zstd 0.13.3", -] +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" [[package]] -name = "polars-arrow-format" -version = "0.2.1" +name = "pori" +version = "0.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a556ac0ee744e61e167f34c1eb0013ce740e0ee6cd8c158b2ec0b518f10e6675" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" dependencies = [ - "planus", - "serde", + "nom 7.1.3", ] [[package]] -name = "polars-compute" -version = "0.51.0" +name = "portable-atomic" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138785beda4e4a90a025219f09d0d15a671b2be9091513ede58e05db6ad4413f" -dependencies = [ - "atoi_simd", - "bytemuck", - "chrono", - "either", - "fast-float2", - "hashbrown 0.15.5", - "itoa", - "num-traits", - "polars-arrow", - "polars-error", - "polars-utils", - "rand 0.9.2", - "ryu", - "serde", - "skiplist", - "strength_reduce", - "strum_macros 0.27.2", - "version_check", -] +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] -name = "polars-core" -version = "0.51.0" +name = "portable-atomic-util" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e77b1f08ef6dbb032bb1d0d3365464be950df9905f6827a95b24c4ca5518901d" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" dependencies = [ - "bitflags 2.9.4", - "boxcar", - "bytemuck", - "chrono", - "chrono-tz", - "comfy-table", - "either", - "hashbrown 0.15.5", - "indexmap", - "itoa", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-dtype", - "polars-error", - "polars-row", - "polars-schema", - "polars-utils", - "rand 0.9.2", - "rand_distr", - "rayon", - "regex", - "serde", - "serde_json", - "strum_macros 0.27.2", - "uuid", - "version_check", - "xxhash-rust", + "portable-atomic", ] [[package]] -name = "polars-dtype" -version = "0.51.0" +name = "portable-pty" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89c43d0ea57168be4546c4d8064479ed8b29a9c79c31a0c7c367ee734b9b7158" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" dependencies = [ - "boxcar", - "hashbrown 0.15.5", - "polars-arrow", - "polars-error", - "polars-utils", - "serde", - "uuid", + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", ] [[package]] -name = "polars-error" -version = "0.51.0" +name = "postage" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cb5d98f59f8b94673ee391840440ad9f0d2170afced95fc98aa86f895563c0" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" dependencies = [ - "object_store", + "atomic", + "crossbeam-queue", + "futures 0.3.31", + "log", "parking_lot", - "polars-arrow-format", - "regex", - "signal-hook", - "simdutf8", + "pin-project", + "pollster", + "static_assertions", + "thiserror 1.0.69", ] [[package]] -name = "polars-expr" -version = "0.51.0" +name = "postcard" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343931b818cf136349135ba11dbc18c27683b52c3477b1ba8ca606cf5ab1965c" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" dependencies = [ - "bitflags 2.9.4", - "hashbrown 0.15.5", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-io", - "polars-ops", - "polars-plan", - "polars-row", - "polars-time", - "polars-utils", - "rand 0.9.2", - "rayon", - "recursive", + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", ] [[package]] -name = "polars-io" -version = "0.51.0" +name = "potential_utf" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10388c64b8155122488229a881d1c6f4fdc393bc988e764ab51b182fcb2307e4" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ - "async-trait", - "atoi_simd", - "blake3", - "bytes 1.10.1", - "chrono", - "fast-float2", - "fs4", - "futures 0.3.31", - "glob", - "hashbrown 0.15.5", - "home", - "itoa", - "memchr", - "memmap2", - "num-traits", - "object_store", - "percent-encoding", - "polars-arrow", - "polars-core", - "polars-error", - "polars-parquet", - "polars-schema", - "polars-time", - "polars-utils", - "rayon", - "regex", - "reqwest 0.12.24", - "ryu", - "serde", - "serde_json", - "simdutf8", - "tokio", - "tokio-util", - "url", + "zerovec", ] [[package]] -name = "polars-lazy" -version = "0.51.0" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb6e2c6c2fa4ea0c660df1c06cf56960c81e7c2683877995bae3d4e3d408147" -dependencies = [ - "bitflags 2.9.4", - "chrono", - "either", - "memchr", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-expr", - "polars-io", - "polars-mem-engine", - "polars-ops", - "polars-plan", - "polars-stream", - "polars-time", - "polars-utils", - "rayon", - "version_check", -] +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "polars-mem-engine" -version = "0.51.0" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a856e98e253587c28d8132a5e7e5a75cb2c44731ca090f1481d45f1d123771" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "futures 0.3.31", - "memmap2", - "polars-arrow", - "polars-core", - "polars-error", - "polars-expr", - "polars-io", - "polars-ops", - "polars-plan", - "polars-time", - "polars-utils", - "rayon", - "recursive", - "tokio", + "zerocopy", ] [[package]] -name = "polars-ops" -version = "0.51.0" +name = "precomputed-hash" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf6062173fdc9ba05775548beb66e76643a148d9aeadc9984ed712bc4babd76" -dependencies = [ - "argminmax", - "base64 0.22.1", - "bytemuck", - "chrono", - "chrono-tz", - "either", - "hashbrown 0.15.5", - "hex", - "indexmap", - "libm", - "memchr", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-error", - "polars-schema", - "polars-utils", - "rayon", - "regex", - "regex-syntax", - "strum_macros 0.27.2", - "unicode-normalization", - "unicode-reverse", - "version_check", -] - -[[package]] -name = "polars-parquet" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1d769180dec070df0dc4b89299b364bf2cfe32b218ecc4ddd8f1a49ae60669" -dependencies = [ - "async-stream", - "base64 0.22.1", - "brotli", - "bytemuck", - "ethnum", - "flate2", - "futures 0.3.31", - "hashbrown 0.15.5", - "lz4", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-error", - "polars-parquet-format", - "polars-utils", - "serde", - "simdutf8", - "snap", - "streaming-decompression", - "zstd 0.13.3", -] - -[[package]] -name = "polars-parquet-format" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c025243dcfe8dbc57e94d9f82eb3bef10b565ab180d5b99bed87fd8aea319ce1" -dependencies = [ - "async-trait", - "futures 0.3.31", -] - -[[package]] -name = "polars-plan" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd3a2e33ae4484fe407ab2d2ba5684f0889d1ccf3ad6b844103c03638e6d0a0" -dependencies = [ - "bitflags 2.9.4", - "bytemuck", - "bytes 1.10.1", - "chrono", - "chrono-tz", - "either", - "futures 0.3.31", - "hashbrown 0.15.5", - "memmap2", - "num-traits", - "percent-encoding", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-error", - "polars-io", - "polars-ops", - "polars-parquet", - "polars-time", - "polars-utils", - "rayon", - "recursive", - "regex", - "sha2", - "strum_macros 0.27.2", - "version_check", -] - -[[package]] -name = "polars-row" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18734f17e0e348724df3ae65f3ee744c681117c04b041cac969dfceb05edabc0" -dependencies = [ - "bitflags 2.9.4", - "bytemuck", - "polars-arrow", - "polars-compute", - "polars-dtype", - "polars-error", - "polars-utils", -] - -[[package]] -name = "polars-schema" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6c1ab13e04d5167661a9854ed1ea0482b2ed9b8a0f1118dabed7cd994a85e3" -dependencies = [ - "indexmap", - "polars-error", - "polars-utils", - "serde", - "version_check", -] - -[[package]] -name = "polars-sql" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e7766da02cc1d464994404d3e88a7a0ccd4933df3627c325480fbd9bbc0a11" -dependencies = [ - "bitflags 2.9.4", - "hex", - "polars-core", - "polars-error", - "polars-lazy", - "polars-ops", - "polars-plan", - "polars-time", - "polars-utils", - "rand 0.9.2", - "regex", - "serde", - "sqlparser", -] - -[[package]] -name = "polars-stream" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f6c6ca1ea01f9dea424d167e4f33f5ec44cd67fbfac9efd40575ed20521f14" -dependencies = [ - "async-channel 2.5.0", - "async-trait", - "atomic-waker", - "bitflags 2.9.4", - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-queue", - "crossbeam-utils", - "futures 0.3.31", - "memmap2", - "parking_lot", - "percent-encoding", - "pin-project-lite", - "polars-arrow", - "polars-core", - "polars-error", - "polars-expr", - "polars-io", - "polars-mem-engine", - "polars-ops", - "polars-parquet", - "polars-plan", - "polars-utils", - "rand 0.9.2", - "rayon", - "recursive", - "slotmap", - "tokio", - "tokio-util", - "version_check", -] - -[[package]] -name = "polars-time" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6a3a6e279a7a984a0b83715660f9e880590c6129ec2104396bfa710bcd76dee" -dependencies = [ - "atoi_simd", - "bytemuck", - "chrono", - "chrono-tz", - "now", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-error", - "polars-ops", - "polars-utils", - "rayon", - "regex", - "strum_macros 0.27.2", -] - -[[package]] -name = "polars-utils" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57b267021b0e5422d7fbc70fd79e51b9f9a8466c585779373a18b0199e973f29" -dependencies = [ - "bincode 2.0.1", - "bytemuck", - "bytes 1.10.1", - "compact_str", - "either", - "flate2", - "foldhash 0.1.5", - "hashbrown 0.15.5", - "indexmap", - "libc", - "memmap2", - "num-traits", - "polars-error", - "rand 0.9.2", - "raw-cpuid 11.6.0", - "rayon", - "regex", - "rmp-serde", - "serde", - "serde_json", - "serde_stacker", - "slotmap", - "stacker", - "uuid", - "version_check", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" - -[[package]] -name = "pori" -version = "0.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix 0.28.0", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - -[[package]] -name = "postage" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" -dependencies = [ - "atomic", - "crossbeam-queue", - "futures 0.3.31", - "log", - "parking_lot", - "pin-project", - "pollster", - "static_assertions", - "thiserror 1.0.69", -] - -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "serde", -] - -[[package]] -name = "potential_utf" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "prettier" -version = "0.1.0" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettier" +version = "0.1.0" dependencies = [ "anyhow", "collections", @@ -13009,8 +12475,10 @@ dependencies = [ "context_server", "dap", "dap_adapters", + "db", + "encoding_rs", "extension", - "fancy-regex 0.14.0", + "fancy-regex", "fs", "futures 0.3.31", "fuzzy", @@ -13055,6 +12523,7 @@ dependencies = [ "terminal", "text", "toml 0.8.23", + "tracing", "unindent", "url", "util", @@ -13064,6 +12533,7 @@ dependencies = [ "worktree", "zeroize", "zlog", + "ztracing", ] [[package]] @@ -13100,6 +12570,7 @@ dependencies = [ "gpui", "language", "menu", + "notifications", "pretty_assertions", "project", "rayon", @@ -13177,6 +12648,8 @@ dependencies = [ "paths", "rope", "serde", + "strum 0.27.2", + "tempfile", "text", "util", "uuid", @@ -13380,13 +12853,12 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" [[package]] name = "pulley-interpreter" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d" +checksum = "986beaef947a51d17b42b0ea18ceaa88450d35b6994737065ed505c39172db71" dependencies = [ "cranelift-bitset", "log", - "sptr", "wasmtime-math", ] @@ -13465,7 +12937,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ "memchr", - "serde", ] [[package]] @@ -13767,6 +13238,7 @@ dependencies = [ "askpass", "auto_update", "dap", + "db", "editor", "extension_host", "file_finder", @@ -13778,6 +13250,7 @@ dependencies = [ "log", "markdown", "menu", + "node_runtime", "ordered-float 2.10.1", "paths", "picker", @@ -13796,29 +13269,10 @@ dependencies = [ "util", "windows-registry 0.6.1", "workspace", + "worktree", "zed_actions", ] -[[package]] -name = "recursive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" -dependencies = [ - "recursive-proc-macro-impl", - "stacker", -] - -[[package]] -name = "recursive-proc-macro-impl" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" -dependencies = [ - "quote", - "syn 2.0.106", -] - [[package]] name = "redox_syscall" version = "0.2.16" @@ -13881,13 +13335,14 @@ dependencies = [ [[package]] name = "referencing" -version = "0.30.0" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" +checksum = "4283168a506f0dcbdce31c9f9cce3129c924da4c6bca46e46707fcb746d2d70c" dependencies = [ "ahash 0.8.12", "fluent-uri", - "once_cell", + "getrandom 0.3.4", + "hashbrown 0.16.1", "parking_lot", "percent-encoding", "serde_json", @@ -13902,9 +13357,9 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.11.2" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" +checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" dependencies = [ "allocator-api2", "bumpalo", @@ -14174,35 +13629,26 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "http-body-util", "hyper 1.7.0", - "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", - "quinn", - "rustls 0.23.33", - "rustls-native-certs 0.8.2", - "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tokio-rustls 0.26.2", - "tokio-util", "tower 0.5.2", "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", ] @@ -14325,17 +13771,6 @@ dependencies = [ "paste", ] -[[package]] -name = "rmp-serde" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" -dependencies = [ - "byteorder", - "rmp", - "serde", -] - [[package]] name = "rmpv" version = "1.3.0" @@ -14372,9 +13807,11 @@ dependencies = [ "rand 0.9.2", "rayon", "sum_tree", + "tracing", "unicode-segmentation", "util", "zlog", + "ztracing", ] [[package]] @@ -14405,7 +13842,7 @@ dependencies = [ "tracing", "util", "zlog", - "zstd 0.11.2+zstd.1.5.2", + "zstd", ] [[package]] @@ -14870,6 +14307,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "settings", "theme", ] @@ -15088,6 +14526,7 @@ dependencies = [ "editor", "futures 0.3.31", "gpui", + "itertools 0.14.0", "language", "lsp", "menu", @@ -15099,12 +14538,14 @@ dependencies = [ "settings", "smol", "theme", + "tracing", "ui", "unindent", "util", "util_macros", "workspace", "zed_actions", + "ztracing", ] [[package]] @@ -15296,17 +14737,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_stacker" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" -dependencies = [ - "serde", - "serde_core", - "stacker", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -15440,6 +14870,8 @@ dependencies = [ "assets", "bm25", "client", + "copilot", + "edit_prediction", "editor", "feature_flags", "fs", @@ -15448,6 +14880,7 @@ dependencies = [ "gpui", "heck 0.5.0", "language", + "language_models", "log", "menu", "node_runtime", @@ -15648,16 +15081,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" -[[package]] -name = "skiplist" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354fd282d3177c2951004953e2fdc4cb342fa159bbee8b829852b6a081c8ea1" -dependencies = [ - "rand 0.9.2", - "thiserror 2.0.17", -] - [[package]] name = "skrifa" version = "0.37.0" @@ -15733,12 +15156,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -[[package]] -name = "snap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" - [[package]] name = "snippet" version = "0.1.0" @@ -15785,26 +15202,6 @@ dependencies = [ "workspace", ] -[[package]] -name = "soa-rs" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75ae4668062b095fda87ba54118697bed601f07f6c68bf50289a25ca0c8c935" -dependencies = [ - "soa-rs-derive", -] - -[[package]] -name = "soa-rs-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c09121507da587d3434e5929ce3321162f36bd3eff403873cb163c06b176913" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "socket2" version = "0.5.10" @@ -15924,15 +15321,6 @@ dependencies = [ "unicode_categories", ] -[[package]] -name = "sqlparser" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05a528114c392209b3264855ad491fcce534b94a38771b0a0b97a79379275ce8" -dependencies = [ - "log", -] - [[package]] name = "sqlx" version = "0.8.6" @@ -16234,15 +15622,6 @@ dependencies = [ "ui", ] -[[package]] -name = "streaming-decompression" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf6cc3b19bfb128a8ad11026086e31d3ce9ad23f8ea37354b31383a187c44cf3" -dependencies = [ - "fallible-streaming-iterator", -] - [[package]] name = "streaming-iterator" version = "0.1.9" @@ -16374,7 +15753,9 @@ dependencies = [ "log", "rand 0.9.2", "rayon", + "tracing", "zlog", + "ztracing", ] [[package]] @@ -16384,7 +15765,7 @@ dependencies = [ "anyhow", "client", "collections", - "edit_prediction", + "edit_prediction_types", "editor", "env_logger 0.11.8", "futures 0.3.31", @@ -17080,13 +16461,13 @@ dependencies = [ "alacritty_terminal", "anyhow", "collections", - "fancy-regex 0.14.0", "futures 0.3.31", "gpui", "itertools 0.14.0", "libc", "log", "rand 0.9.2", + "regex", "release_channel", "schemars", "serde", @@ -17313,13 +16694,13 @@ dependencies = [ [[package]] name = "tiktoken-rs" -version = "0.8.0" -source = "git+https://github.com/zed-industries/tiktoken-rs?rev=30c32a4522751699adeda0d5840c71c3b75ae73d#30c32a4522751699adeda0d5840c71c3b75ae73d" +version = "0.9.1" +source = "git+https://github.com/zed-industries/tiktoken-rs?rev=2570c4387a8505fb8f1d3f3557454b474f1e8271#2570c4387a8505fb8f1d3f3557454b474f1e8271" dependencies = [ "anyhow", "base64 0.22.1", "bstr", - "fancy-regex 0.13.0", + "fancy-regex", "lazy_static", "regex", "rustc-hash 1.1.0", @@ -17628,7 +17009,6 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", - "futures-util", "pin-project-lite", "tokio", ] @@ -17860,9 +17240,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -17872,9 +17252,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -17883,9 +17263,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -17914,9 +17294,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -17933,6 +17313,38 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-tracy" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eaa1852afa96e0fe9e44caa53dc0bd2d9d05e0f2611ce09f97f8677af56e4ba" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracy-client", +] + +[[package]] +name = "tracy-client" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d722a05fe49b31fef971c4732a7d4aa6a18283d9ba46abddab35f484872947" +dependencies = [ + "loom", + "once_cell", + "tracy-client-sys", +] + +[[package]] +name = "tracy-client-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" +dependencies = [ + "cc", + "windows-targets 0.52.6", +] + [[package]] name = "trait-variant" version = "0.1.2" @@ -17956,9 +17368,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.25.10" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e" dependencies = [ "cc", "regex", @@ -17971,9 +17383,9 @@ dependencies = [ [[package]] name = "tree-sitter-bash" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "871b0606e667e98a1237ebdc1b0d7056e0aebfdc3141d12b399865d4cb6ed8a6" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" dependencies = [ "cc", "tree-sitter-language", @@ -18451,6 +17863,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -18478,15 +17896,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" -[[package]] -name = "unicode-reverse" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6f4888ebc23094adfb574fdca9fdc891826287a6397d2cd28802ffd6f20c76" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "unicode-script" version = "0.5.7" @@ -18547,12 +17956,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "url" version = "2.5.7" @@ -18685,7 +18088,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" dependencies = [ "outref", - "uuid", "vsimd", ] @@ -18793,9 +18195,11 @@ dependencies = [ "language", "log", "lsp", + "markdown_preview", "menu", "multi_buffer", "nvim-rs", + "outline_panel", "parking_lot", "perf", "picker", @@ -18829,12 +18233,6 @@ dependencies = [ "settings", ] -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - [[package]] name = "vscode_theme" version = "0.2.0" @@ -19059,6 +18457,16 @@ dependencies = [ "wasmparser 0.227.1", ] +[[package]] +name = "wasm-encoder" +version = "0.229.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ba1d491ecacb085a2552025c10a675a6fddcbd03b1fc9b36c536010ce265d2" +dependencies = [ + "leb128fmt", + "wasmparser 0.229.0", +] + [[package]] name = "wasm-metadata" version = "0.201.0" @@ -19143,23 +18551,37 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.229.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3b1f053f5d41aa55640a1fa9b6d1b8a9e4418d118ce308d20e24ff3575a8c" +dependencies = [ + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", +] + [[package]] name = "wasmprinter" -version = "0.221.3" +version = "0.229.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" +checksum = "d25dac01892684a99b8fbfaf670eb6b56edea8a096438c75392daeb83156ae2e" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.221.3", + "wasmparser 0.229.0", ] [[package]] name = "wasmtime" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" +checksum = "57373e1d8699662fb791270ac5dfac9da5c14f618ecf940cdb29dc3ad9472a3c" dependencies = [ + "addr2line 0.24.2", "anyhow", "async-trait", "bitflags 2.9.4", @@ -19167,7 +18589,7 @@ dependencies = [ "cc", "cfg-if", "encoding_rs", - "hashbrown 0.14.5", + "hashbrown 0.15.5", "indexmap", "libc", "log", @@ -19175,12 +18597,11 @@ dependencies = [ "memfd", "object 0.36.7", "once_cell", - "paste", "postcard", "psm", "pulley-interpreter", "rayon", - "rustix 0.38.44", + "rustix 1.1.2", "semver", "serde", "serde_derive", @@ -19188,7 +18609,7 @@ dependencies = [ "sptr", "target-lexicon 0.13.3", "trait-variant", - "wasmparser 0.221.3", + "wasmparser 0.229.0", "wasmtime-asm-macros", "wasmtime-component-macro", "wasmtime-component-util", @@ -19205,18 +18626,18 @@ dependencies = [ [[package]] name = "wasmtime-asm-macros" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2" +checksum = "bd0fc91372865167a695dc98d0d6771799a388a7541d3f34e939d0539d6583de" dependencies = [ "cfg-if", ] [[package]] name = "wasmtime-c-api-impl" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea30cef3608f2de5797c7bbb94c1ba4f3676d9a7f81ae86ced1b512e2766ed0c" +checksum = "46db556f1dccdd88e0672bd407162ab0036b72e5eccb0f4398d8251cba32dba1" dependencies = [ "anyhow", "log", @@ -19227,9 +18648,9 @@ dependencies = [ [[package]] name = "wasmtime-c-api-macros" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022a79ebe1124d5d384d82463d7e61c6b4dd857d81f15cb8078974eeb86db65b" +checksum = "315cc6bc8cdc66f296accb26d7625ae64c1c7b6da6f189e8a72ce6594bf7bd36" dependencies = [ "proc-macro2", "quote", @@ -19237,9 +18658,9 @@ dependencies = [ [[package]] name = "wasmtime-component-macro" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf" +checksum = "25c9c7526675ff9a9794b115023c4af5128e3eb21389bfc3dc1fd344d549258f" dependencies = [ "anyhow", "proc-macro2", @@ -19247,20 +18668,20 @@ dependencies = [ "syn 2.0.106", "wasmtime-component-util", "wasmtime-wit-bindgen", - "wit-parser 0.221.3", + "wit-parser 0.229.0", ] [[package]] name = "wasmtime-component-util" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e" +checksum = "cc42ec8b078875804908d797cb4950fec781d9add9684c9026487fd8eb3f6291" [[package]] name = "wasmtime-cranelift" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87" +checksum = "b2bd72f0a6a0ffcc6a184ec86ac35c174e48ea0e97bbae277c8f15f8bf77a566" dependencies = [ "anyhow", "cfg-if", @@ -19270,22 +18691,23 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli 0.31.1", - "itertools 0.12.1", + "itertools 0.14.0", "log", "object 0.36.7", + "pulley-interpreter", "smallvec", "target-lexicon 0.13.3", - "thiserror 1.0.69", - "wasmparser 0.221.3", + "thiserror 2.0.17", + "wasmparser 0.229.0", "wasmtime-environ", "wasmtime-versioned-export-macros", ] [[package]] name = "wasmtime-environ" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad" +checksum = "e6187bb108a23eb25d2a92aa65d6c89fb5ed53433a319038a2558567f3011ff2" dependencies = [ "anyhow", "cpp_demangle", @@ -19302,22 +18724,22 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon 0.13.3", - "wasm-encoder 0.221.3", - "wasmparser 0.221.3", + "wasm-encoder 0.229.0", + "wasmparser 0.229.0", "wasmprinter", "wasmtime-component-util", ] [[package]] name = "wasmtime-fiber" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117" +checksum = "dc8965d2128c012329f390e24b8b2758dd93d01bf67e1a1a0dd3d8fd72f56873" dependencies = [ "anyhow", "cc", "cfg-if", - "rustix 0.38.44", + "rustix 1.1.2", "wasmtime-asm-macros", "wasmtime-versioned-export-macros", "windows-sys 0.59.0", @@ -19325,9 +18747,9 @@ dependencies = [ [[package]] name = "wasmtime-jit-icache-coherence" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1" +checksum = "7af0e940cb062a45c0b3f01a926f77da5947149e99beb4e3dd9846d5b8f11619" dependencies = [ "anyhow", "cfg-if", @@ -19337,24 +18759,24 @@ dependencies = [ [[package]] name = "wasmtime-math" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17" +checksum = "acfca360e719dda9a27e26944f2754ff2fd5bad88e21919c42c5a5f38ddd93cb" dependencies = [ "libm", ] [[package]] name = "wasmtime-slab" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf" +checksum = "48e240559cada55c4b24af979d5f6c95e0029f5772f32027ec3c62b258aaff65" [[package]] name = "wasmtime-versioned-export-macros" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" +checksum = "d0963c1438357a3d8c0efe152b4ef5259846c1cf8b864340270744fe5b3bae5e" dependencies = [ "proc-macro2", "quote", @@ -19363,9 +18785,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d1be69bfcab1bdac74daa7a1f9695ab992b9c8e21b9b061e7d66434097e0ca4" +checksum = "4ae951b72c7c6749a1c15dcdfb6d940a2614c932b4a54f474636e78e2c744b4c" dependencies = [ "anyhow", "async-trait", @@ -19380,30 +18802,43 @@ dependencies = [ "futures 0.3.31", "io-extras", "io-lifetimes", - "rustix 0.38.44", + "rustix 1.1.2", "system-interface", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tracing", - "trait-variant", "url", "wasmtime", + "wasmtime-wasi-io", "wiggle", "windows-sys 0.59.0", ] +[[package]] +name = "wasmtime-wasi-io" +version = "33.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a835790dcecc3d7051ec67da52ba9e04af25e1bc204275b9391e3f0042b10797" +dependencies = [ + "anyhow", + "async-trait", + "bytes 1.10.1", + "futures 0.3.31", + "wasmtime", +] + [[package]] name = "wasmtime-winch" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f" +checksum = "cbc3b117d03d6eeabfa005a880c5c22c06503bb8820f3aa2e30f0e8d87b6752f" dependencies = [ "anyhow", "cranelift-codegen", "gimli 0.31.1", "object 0.36.7", "target-lexicon 0.13.3", - "wasmparser 0.221.3", + "wasmparser 0.229.0", "wasmtime-cranelift", "wasmtime-environ", "winch-codegen", @@ -19411,14 +18846,14 @@ dependencies = [ [[package]] name = "wasmtime-wit-bindgen" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" +checksum = "1382f4f09390eab0d75d4994d0c3b0f6279f86a571807ec67a8253c87cf6a145" dependencies = [ "anyhow", "heck 0.5.0", "indexmap", - "wit-parser 0.221.3", + "wit-parser 0.229.0", ] [[package]] @@ -19494,18 +18929,6 @@ dependencies = [ "xcursor", ] -[[package]] -name = "wayland-protocols" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" -dependencies = [ - "bitflags 2.9.4", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - [[package]] name = "wayland-protocols" version = "0.32.9" @@ -19520,14 +18943,14 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.2.0" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ "bitflags 2.9.4", "wayland-backend", "wayland-client", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-scanner", ] @@ -19540,7 +18963,7 @@ dependencies = [ "bitflags 2.9.4", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.9", + "wayland-protocols", "wayland-scanner", ] @@ -19700,6 +19123,20 @@ dependencies = [ "winsafe", ] +[[package]] +name = "which_key" +version = "0.1.0" +dependencies = [ + "command_palette", + "gpui", + "serde", + "settings", + "theme", + "ui", + "util", + "workspace", +] + [[package]] name = "whoami" version = "1.6.1" @@ -19712,14 +19149,14 @@ dependencies = [ [[package]] name = "wiggle" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9af35bc9629c52c261465320a9a07959164928b4241980ba1cf923b9e6751d" +checksum = "649c1aca13ef9e9dccf2d5efbbebf12025bc5521c3fb7754355ef60f5eb810be" dependencies = [ "anyhow", "async-trait", "bitflags 2.9.4", - "thiserror 1.0.69", + "thiserror 2.0.17", "tracing", "wasmtime", "wiggle-macro", @@ -19727,24 +19164,23 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf267dd05673912c8138f4b54acabe6bd53407d9d1536f0fadb6520dd16e101" +checksum = "164870fc34214ee42bd81b8ce9e7c179800fa1a7d4046d17a84e7f7bf422c8ad" dependencies = [ "anyhow", "heck 0.5.0", "proc-macro2", "quote", - "shellexpand 2.1.2", "syn 2.0.106", "witx", ] [[package]] name = "wiggle-macro" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c5c473d4198e6c2d377f3809f713ff0c110cab88a0805ae099a82119ee250c" +checksum = "d873bb5b59ca703b5e41562e96a4796d1af61bf4cf80bf8a7abda755a380ec1c" dependencies = [ "proc-macro2", "quote", @@ -19785,18 +19221,19 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c" +checksum = "7914c296fbcef59d1b89a15e82384d34dc9669bc09763f2ef068a28dd3a64ebf" dependencies = [ "anyhow", + "cranelift-assembler-x64", "cranelift-codegen", "gimli 0.31.1", "regalloc2", "smallvec", "target-lexicon 0.13.3", - "thiserror 1.0.69", - "wasmparser 0.221.3", + "thiserror 2.0.17", + "wasmparser 0.229.0", "wasmtime-cranelift", "wasmtime-environ", ] @@ -20694,9 +20131,9 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.221.3" +version = "0.227.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", @@ -20707,14 +20144,14 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.221.3", + "wasmparser 0.227.1", ] [[package]] name = "wit-parser" -version = "0.227.1" +version = "0.229.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +checksum = "459c6ba62bf511d6b5f2a845a2a736822e38059c1cfa0b644b467bbbfae4efa6" dependencies = [ "anyhow", "id-arena", @@ -20725,7 +20162,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.227.1", + "wasmparser 0.229.0", ] [[package]] @@ -20754,13 +20191,16 @@ dependencies = [ "component", "dap", "db", + "feature_flags", "fs", "futures 0.3.31", + "git", "gpui", "http_client", "itertools 0.14.0", "language", "log", + "markdown", "menu", "node_runtime", "parking_lot", @@ -20794,8 +20234,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-lock 2.8.0", + "chardetng", "clock", "collections", + "encoding_rs", "fs", "futures 0.3.31", "fuzzy", @@ -20823,6 +20265,16 @@ dependencies = [ "zlog", ] +[[package]] +name = "worktree_benchmarks" +version = "0.1.0" +dependencies = [ + "fs", + "gpui", + "settings", + "worktree", +] + [[package]] name = "writeable" version = "0.6.1" @@ -20985,16 +20437,11 @@ dependencies = [ "indexmap", "indoc", "serde", + "serde_json", "toml 0.8.23", "toml_edit 0.22.27", ] -[[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - [[package]] name = "yaml-rust2" version = "0.8.1" @@ -21169,12 +20616,13 @@ dependencies = [ [[package]] name = "zed" -version = "0.215.0" +version = "0.219.0" dependencies = [ "acp_tools", "activity_indicator", "agent_settings", "agent_ui", + "agent_ui_v2", "anyhow", "ashpd 0.11.0", "askpass", @@ -21182,7 +20630,7 @@ dependencies = [ "audio", "auto_update", "auto_update_ui", - "bincode 1.3.3", + "bincode", "breadcrumbs", "call", "channel", @@ -21204,7 +20652,8 @@ dependencies = [ "debugger_tools", "debugger_ui", "diagnostics", - "edit_prediction_button", + "edit_prediction", + "edit_prediction_ui", "editor", "env_logger 0.11.8", "extension", @@ -21295,6 +20744,7 @@ dependencies = [ "time", "title_bar", "toolchain_selector", + "tracing", "tree-sitter-md", "tree-sitter-rust", "ui", @@ -21309,17 +20759,16 @@ dependencies = [ "watch", "web_search", "web_search_providers", + "which_key", "windows 0.61.3", "winresource", "workspace", "zed-reqwest", "zed_actions", "zed_env_vars", - "zeta", - "zeta2", - "zeta2_tools", "zlog", "zlog_settings", + "ztracing", ] [[package]] @@ -21460,6 +20909,8 @@ dependencies = [ [[package]] name = "zed_extension_api" version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" dependencies = [ "serde", "serde_json", @@ -21468,9 +20919,7 @@ dependencies = [ [[package]] name = "zed_extension_api" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" +version = "0.8.0" dependencies = [ "serde", "serde_json", @@ -21486,23 +20935,23 @@ dependencies = [ [[package]] name = "zed_html" -version = "0.2.3" +version = "0.3.0" dependencies = [ - "zed_extension_api 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "zed_extension_api 0.7.0", ] [[package]] name = "zed_proto" -version = "0.2.2" +version = "0.3.0" dependencies = [ - "zed_extension_api 0.1.0", + "zed_extension_api 0.7.0", ] [[package]] name = "zed_test_extension" version = "0.1.0" dependencies = [ - "zed_extension_api 0.7.0", + "zed_extension_api 0.8.0", ] [[package]] @@ -21630,188 +21079,10 @@ dependencies = [ ] [[package]] -name = "zeta" -version = "0.1.0" -dependencies = [ - "ai_onboarding", - "anyhow", - "arrayvec", - "call", - "client", - "clock", - "cloud_api_types", - "cloud_llm_client", - "collections", - "command_palette_hooks", - "copilot", - "ctor", - "db", - "edit_prediction", - "editor", - "feature_flags", - "fs", - "futures 0.3.31", - "gpui", - "http_client", - "indoc", - "itertools 0.14.0", - "language", - "language_model", - "log", - "menu", - "parking_lot", - "postage", - "project", - "rand 0.9.2", - "regex", - "release_channel", - "reqwest_client", - "rpc", - "semver", - "serde", - "serde_json", - "settings", - "strum 0.27.2", - "telemetry", - "telemetry_events", - "theme", - "thiserror 2.0.17", - "tree-sitter-go", - "tree-sitter-rust", - "ui", - "util", - "uuid", - "workspace", - "worktree", - "zed_actions", - "zlog", -] - -[[package]] -name = "zeta2" -version = "0.1.0" -dependencies = [ - "anyhow", - "arrayvec", - "brotli", - "chrono", - "client", - "clock", - "cloud_llm_client", - "cloud_zeta2_prompt", - "collections", - "edit_prediction", - "edit_prediction_context", - "feature_flags", - "futures 0.3.31", - "gpui", - "indoc", - "language", - "language_model", - "log", - "lsp", - "open_ai", - "pretty_assertions", - "project", - "release_channel", - "semver", - "serde", - "serde_json", - "settings", - "smol", - "strsim", - "thiserror 2.0.17", - "util", - "uuid", - "workspace", - "worktree", - "zlog", -] - -[[package]] -name = "zeta2_tools" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "clap", - "client", - "cloud_llm_client", - "cloud_zeta2_prompt", - "collections", - "edit_prediction_context", - "editor", - "feature_flags", - "futures 0.3.31", - "gpui", - "indoc", - "language", - "log", - "multi_buffer", - "ordered-float 2.10.1", - "pretty_assertions", - "project", - "serde", - "serde_json", - "settings", - "telemetry", - "text", - "ui", - "ui_input", - "util", - "workspace", - "zeta2", - "zlog", -] - -[[package]] -name = "zeta_cli" +name = "zeta_prompt" version = "0.1.0" dependencies = [ - "anyhow", - "chrono", - "clap", - "client", - "cloud_llm_client", - "cloud_zeta2_prompt", - "collections", - "debug_adapter_extension", - "edit_prediction_context", - "extension", - "fs", - "futures 0.3.31", - "gpui", - "gpui_tokio", - "indoc", - "language", - "language_extension", - "language_model", - "language_models", - "languages", - "log", - "node_runtime", - "ordered-float 2.10.1", - "paths", - "polars", - "pretty_assertions", - "project", - "prompt_store", - "pulldown-cmark 0.12.2", - "release_channel", - "reqwest_client", "serde", - "serde_json", - "settings", - "shellexpand 2.1.2", - "smol", - "soa-rs", - "terminal_view", - "toml 0.8.23", - "util", - "watch", - "zeta", - "zeta2", - "zlog", ] [[package]] @@ -21823,7 +21094,7 @@ dependencies = [ "aes", "byteorder", "bzip2", - "constant_time_eq 0.1.5", + "constant_time_eq", "crc32fast", "crossbeam-utils", "flate2", @@ -21831,7 +21102,7 @@ dependencies = [ "pbkdf2 0.11.0", "sha1", "time", - "zstd 0.11.2+zstd.1.5.2", + "zstd", ] [[package]] @@ -21849,12 +21120,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "zlib-rs" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" - [[package]] name = "zlog" version = "0.1.0" @@ -21882,16 +21147,7 @@ version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe 7.2.4", + "zstd-safe", ] [[package]] @@ -21904,15 +21160,6 @@ dependencies = [ "zstd-sys", ] -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - [[package]] name = "zstd-sys" version = "2.0.16+zstd.1.5.7" @@ -21923,6 +21170,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "ztracing" +version = "0.1.0" +dependencies = [ + "tracing", + "tracing-subscriber", + "tracing-tracy", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index e3ba2cb817357f5733179864bc23161d01aa1123..b507e8824484ea670619b5225fef9cfd41c81d4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/agent_servers", "crates/agent_settings", "crates/agent_ui", + "crates/agent_ui_v2", "crates/ai_onboarding", "crates/anthropic", "crates/askpass", @@ -32,7 +33,6 @@ members = [ "crates/cloud_api_client", "crates/cloud_api_types", "crates/cloud_llm_client", - "crates/cloud_zeta2_prompt", "crates/collab", "crates/collab_ui", "crates/collections", @@ -54,11 +54,12 @@ members = [ "crates/diagnostics", "crates/docs_preprocessor", "crates/edit_prediction", - "crates/edit_prediction_button", + "crates/edit_prediction_types", + "crates/edit_prediction_ui", "crates/edit_prediction_context", - "crates/zeta2_tools", "crates/editor", "crates/eval", + "crates/eval_utils", "crates/explorer_command_injector", "crates/extension", "crates/extension_api", @@ -191,20 +192,23 @@ members = [ "crates/vercel", "crates/vim", "crates/vim_mode_setting", + "crates/which_key", "crates/watch", "crates/web_search", "crates/web_search_providers", "crates/workspace", "crates/worktree", + "crates/worktree_benchmarks", "crates/x_ai", "crates/zed", "crates/zed_actions", "crates/zed_env_vars", - "crates/zeta", - "crates/zeta2", - "crates/zeta_cli", + "crates/edit_prediction_cli", + "crates/zeta_prompt", "crates/zlog", "crates/zlog_settings", + "crates/ztracing", + "crates/ztracing_macro", # # Extensions @@ -241,9 +245,9 @@ action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } +agent_ui_v2 = { path = "crates/agent_ui_v2" } agent_settings = { path = "crates/agent_settings" } agent_servers = { path = "crates/agent_servers" } -ai = { path = "crates/ai" } ai_onboarding = { path = "crates/ai_onboarding" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } @@ -253,7 +257,6 @@ assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_slash_commands = { path = "crates/assistant_slash_commands" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } -auto_update_helper = { path = "crates/auto_update_helper" } auto_update_ui = { path = "crates/auto_update_ui" } aws_http_client = { path = "crates/aws_http_client" } bedrock = { path = "crates/bedrock" } @@ -267,8 +270,6 @@ clock = { path = "crates/clock" } cloud_api_client = { path = "crates/cloud_api_client" } cloud_api_types = { path = "crates/cloud_api_types" } cloud_llm_client = { path = "crates/cloud_llm_client" } -cloud_zeta2_prompt = { path = "crates/cloud_zeta2_prompt" } -collab = { path = "crates/collab" } collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections", version = "0.1.0" } command_palette = { path = "crates/command_palette" } @@ -289,6 +290,7 @@ deepseek = { path = "crates/deepseek" } derive_refineable = { path = "crates/refineable/derive_refineable" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } +eval_utils = { path = "crates/eval_utils" } extension = { path = "crates/extension" } extension_host = { path = "crates/extension_host" } extensions_ui = { path = "crates/extensions_ui" } @@ -312,10 +314,9 @@ http_client = { path = "crates/http_client" } http_client_tls = { path = "crates/http_client_tls" } icons = { path = "crates/icons" } image_viewer = { path = "crates/image_viewer" } -edit_prediction = { path = "crates/edit_prediction" } -edit_prediction_button = { path = "crates/edit_prediction_button" } +edit_prediction_types = { path = "crates/edit_prediction_types" } +edit_prediction_ui = { path = "crates/edit_prediction_ui" } edit_prediction_context = { path = "crates/edit_prediction_context" } -zeta2_tools = { path = "crates/zeta2_tools" } inspector_ui = { path = "crates/inspector_ui" } install_cli = { path = "crates/install_cli" } journal = { path = "crates/journal" } @@ -357,8 +358,6 @@ panel = { path = "crates/panel" } paths = { path = "crates/paths" } perf = { path = "tooling/perf" } picker = { path = "crates/picker" } -plugin = { path = "crates/plugin" } -plugin_macros = { path = "crates/plugin_macros" } prettier = { path = "crates/prettier" } settings_profile_selector = { path = "crates/settings_profile_selector" } project = { path = "crates/project" } @@ -369,12 +368,10 @@ proto = { path = "crates/proto" } recent_projects = { path = "crates/recent_projects" } refineable = { path = "crates/refineable" } release_channel = { path = "crates/release_channel" } -scheduler = { path = "crates/scheduler" } remote = { path = "crates/remote" } remote_server = { path = "crates/remote_server" } repl = { path = "crates/repl" } reqwest_client = { path = "crates/reqwest_client" } -rich_text = { path = "crates/rich_text" } rodio = { git = "https://github.com/RustAudio/rodio", rev ="e2074c6c2acf07b57cf717e076bdda7a9ac6e70b", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } @@ -391,7 +388,6 @@ snippets_ui = { path = "crates/snippets_ui" } sqlez = { path = "crates/sqlez" } sqlez_macros = { path = "crates/sqlez_macros" } story = { path = "crates/story" } -storybook = { path = "crates/storybook" } streaming_diff = { path = "crates/streaming_diff" } sum_tree = { path = "crates/sum_tree" } supermaven = { path = "crates/supermaven" } @@ -408,7 +404,6 @@ terminal_view = { path = "crates/terminal_view" } text = { path = "crates/text" } theme = { path = "crates/theme" } theme_extension = { path = "crates/theme_extension" } -theme_importer = { path = "crates/theme_importer" } theme_selector = { path = "crates/theme_selector" } time_format = { path = "crates/time_format" } title_bar = { path = "crates/title_bar" } @@ -422,6 +417,7 @@ util_macros = { path = "crates/util_macros" } vercel = { path = "crates/vercel" } vim = { path = "crates/vim" } vim_mode_setting = { path = "crates/vim_mode_setting" } +which_key = { path = "crates/which_key" } watch = { path = "crates/watch" } web_search = { path = "crates/web_search" } @@ -432,16 +428,18 @@ x_ai = { path = "crates/x_ai" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } zed_env_vars = { path = "crates/zed_env_vars" } -zeta = { path = "crates/zeta" } -zeta2 = { path = "crates/zeta2" } +edit_prediction = { path = "crates/edit_prediction" } +zeta_prompt = { path = "crates/zeta_prompt" } zlog = { path = "crates/zlog" } zlog_settings = { path = "crates/zlog_settings" } +ztracing = { path = "crates/ztracing" } +ztracing_macro = { path = "crates/ztracing_macro" } # # External crates # -agent-client-protocol = { version = "0.7.0", features = ["unstable"] } +agent-client-protocol = { version = "=0.9.2", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = "0.25.1-rc1" any_vec = "0.14" @@ -460,15 +458,15 @@ async-task = "4.7" async-trait = "0.1" async-tungstenite = "0.31.0" async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] } -aws-config = { version = "1.6.1", features = ["behavior-version-latest"] } -aws-credential-types = { version = "1.2.2", features = [ +aws-config = { version = "1.8.10", features = ["behavior-version-latest"] } +aws-credential-types = { version = "1.2.8", features = [ "hardcoded-credentials", ] } -aws-sdk-bedrockruntime = { version = "1.80.0", features = [ +aws-sdk-bedrockruntime = { version = "1.112.0", features = [ "behavior-version-latest", ] } -aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] } -aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] } +aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } +aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" bincode = "1.2.1" @@ -481,6 +479,7 @@ bytes = "1.0" cargo_metadata = "0.19" cargo_toml = "0.21" cfg-if = "1.0.3" +chardetng = "0.1" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" circular-buffer = "1.0" @@ -504,17 +503,16 @@ dotenvy = "0.15.0" ec4rs = "1.1" emojis = "0.6.1" env_logger = "0.11" +encoding_rs = "0.8" exec = "0.3.1" -fancy-regex = "0.14.0" +fancy-regex = "0.16.0" fork = "0.4.0" futures = "0.3" -futures-batch = "0.6.1" futures-lite = "1.13" -gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "3eaa84abca0778eb54272f45a312cb24f9a0b435" } +gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "09acfdf2bd5c1d6254abefd609c808ff73547b2c" } git2 = { version = "0.20.1", default-features = false } globset = "0.4" handlebars = "4.3" -hashbrown = "0.15.3" heck = "0.5" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } hex = "0.4.3" @@ -531,7 +529,7 @@ indoc = "2" inventory = "0.3.19" itertools = "0.14.0" json_dotpath = "1.1" -jsonschema = "0.30.0" +jsonschema = "0.37.0" jsonwebtoken = "9.3" jupyter-protocol = "0.10.0" jupyter-websocket-client = "0.15.0" @@ -550,7 +548,6 @@ nanoid = "0.4" nbformat = "0.15.0" nix = "0.29" num-format = "0.4.4" -num-traits = "0.2" objc = "0.2" objc2-foundation = { version = "=0.3.1", default-features = false, features = [ "NSArray", @@ -585,14 +582,13 @@ partial-json-fixer = "0.5.3" parse_int = "0.9" pciid-parser = "0.8.0" pathdiff = "0.2" -pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } -pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } -pet-pixi = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } -pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } +pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } +pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } portable-pty = "0.9.0" postage = { version = "0.5", features = ["futures-traits"] } pretty_assertions = { version = "1.3.0", features = ["unstable"] } @@ -629,7 +625,6 @@ scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197 schemars = { version = "1.0", features = ["indexmap2"] } semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0.221", features = ["derive", "rc"] } -serde_derive = "1.0.221" serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } serde_json_lenient = { version = "0.2", features = [ "preserve_order", @@ -643,7 +638,7 @@ shellexpand = "2.1.0" shlex = "1.3.0" simplelog = "0.12.2" slotmap = "1.0.6" -smallvec = { version = "1.6", features = ["union"] } +smallvec = { version = "1.6", features = ["union", "const_new"] } smol = "2.0" sqlformat = "0.2" stacksafe = "0.1" @@ -657,7 +652,7 @@ sysinfo = "0.37.0" take-until = "0.2.0" tempfile = "3.20.0" thiserror = "2.0.12" -tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "30c32a4522751699adeda0d5840c71c3b75ae73d" } +tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "2570c4387a8505fb8f1d3f3557454b474f1e8271" } time = { version = "0.3", features = [ "macros", "parsing", @@ -669,11 +664,12 @@ time = { version = "0.3", features = [ tiny_http = "0.8" tokio = { version = "1" } tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } +tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io", "tokio"] } toml = "0.8" toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } tower-http = "0.4.4" -tree-sitter = { version = "0.25.10", features = ["wasm"] } -tree-sitter-bash = "0.25.0" +tree-sitter = { version = "0.26", features = ["wasm"] } +tree-sitter-bash = "0.25.1" tree-sitter-c = "0.23" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } tree-sitter-css = "0.23" @@ -695,6 +691,7 @@ tree-sitter-ruby = "0.23" tree-sitter-rust = "0.24" tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } +tracing = "0.1.40" unicase = "2.6" unicode-script = "0.5.7" unicode-segmentation = "1.10" @@ -705,7 +702,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } walkdir = "2.5" wasm-encoder = "0.221" wasmparser = "0.221" -wasmtime = { version = "29", default-features = false, features = [ +wasmtime = { version = "33", default-features = false, features = [ "async", "demangle", "runtime", @@ -714,11 +711,10 @@ wasmtime = { version = "29", default-features = false, features = [ "incremental-cache", "parallel-compilation", ] } -wasmtime-wasi = "29" +wasmtime-wasi = "33" wax = "0.6" which = "6.0.0" windows-core = "0.61" -wit-component = "0.221" yawc = "0.2.5" zeroize = "1.8" zstd = "0.11" @@ -800,20 +796,13 @@ settings_macros = { opt-level = 3 } sqlez_macros = { opt-level = 3, codegen-units = 1 } ui_macros = { opt-level = 3 } util_macros = { opt-level = 3 } -serde_derive = { opt-level = 3 } quote = { opt-level = 3 } syn = { opt-level = 3 } proc-macro2 = { opt-level = 3 } # proc-macros end taffy = { opt-level = 3 } -cranelift-codegen = { opt-level = 3 } -cranelift-codegen-meta = { opt-level = 3 } -cranelift-codegen-shared = { opt-level = 3 } resvg = { opt-level = 3 } -rustybuzz = { opt-level = 3 } -ttf-parser = { opt-level = 3 } -wasmtime-cranelift = { opt-level = 3 } wasmtime = { opt-level = 3 } # Build single-source-file crates with cg=1 as it helps make `cargo build` of a whole workspace a bit faster activity_indicator = { codegen-units = 1 } @@ -822,12 +811,11 @@ breadcrumbs = { codegen-units = 1 } collections = { codegen-units = 1 } command_palette = { codegen-units = 1 } command_palette_hooks = { codegen-units = 1 } -extension_cli = { codegen-units = 1 } feature_flags = { codegen-units = 1 } file_icons = { codegen-units = 1 } fsevent = { codegen-units = 1 } image_viewer = { codegen-units = 1 } -edit_prediction_button = { codegen-units = 1 } +edit_prediction_ui = { codegen-units = 1 } install_cli = { codegen-units = 1 } journal = { codegen-units = 1 } json_schema_store = { codegen-units = 1 } @@ -842,7 +830,6 @@ project_symbols = { codegen-units = 1 } refineable = { codegen-units = 1 } release_channel = { codegen-units = 1 } reqwest_client = { codegen-units = 1 } -rich_text = { codegen-units = 1 } session = { codegen-units = 1 } snippet = { codegen-units = 1 } snippets_ui = { codegen-units = 1 } @@ -875,8 +862,6 @@ unexpected_cfgs = { level = "allow" } dbg_macro = "deny" todo = "deny" -# This is not a style lint, see https://github.com/rust-lang/rust-clippy/pull/15454 -# Remove when the lint gets promoted to `suspicious`. declare_interior_mutable_const = "deny" redundant_clone = "deny" diff --git a/Dockerfile-collab b/Dockerfile-collab index 4bc369140e79219b9719b570659e9edd27453260..188e7daddfb471c41b237ca75469355cfc866ae3 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.91.1-bookworm as builder +FROM rust:1.92-bookworm as builder WORKDIR app COPY . . @@ -34,8 +34,4 @@ RUN apt-get update; \ linux-perf binutils WORKDIR app COPY --from=builder /app/collab /app/collab -COPY --from=builder /app/crates/collab/migrations /app/migrations -COPY --from=builder /app/crates/collab/migrations_llm /app/migrations_llm -ENV MIGRATIONS_PATH=/app/migrations -ENV LLM_DATABASE_MIGRATIONS_PATH=/app/migrations_llm ENTRYPOINT ["/app/collab"] diff --git a/README.md b/README.md index d1e2a75beccc9b115bd3b2e09bcc812aebc98329..866762c8c9139666993c2e29d9682966106c516b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of ### Installation -On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager). +On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or install Zed via your local package manager ([macOS](https://zed.dev/docs/installation#macos)/[Linux](https://zed.dev/docs/linux#installing-via-a-package-manager)/[Windows](https://zed.dev/docs/windows#package-managers)). Other platforms are not yet available: @@ -20,7 +20,6 @@ Other platforms are not yet available: - [Building Zed for macOS](./docs/src/development/macos.md) - [Building Zed for Linux](./docs/src/development/linux.md) - [Building Zed for Windows](./docs/src/development/windows.md) -- [Running Collaboration Locally](./docs/src/development/local-collaboration.md) ### Contributing diff --git a/REVIEWERS.conl b/REVIEWERS.conl index ca39a5c28002f6851beaf6710c44ac04b88592a3..bca694d7a06fe1112f7f8bab158dad63a365ea74 100644 --- a/REVIEWERS.conl +++ b/REVIEWERS.conl @@ -28,7 +28,7 @@ ai = @rtfeldman audio - = @dvdsk + = @yara-blue crashes = @p1n3appl3 @@ -43,8 +43,9 @@ design = @danilo-leal docs - = @probably-neb = @miguelraz + = @probably-neb + = @yeskunall extension = @kubkon @@ -52,6 +53,10 @@ extension git = @cole-miller = @danilo-leal + = @yara-blue + = @kubkon + = @Anthony-Eid + = @cameron1024 gpui = @Anthony-Eid @@ -71,7 +76,7 @@ languages linux = @cole-miller - = @dvdsk + = @yara-blue = @p1n3appl3 = @probably-neb = @smitbarmase @@ -87,7 +92,7 @@ multi_buffer = @SomeoneToIgnore pickers - = @dvdsk + = @yara-blue = @p1n3appl3 = @SomeoneToIgnore @@ -99,6 +104,9 @@ settings_ui = @danilo-leal = @probably-neb +sum_tree + = @Veykril + support = @miguelraz @@ -110,6 +118,9 @@ terminal = @kubkon = @Veykril +text + = @Veykril + vim = @ConradIrwin = @dinocosta @@ -119,3 +130,4 @@ vim windows = @localcc = @reflectronic + = @Veykril diff --git a/assets/icons/box.svg b/assets/icons/box.svg new file mode 100644 index 0000000000000000000000000000000000000000..7e1276c629fb8bdc5a7ed48d9e2de6369d4c2bb0 --- /dev/null +++ b/assets/icons/box.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/debug_step_back.svg b/assets/icons/debug_step_back.svg deleted file mode 100644 index 61d45866f61cbabbd9a7ae9975809d342cb76ed5..0000000000000000000000000000000000000000 --- a/assets/icons/debug_step_back.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/debug_step_into.svg b/assets/icons/debug_step_into.svg index 9a517fc7ca0762b17446a75cd90f39a91e1b51cf..0a5882354380b659425fecca2b4c6000516e422f 100644 --- a/assets/icons/debug_step_into.svg +++ b/assets/icons/debug_step_into.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/debug_step_out.svg b/assets/icons/debug_step_out.svg index 147a44f930f34f6c3ddce94693a178a932129cb5..c128f56111f2b68d7229f9d2f61b6b2496f99bba 100644 --- a/assets/icons/debug_step_out.svg +++ b/assets/icons/debug_step_out.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/debug_step_over.svg b/assets/icons/debug_step_over.svg index 336abc11deb866a128e8418dab47af01b6e4d3f6..5d8ccd5b7a20b2f8a108ab4c2e03694db4f6f8a8 100644 --- a/assets/icons/debug_step_over.svg +++ b/assets/icons/debug_step_over.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/file_icons/odin.svg b/assets/icons/file_icons/odin.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b4ef8931943eb30279a998010e8df0632b08cf4 --- /dev/null +++ b/assets/icons/file_icons/odin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/git_branch_plus.svg b/assets/icons/git_branch_plus.svg new file mode 100644 index 0000000000000000000000000000000000000000..cf60ce66b4086ba57ef4c2e56f3554d548e863fc --- /dev/null +++ b/assets/icons/git_branch_plus.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/inception.svg b/assets/icons/inception.svg new file mode 100644 index 0000000000000000000000000000000000000000..77a96c0b390ab9f2fe89143c2a89ba916000fabc --- /dev/null +++ b/assets/icons/inception.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/zed_agent_two.svg b/assets/icons/zed_agent_two.svg new file mode 100644 index 0000000000000000000000000000000000000000..c352be84d2f1bea6da1f6a5be70b9420f019b6d6 --- /dev/null +++ b/assets/icons/zed_agent_two.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 7ddabfc9f55577c8ced3fbad0cc881cd4bb183d0..465c7d86aeaff23bdebe65792304ac2963edaaa7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -25,7 +25,8 @@ "ctrl-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", "open": "workspace::Open", - "ctrl-o": "workspace::Open", + "ctrl-o": "workspace::OpenFiles", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], @@ -41,17 +42,18 @@ "ctrl-f11": "debugger::StepInto", "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", - "ctrl-alt-z": "edit_prediction::RateCompletions", + "ctrl-alt-z": "edit_prediction::RatePredictions", "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", - "ctrl-alt-l": "lsp_tool::ToggleMenu" - } + "ctrl-alt-l": "lsp_tool::ToggleMenu", + "ctrl-alt-shift-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Picker || menu", "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -62,7 +64,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -124,8 +125,8 @@ "shift-f10": "editor::OpenContextMenu", "ctrl-alt-shift-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -144,44 +145,44 @@ "ctrl-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "bindings": { "copy": "markdown::Copy", "ctrl-insert": "markdown::Copy", - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -189,8 +190,8 @@ "ctrl-k ctrl-r": "git::Restore", "ctrl-alt-y": "git::ToggleStaged", "alt-y": "git::StageAndNext", - "alt-shift-y": "git::UnstageAndNext" - } + "alt-shift-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -199,8 +200,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,8 +209,8 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", @@ -225,8 +226,9 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -250,37 +252,38 @@ "alt-enter": "agent::ContinueWithBurnMode", "ctrl-y": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "ctrl-alt-z": "agent::RejectOnce" - } + "ctrl-alt-z": "agent::RejectOnce", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "bindings": { "copy": "markdown::CopyAsMarkdown", "ctrl-insert": "markdown::CopyAsMarkdown", - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -290,8 +293,9 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -301,30 +305,31 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "EditMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -333,8 +338,8 @@ "enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -344,14 +349,15 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "ThreadHistory", "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -359,8 +365,8 @@ "new": "rules_library::NewRule", "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -373,22 +379,22 @@ "find": "search::FocusSearch", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -399,22 +405,22 @@ "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -422,8 +428,8 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -472,8 +478,8 @@ "ctrl-alt-shift-r": "search::ToggleRegex", "ctrl-alt-shift-x": "search::ToggleRegex", "alt-r": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -500,6 +506,7 @@ "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -536,31 +543,31 @@ "ctrl-\\": "pane::SplitRight", "ctrl-alt-shift-c": "editor::DisplayCursorNames", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -616,8 +623,8 @@ "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "ctrl-e": "file_finder::Toggle", "f1": "command_palette::Toggle", "ctrl-shift-p": "command_palette::Toggle", @@ -654,28 +661,28 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -693,8 +700,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -703,37 +710,37 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -745,22 +752,24 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -770,29 +779,29 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { "bindings": { "ctrl-alt-shift-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "ctrl-alt-i": "dev::ToggleInspector" - } + "ctrl-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -804,15 +813,17 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -820,14 +831,14 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -844,8 +855,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -883,20 +894,22 @@ "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", "enter": "menu::Confirm", "alt-y": "git::StageFile", "alt-shift-y": "git::UnstageFile", @@ -911,15 +924,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -928,8 +941,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -945,8 +958,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -954,14 +967,14 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -973,16 +986,16 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -994,8 +1007,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1003,35 +1016,35 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1040,29 +1053,29 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1071,8 +1084,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1080,15 +1093,15 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1133,65 +1146,69 @@ "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", - "ctrl-shift-5": "pane::SplitRight" - } + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "ZedPredictModal", "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1205,8 +1222,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1214,24 +1231,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1243,8 +1260,8 @@ "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1253,23 +1270,28 @@ "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], - "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + "ctrl-1": ["welcome::OpenRecentProject", 0], + "ctrl-2": ["welcome::OpenRecentProject", 1], + "ctrl-3": ["welcome::OpenRecentProject", 2], + "ctrl-4": ["welcome::OpenRecentProject", 3], + "ctrl-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1279,6 +1301,7 @@ "escape": "workspace::CloseWindow", "ctrl-m": "settings_editor::Minimize", "ctrl-f": "search::FocusSearch", + "ctrl-,": "settings_editor::OpenCurrentFile", "left": "settings_editor::ToggleFocusNav", "ctrl-shift-e": "settings_editor::ToggleFocusNav", // todo(settings_ui): cut this down based on the max files and overflow UI @@ -1293,16 +1316,16 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1317,22 +1340,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { - "context": "Zeta2Feedback > Editor", + "context": "EditPredictionContext > Editor", "bindings": { - "enter": "editor::Newline", - "ctrl-enter up": "dev::Zeta2RatePredictionPositive", - "ctrl-enter down": "dev::Zeta2RatePredictionNegative" - } + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { - "context": "Zeta2Context > Editor", + "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", + "use_key_equivalents": true, "bindings": { - "alt-left": "dev::Zeta2ContextGoBack", - "alt-right": "dev::Zeta2ContextGoForward" - } - } + "ctrl-shift-backspace": "branch_picker::DeleteBranch", + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 2f7c25a3560e09bccb9f45c64df38048eefdddd6..7ff00c41d5d6108b2a0b9fa0de85c511fab1f6e0 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -47,10 +47,12 @@ "cmd-m": "zed::Minimize", "fn-f": "zed::ToggleFullScreen", "ctrl-cmd-f": "zed::ToggleFullScreen", - "ctrl-cmd-z": "edit_prediction::RateCompletions", + "ctrl-cmd-z": "edit_prediction::RatePredictions", "ctrl-cmd-i": "edit_prediction::ToggleMenu", - "ctrl-cmd-l": "lsp_tool::ToggleMenu" - } + "ctrl-cmd-l": "lsp_tool::ToggleMenu", + "ctrl-cmd-c": "editor::DisplayCursorNames", + "ctrl-cmd-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Editor", @@ -147,8 +149,8 @@ "shift-f9": "editor::EditLogBreakpoint", "ctrl-f12": "editor::GoToDeclaration", "alt-ctrl-f12": "editor::GoToDeclarationSplit", - "ctrl-cmd-e": "editor::ToggleEditPrediction" - } + "ctrl-cmd-e": "editor::ToggleEditPrediction", + }, }, { "context": "Editor && mode == full", @@ -166,8 +168,8 @@ "cmd->": "agent::AddSelectionToThread", "cmd-<": "assistant::InsertIntoEditor", "cmd-alt-e": "editor::SelectEnclosingSymbol", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && multibuffer", @@ -176,23 +178,23 @@ "cmd-up": "editor::MoveToStartOfExcerpt", "cmd-down": "editor::MoveToStartOfNextExcerpt", "cmd-shift-up": "editor::SelectToStartOfExcerpt", - "cmd-shift-down": "editor::SelectToStartOfNextExcerpt" - } + "cmd-shift-down": "editor::SelectToStartOfNextExcerpt", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::NextEditPrediction", - "alt-shift-tab": "editor::PreviousEditPrediction" - } + "alt-shift-tab": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-tab": "editor::ShowEditPrediction" - } + "alt-tab": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -200,23 +202,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::Copy" - } + "cmd-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff && !AgentPanel", @@ -225,8 +227,8 @@ "cmd-alt-z": "git::Restore", "cmd-alt-y": "git::ToggleStaged", "cmd-y": "git::StageAndNext", - "cmd-shift-y": "git::UnstageAndNext" - } + "cmd-shift-y": "git::UnstageAndNext", + }, }, { "context": "AgentDiff", @@ -235,8 +237,8 @@ "cmd-y": "agent::Keep", "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "Editor && editor_agent_diff", @@ -246,8 +248,8 @@ "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "ContextEditor > Editor", @@ -263,8 +265,10 @@ "cmd-k c": "assistant::CopyCode", "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPreviousMatch", - "cmd-k l": "agent::OpenRulesLibrary" - } + "cmd-k l": "agent::OpenRulesLibrary", + "alt-tab": "agent::CycleFavoriteModels", + "cmd-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -289,37 +293,38 @@ "alt-enter": "agent::ContinueWithBurnMode", "cmd-y": "agent::AllowOnce", "cmd-alt-y": "agent::AllowAlways", - "cmd-alt-z": "agent::RejectOnce" - } + "cmd-alt-z": "agent::RejectOnce", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::CopyAsMarkdown" - } + "cmd-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewTextThread", - "cmd-alt-n": "agent::NewExternalAgentThread" - } + "cmd-alt-n": "agent::NewExternalAgentThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewExternalAgentThread", - "cmd-alt-t": "agent::NewThread" - } + "cmd-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -330,8 +335,9 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + "cmd-shift-v": "agent::PasteRaw", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -342,8 +348,9 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + "cmd-shift-v": "agent::PasteRaw", + }, }, { "context": "EditMessageEditor > Editor", @@ -351,8 +358,8 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -360,20 +367,20 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentConfiguration", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -383,8 +390,9 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -394,20 +402,21 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "ThreadHistory", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "ThreadHistory > Editor", "bindings": { - "shift-backspace": "agent::RemoveSelectedThread" - } + "shift-backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -415,8 +424,8 @@ "bindings": { "cmd-n": "rules_library::NewRule", "cmd-shift-s": "rules_library::ToggleDefaultRule", - "cmd-w": "workspace::CloseWindow" - } + "cmd-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -430,24 +439,24 @@ "cmd-f": "search::FocusSearch", "cmd-alt-f": "search::ToggleReplace", "cmd-alt-l": "search::ToggleSelection", - "cmd-shift-o": "outline::Toggle" - } + "cmd-shift-o": "outline::Toggle", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -459,24 +468,24 @@ "cmd-shift-f": "search::FocusSearch", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -487,8 +496,8 @@ "shift-enter": "project_search::ToggleAllSearchResults", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -518,8 +527,8 @@ "alt-cmd-w": "search::ToggleWholeWord", "alt-cmd-f": "project_search::ToggleFilters", "alt-cmd-x": "search::ToggleRegex", - "cmd-k shift-enter": "pane::TogglePinTab" - } + "cmd-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -590,24 +599,23 @@ "cmd-k r": "editor::RevealInFileManager", "cmd-k p": "editor::CopyPath", "cmd-\\": "pane::SplitRight", - "ctrl-cmd-c": "editor::DisplayCursorNames" - } + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "cmd-k v": "markdown::OpenPreviewToTheSide", - "cmd-shift-v": "markdown::OpenPreview" - } + "cmd-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "cmd-k v": "svg::OpenPreviewToTheSide", - "cmd-shift-v": "svg::OpenPreview" - } + "cmd-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", @@ -616,8 +624,8 @@ "cmd-shift-o": "outline::Toggle", "ctrl-g": "go_to_line::Toggle", "cmd-shift-backspace": "editor::GoToPreviousChange", - "cmd-shift-alt-backspace": "editor::GoToNextChange" - } + "cmd-shift-alt-backspace": "editor::GoToNextChange", + }, }, { "context": "Pane", @@ -635,8 +643,8 @@ "ctrl-0": "pane::ActivateLastItem", "ctrl--": "pane::GoBack", "ctrl-_": "pane::GoForward", - "cmd-shift-f": "pane::DeploySearch" - } + "cmd-shift-f": "pane::DeploySearch", + }, }, { "context": "Workspace", @@ -684,8 +692,8 @@ "ctrl-alt-cmd-p": "settings_profile_selector::Toggle", "cmd-t": "project_symbols::Toggle", "cmd-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", "cmd-shift-e": "project_panel::ToggleFocus", @@ -707,8 +715,8 @@ "cmd-k shift-down": "workspace::SwapPaneDown", "cmd-shift-x": "zed::Extensions", "f5": "debugger::Rerun", - "cmd-w": "workspace::CloseActiveDock" - } + "cmd-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && !Terminal", @@ -719,26 +727,27 @@ // All task parameters are captured and unchanged between reruns by default. // Use the `"reevaluate_context"` parameter to control this. "cmd-alt-r": ["task::Rerun", { "reevaluate_context": false }], - "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }] + "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }], // also possible to spawn tasks by name: // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] // or by tag: // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], - } + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + "f11": "debugger::StepInto", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, // Bindings from Sublime Text { @@ -759,8 +768,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -770,16 +779,16 @@ "cmd-k up": "pane::SplitUp", "cmd-k down": "pane::SplitDown", "cmd-k left": "pane::SplitLeft", - "cmd-k right": "pane::SplitRight" - } + "cmd-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -787,45 +796,47 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, { "context": "Editor && edit_prediction", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -836,15 +847,15 @@ "down": "editor::ContextMenuNext", "ctrl-n": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -854,8 +865,8 @@ // TODO: Move this to a dock open action "cmd-shift-c": "collab_panel::ToggleFocus", // Only available in debug builds: opens an element inspector for development. - "cmd-alt-i": "dev::ToggleInspector" - } + "cmd-alt-i": "dev::ToggleInspector", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -868,17 +879,20 @@ "cmd-f8": "editor::GoToHunk", "cmd-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", "use_key_equivalents": true, "bindings": { "cmd-alt-/": "agent::ToggleModelSelector", + "alt-tab": "agent::CycleFavoriteModels", "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "cmd-shift-enter": "inline_assistant::ThumbsUpResult", + "cmd-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -887,15 +901,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "cmd-enter": "project_search::SearchInNew" - } + "cmd-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -911,8 +925,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "cmd-alt-enter": "editor::OpenExcerptsSplit" - } + "cmd-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -942,15 +956,15 @@ "cmd-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "VariableList", @@ -963,17 +977,19 @@ "cmd-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "GitPanel && ChangesList", "use_key_equivalents": true, "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", - "cmd-up": "menu::SelectFirst", - "cmd-down": "menu::SelectLast", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", + "cmd-up": "git_panel::FirstEntry", + "cmd-down": "git_panel::LastEntry", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", "enter": "menu::Confirm", "cmd-alt-y": "git::ToggleStaged", "space": "git::ToggleStaged", @@ -987,15 +1003,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], - "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }] - } + "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitDiff > Editor", @@ -1004,8 +1020,8 @@ "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", "cmd-ctrl-y": "git::StageAll", - "cmd-ctrl-shift-y": "git::UnstageAll" - } + "cmd-ctrl-shift-y": "git::UnstageAll", + }, }, { "context": "CommitEditor > Editor", @@ -1018,8 +1034,8 @@ "shift-tab": "git_panel::FocusChanges", "alt-up": "git_panel::FocusChanges", "shift-escape": "git::ExpandCommitEditor", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -1036,8 +1052,8 @@ "cmd-ctrl-y": "git::StageAll", "cmd-ctrl-shift-y": "git::UnstageAll", "cmd-enter": "git::Commit", - "cmd-shift-enter": "git::Amend" - } + "cmd-shift-enter": "git::Amend", + }, }, { "context": "GitCommit > Editor", @@ -1047,16 +1063,16 @@ "escape": "menu::Cancel", "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "cmd-t": "debugger::ToggleThreadPicker", "cmd-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "BreakpointList", @@ -1064,16 +1080,16 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", @@ -1081,22 +1097,22 @@ "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1107,30 +1123,30 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], - "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }] - } + "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "toolchain::AddToolchain" - } + "cmd-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu" - } + "cmd-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1140,8 +1156,8 @@ "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", - "cmd-l": "pane::SplitRight" - } + "cmd-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1150,16 +1166,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1214,35 +1230,35 @@ "ctrl-alt-left": "pane::SplitLeft", "ctrl-alt-right": "pane::SplitRight", "cmd-d": "pane::SplitRight", - "cmd-alt-r": "terminal::RerunTask" - } + "cmd-alt-r": "terminal::RerunTask", + }, }, { - "context": "RateCompletionModal", + "context": "RatePredictionsModal", "use_key_equivalents": true, "bindings": { - "cmd-shift-enter": "zeta::ThumbsUpActiveCompletion", - "cmd-shift-backspace": "zeta::ThumbsDownActiveCompletion", + "cmd-shift-enter": "zeta::ThumbsUpActivePrediction", + "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", "shift-down": "zeta::NextEdit", "shift-up": "zeta::PreviousEdit", - "right": "zeta::PreviewCompletion" - } + "right": "zeta::PreviewPrediction", + }, }, { - "context": "RateCompletionModal > Editor", + "context": "RatePredictionsModal > Editor", "use_key_equivalents": true, "bindings": { - "escape": "zeta::FocusCompletions", - "cmd-shift-enter": "zeta::ThumbsUpActiveCompletion", - "cmd-shift-backspace": "zeta::ThumbsDownActiveCompletion" - } + "escape": "zeta::FocusPredictions", + "cmd-shift-enter": "zeta::ThumbsUpActivePrediction", + "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1250,52 +1266,56 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1308,8 +1328,8 @@ "alt-enter": "keymap_editor::CreateBinding", "cmd-c": "keymap_editor::CopyAction", "cmd-shift-c": "keymap_editor::CopyContext", - "cmd-t": "keymap_editor::ShowMatchingKeybinds" - } + "cmd-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1317,24 +1337,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "cmd-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1346,8 +1366,8 @@ "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], "cmd-enter": "onboarding::Finish", "alt-tab": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1356,23 +1376,28 @@ "cmd-=": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd--": ["zed::DecreaseUiFontSize", { "persist": false }], - "cmd-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], + "cmd-1": ["welcome::OpenRecentProject", 0], + "cmd-2": ["welcome::OpenRecentProject", 1], + "cmd-3": ["welcome::OpenRecentProject", 2], + "cmd-4": ["welcome::OpenRecentProject", 3], + "cmd-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1382,6 +1407,7 @@ "escape": "workspace::CloseWindow", "cmd-m": "settings_editor::Minimize", "cmd-f": "search::FocusSearch", + "cmd-,": "settings_editor::OpenCurrentFile", "left": "settings_editor::ToggleFocusNav", "cmd-shift-e": "settings_editor::ToggleFocusNav", // todo(settings_ui): cut this down based on the max files and overflow UI @@ -1396,8 +1422,8 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "cmd-{": "settings_editor::FocusPreviousFile", - "cmd-}": "settings_editor::FocusNextFile" - } + "cmd-}": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", @@ -1405,8 +1431,8 @@ "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1421,22 +1447,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { - "context": "Zeta2Feedback > Editor", + "context": "EditPredictionContext > Editor", "bindings": { - "enter": "editor::Newline", - "cmd-enter up": "dev::Zeta2RatePredictionPositive", - "cmd-enter down": "dev::Zeta2RatePredictionNegative" - } + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { - "context": "Zeta2Context > Editor", + "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", + "use_key_equivalents": true, "bindings": { - "alt-left": "dev::Zeta2ContextGoBack", - "alt-right": "dev::Zeta2ContextGoForward" - } - } + "cmd-shift-backspace": "branch_picker::DeleteBranch", + "cmd-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 40fdc4609967bfbabf5f575a9bae6cc02abab9cd..445933c950cbc9ef72eb2cca90ab8115471f1e6f 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -24,7 +24,8 @@ "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }], "ctrl-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", - "ctrl-o": "workspace::Open", + "ctrl-o": "workspace::OpenFiles", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl-shift-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], @@ -36,21 +37,22 @@ "shift-f5": "debugger::Stop", "ctrl-shift-f5": "debugger::RerunSession", "f6": "debugger::Pause", - "f7": "debugger::StepOver", - "ctrl-f11": "debugger::StepInto", + "f10": "debugger::StepOver", "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-shift-i": "edit_prediction::ToggleMenu", - "shift-alt-l": "lsp_tool::ToggleMenu" - } + "shift-alt-l": "lsp_tool::ToggleMenu", + "ctrl-shift-alt-c": "editor::DisplayCursorNames", + "ctrl-shift-alt-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Picker || menu", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -62,7 +64,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -117,10 +118,10 @@ "alt-g m": "git::OpenModifiedFiles", "menu": "editor::OpenContextMenu", "shift-f10": "editor::OpenContextMenu", - "ctrl-shift-e": "editor::ToggleEditPrediction", + "ctrl-alt-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -139,23 +140,23 @@ "shift-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -163,23 +164,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -187,8 +188,8 @@ "bindings": { "ctrl-k ctrl-r": "git::Restore", "alt-y": "git::StageAndNext", - "shift-alt-y": "git::UnstageAndNext" - } + "shift-alt-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -198,8 +199,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "ctrl-shift-r": "agent::OpenAgentDiff" - } + "ctrl-shift-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,14 +209,14 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "assistant::Assist", + "ctrl-i": "assistant::Assist", "ctrl-s": "workspace::Save", "ctrl-shift-,": "assistant::InsertIntoEditor", "shift-enter": "assistant::Split", @@ -225,8 +226,9 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -240,49 +242,50 @@ "shift-alt-p": "agent::ManageProfiles", "ctrl-i": "agent::ToggleProfileSelector", "shift-alt-/": "agent::ToggleModelSelector", - "ctrl-shift-j": "agent::ToggleNavigationMenu", - "ctrl-alt-i": "agent::ToggleOptionsMenu", - // "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", + "shift-alt-j": "agent::ToggleNavigationMenu", + "shift-alt-i": "agent::ToggleOptionsMenu", + "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", "super-ctrl-b": "agent::ToggleBurnMode", "alt-enter": "agent::ContinueWithBurnMode", - "ctrl-y": "agent::AllowOnce", + "shift-alt-a": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "ctrl-alt-z": "agent::RejectOnce" - } + "shift-alt-z": "agent::RejectOnce", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AgentPanel > NavigationMenu", "use_key_equivalents": true, "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -293,8 +296,9 @@ "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -305,8 +309,9 @@ "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "EditMessageEditor > Editor", @@ -314,8 +319,8 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -323,14 +328,14 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -340,8 +345,9 @@ "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -351,15 +357,16 @@ "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "ThreadHistory", "use_key_equivalents": true, "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -367,8 +374,8 @@ "bindings": { "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -381,24 +388,24 @@ "alt-enter": "search::SelectAllMatches", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -407,24 +414,24 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -432,8 +439,8 @@ "bindings": { "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "Pane", @@ -464,8 +471,10 @@ "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", "back": "pane::GoBack", "alt--": "pane::GoBack", + "alt-left": "pane::GoBack", "forward": "pane::GoForward", "alt-=": "pane::GoForward", + "alt-right": "pane::GoForward", "f3": "search::SelectNextMatch", "shift-f3": "search::SelectPreviousMatch", "ctrl-shift-f": "project_search::ToggleFocus", @@ -478,8 +487,8 @@ "shift-enter": "project_search::ToggleAllSearchResults", "alt-r": "search::ToggleRegex", // "ctrl-shift-alt-x": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -488,8 +497,8 @@ "bindings": { "ctrl-[": "editor::Outdent", "ctrl-]": "editor::Indent", - "ctrl-shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above - "ctrl-shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below + "ctrl-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above + "ctrl-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below "ctrl-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", @@ -500,24 +509,21 @@ "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand - "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch - "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch + "ctrl-f3": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip - "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch + "ctrl-shift-f3": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", + "ctrl-k ctrl-f": "editor::FormatSelections", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", "f12": "editor::GoToDefinition", "alt-f12": "editor::GoToDefinitionSplit", - "ctrl-shift-f10": "editor::GoToDefinitionSplit", "ctrl-f12": "editor::GoToImplementation", - "shift-f12": "editor::GoToTypeDefinition", - "ctrl-alt-f12": "editor::GoToTypeDefinitionSplit", "shift-alt-f12": "editor::FindAllReferences", - "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains "ctrl-shift-\\": "editor::MoveToEnclosingBracket", "ctrl-shift-[": "editor::Fold", "ctrl-shift-]": "editor::UnfoldLines", @@ -541,34 +547,33 @@ "ctrl-k r": "editor::RevealInFileManager", "ctrl-k p": "editor::CopyPath", "ctrl-\\": "pane::SplitRight", - "ctrl-shift-alt-c": "editor::DisplayCursorNames", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "use_key_equivalents": true, "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -616,8 +621,8 @@ "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "ctrl-e": "file_finder::Toggle", "f1": "command_palette::Toggle", "ctrl-shift-p": "command_palette::Toggle", @@ -652,22 +657,22 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", @@ -675,8 +680,8 @@ "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -693,8 +698,8 @@ "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", "ctrl-alt-right": "editor::MoveToNextSubwordEnd", "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart", - "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd" - } + "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -704,16 +709,16 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -721,22 +726,22 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -749,8 +754,9 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", @@ -758,15 +764,16 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -777,16 +784,16 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "use_key_equivalents": true, "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -794,15 +801,15 @@ "bindings": { "ctrl-shift-alt-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "shift-alt-i": "dev::ToggleInspector" - } + "shift-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -815,16 +822,18 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-shift-;": "editor::ToggleInlayHints" - } + "ctrl-shift-;": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", "use_key_equivalents": true, "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-delete": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -833,15 +842,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -856,8 +865,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -888,22 +897,24 @@ "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", "use_key_equivalents": true, "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", "enter": "menu::Confirm", "alt-y": "git::StageFile", "shift-alt-y": "git::UnstageFile", @@ -917,15 +928,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -935,8 +946,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -953,8 +964,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -963,15 +974,15 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "use_key_equivalents": true, "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -984,8 +995,8 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", @@ -993,8 +1004,8 @@ "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -1007,8 +1018,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1017,16 +1028,16 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", @@ -1034,22 +1045,22 @@ "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1059,22 +1070,22 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", @@ -1082,8 +1093,8 @@ "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1093,8 +1104,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1103,16 +1114,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1124,7 +1135,7 @@ "shift-insert": "terminal::Paste", "ctrl-v": "terminal::Paste", "ctrl-shift-v": "terminal::Paste", - "ctrl-enter": "assistant::InlineAssist", + "ctrl-i": "assistant::InlineAssist", "alt-b": ["terminal::SendText", "\u001bb"], "alt-f": ["terminal::SendText", "\u001bf"], "alt-.": ["terminal::SendText", "\u001b."], @@ -1136,6 +1147,8 @@ "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-q": ["terminal::SendKeystroke", "ctrl-q"], + "ctrl-r": ["terminal::SendKeystroke", "ctrl-r"], "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], "ctrl-shift-a": "editor::SelectAll", "ctrl-shift-f": "buffer_search::Deploy", @@ -1157,21 +1170,21 @@ "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", - "ctrl-shift-5": "pane::SplitRight" - } + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "Terminal && selection", "bindings": { - "ctrl-c": "terminal::Copy" - } + "ctrl-c": "terminal::Copy", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1179,53 +1192,57 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "use_key_equivalents": true, "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1238,8 +1255,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1247,24 +1264,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1276,8 +1293,8 @@ "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "shift-alt-a": "onboarding::OpenAccount" - } + "shift-alt-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1286,16 +1303,21 @@ "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], - "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + "ctrl-1": ["welcome::OpenRecentProject", 0], + "ctrl-2": ["welcome::OpenRecentProject", 1], + "ctrl-3": ["welcome::OpenRecentProject", 2], + "ctrl-4": ["welcome::OpenRecentProject", 3], + "ctrl-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1305,6 +1327,7 @@ "escape": "workspace::CloseWindow", "ctrl-m": "settings_editor::Minimize", "ctrl-f": "search::FocusSearch", + "ctrl-,": "settings_editor::OpenCurrentFile", "left": "settings_editor::ToggleFocusNav", "ctrl-shift-e": "settings_editor::ToggleFocusNav", // todo(settings_ui): cut this down based on the max files and overflow UI @@ -1319,8 +1342,8 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", @@ -1328,8 +1351,8 @@ "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1344,22 +1367,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { - "context": "Zeta2Feedback > Editor", + "context": "EditPredictionContext > Editor", "bindings": { - "enter": "editor::Newline", - "ctrl-enter up": "dev::Zeta2RatePredictionPositive", - "ctrl-enter down": "dev::Zeta2RatePredictionNegative" - } + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { - "context": "Zeta2Context > Editor", + "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", + "use_key_equivalents": true, "bindings": { - "alt-left": "dev::Zeta2ContextGoBack", - "alt-right": "dev::Zeta2ContextGoForward" - } - } + "ctrl-shift-backspace": "branch_picker::DeleteBranch", + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/initial.json b/assets/keymaps/initial.json index 8e4fe59f44ea7346a51e1c064ffa0553315da3b9..3a8d7f382aa57b39efc22845a17a4ef1bfd240ef 100644 --- a/assets/keymaps/initial.json +++ b/assets/keymaps/initial.json @@ -10,12 +10,12 @@ "context": "Workspace", "bindings": { // "shift shift": "file_finder::Toggle" - } + }, }, { "context": "Editor && vim_mode == insert", "bindings": { // "j k": "vim::NormalBefore" - } - } + }, + }, ] diff --git a/assets/keymaps/linux/atom.json b/assets/keymaps/linux/atom.json index 98992b19fac72055807063edae8b7b23652062d3..a15d4877aab79ac2e570697137ba89e3572d074e 100644 --- a/assets/keymaps/linux/atom.json +++ b/assets/keymaps/linux/atom.json @@ -4,15 +4,15 @@ "bindings": { "ctrl-shift-f5": "workspace::Reload", // window:reload "ctrl-k ctrl-n": "workspace::ActivatePreviousPane", // window:focus-next-pane - "ctrl-k ctrl-p": "workspace::ActivateNextPane" // window:focus-previous-pane - } + "ctrl-k ctrl-p": "workspace::ActivateNextPane", // window:focus-previous-pane + }, }, { "context": "Editor", "bindings": { "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case - "ctrl-k ctrl-l": "editor::ConvertToLowerCase" // editor:lower-case - } + "ctrl-k ctrl-l": "editor::ConvertToLowerCase", // editor:lower-case + }, }, { "context": "Editor && mode == full", @@ -32,8 +32,8 @@ "ctrl-down": "editor::MoveLineDown", // editor:move-line-down "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-shift-m": "markdown::OpenPreviewToTheSide", // markdown-preview:toggle - "ctrl-r": "outline::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "outline::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "BufferSearchBar", @@ -41,8 +41,8 @@ "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous "ctrl-f3": "search::SelectNextMatch", // find-and-replace:find-next-selected - "ctrl-shift-f3": "search::SelectPreviousMatch" // find-and-replace:find-previous-selected - } + "ctrl-shift-f3": "search::SelectPreviousMatch", // find-and-replace:find-previous-selected + }, }, { "context": "Workspace", @@ -50,8 +50,8 @@ "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-t": "file_finder::Toggle", // fuzzy-finder:toggle-file-finder - "ctrl-r": "project_symbols::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "project_symbols::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "Pane", @@ -65,8 +65,8 @@ "ctrl-6": ["pane::ActivateItem", 5], // tree-view:open-selected-entry-in-pane-6 "ctrl-7": ["pane::ActivateItem", 6], // tree-view:open-selected-entry-in-pane-7 "ctrl-8": ["pane::ActivateItem", 7], // tree-view:open-selected-entry-in-pane-8 - "ctrl-9": ["pane::ActivateItem", 8] // tree-view:open-selected-entry-in-pane-9 - } + "ctrl-9": ["pane::ActivateItem", 8], // tree-view:open-selected-entry-in-pane-9 + }, }, { "context": "ProjectPanel", @@ -75,8 +75,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "ctrl-x": "project_panel::Cut", // tree-view:cut "ctrl-c": "project_panel::Copy", // tree-view:copy - "ctrl-v": "project_panel::Paste" // tree-view:paste - } + "ctrl-v": "project_panel::Paste", // tree-view:paste + }, }, { "context": "ProjectPanel && not_editing", @@ -90,7 +90,7 @@ "d": "project_panel::Duplicate", // tree-view:duplicate "home": "menu::SelectFirst", // core:move-to-top "end": "menu::SelectLast", // core:move-to-bottom - "shift-a": "project_panel::NewDirectory" // tree-view:add-folder - } - } + "shift-a": "project_panel::NewDirectory", // tree-view:add-folder + }, + }, ] diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 4d2d13a90d96c31f72b1bb0ccc74608f81004eda..58a7309cf902a3f69f949830cace2200f41fb0fe 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -8,8 +8,8 @@ "ctrl-shift-i": "agent::ToggleFocus", "ctrl-l": "agent::ToggleFocus", "ctrl-shift-l": "agent::ToggleFocus", - "ctrl-shift-j": "agent::OpenSettings" - } + "ctrl-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -20,18 +20,18 @@ "ctrl-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "ctrl-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "ctrl-k": "assistant::InlineAssist", - "ctrl-shift-k": "assistant::InsertIntoEditor" - } + "ctrl-shift-k": "assistant::InsertIntoEditor", + }, }, { "context": "InlineAssistEditor", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "editor::Cancel" + "ctrl-shift-backspace": "editor::Cancel", // "alt-enter": // Quick Question // "ctrl-shift-enter": // Full File Context // "ctrl-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -47,7 +47,7 @@ "ctrl-shift-backspace": "editor::Cancel", "ctrl-r": "agent::NewThread", "ctrl-shift-v": "editor::Paste", - "ctrl-shift-k": "assistant::InsertIntoEditor" + "ctrl-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "ctrl-t": // new thread tab @@ -56,28 +56,29 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "ctrl-enter": "agent::KeepAll", - "ctrl-backspace": "agent::RejectAll" - } + "ctrl-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "ctrl-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-right": "editor::AcceptNextWordEditPrediction", + "ctrl-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-k": "assistant::InlineAssist" - } - } + "ctrl-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index c5cf22c81220bf286187252394f8fde26bdd6509..5b6f841de07ac2f9bd45c73e032dea0ede409007 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -5,8 +5,8 @@ [ { "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, }, { // Workaround to avoid falling back to default bindings. @@ -18,8 +18,8 @@ "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel "ctrl-x": null, // currently activates `editor::Cut` if no following key is pressed for 1 second "ctrl-p": null, // currently activates `file_finder::Toggle` when the cursor is on the first character of the buffer - "ctrl-n": null // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer - } + "ctrl-n": null, // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer + }, }, { "context": "Editor", @@ -82,8 +82,8 @@ "ctrl-s": "buffer_search::Deploy", // isearch-forward "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -119,22 +119,22 @@ "alt->": "editor::SelectToEnd", "ctrl-home": "editor::SelectToBeginning", "ctrl-end": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, // Example setting for using emacs-style tab // (i.e. indent the current line / selection or perform symbol completion depending on context) @@ -164,8 +164,8 @@ "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { // Workaround to enable using native emacs from the Zed terminal. @@ -185,22 +185,22 @@ "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index a0314c5bc1dd59c17f3f132db804891ef1df0d4e..d3bf53a0d3694943252e0fccb2ac821cc6c2a6d3 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -13,8 +13,8 @@ "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", "shift-f9": "debugger::Start", - "alt-shift-f9": "debugger::Start" - } + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -62,28 +62,30 @@ "ctrl-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "ctrl-shift-u": "editor::ToggleCase" - } + "ctrl-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-f12": "outline::Toggle", "ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }], + "ctrl-e": "file_finder::Toggle", "ctrl-shift-n": "file_finder::Toggle", + "ctrl-alt-n": "file_finder::Toggle", "ctrl-g": "go_to_line::Toggle", "alt-enter": "editor::ToggleCodeActions", "ctrl-space": "editor::ShowCompletions", "ctrl-q": "editor::Hover", "ctrl-p": "editor::ShowSignatureHelp", - "ctrl-\\": "assistant::InlineAssist" - } + "ctrl-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -91,8 +93,8 @@ "alt-c": "search::ToggleCaseSensitive", "alt-e": "search::ToggleSelection", "alt-x": "search::ToggleRegex", - "alt-w": "search::ToggleWholeWord" - } + "alt-w": "search::ToggleWholeWord", + }, }, { "context": "Workspace", @@ -105,8 +107,8 @@ "ctrl-e": "file_finder::Toggle", "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "ctrl-shift-n": "file_finder::Toggle", - "ctrl-n": "project_symbols::Toggle", "ctrl-alt-n": "file_finder::Toggle", + "ctrl-n": "project_symbols::Toggle", "ctrl-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", "ctrl-alt-shift-n": "project_symbols::Toggle", @@ -114,8 +116,8 @@ "alt-1": "project_panel::ToggleFocus", "alt-5": "debug_panel::ToggleFocus", "alt-6": "diagnostics::Deploy", - "alt-7": "outline_panel::ToggleFocus" - } + "alt-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -129,15 +131,15 @@ "alt-7": "outline_panel::ToggleFocus", "alt-8": null, // Services (bottom dock) "alt-9": null, // Git History (bottom dock) - "alt-0": "git_panel::ToggleFocus" - } + "alt-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "ctrl-shift-k": "git::Push" - } + "ctrl-shift-k": "git::Push", + }, }, { "context": "Pane", @@ -145,8 +147,8 @@ "ctrl-alt-left": "pane::GoBack", "ctrl-alt-right": "pane::GoForward", "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem" - } + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -156,8 +158,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -167,8 +169,8 @@ "ctrl-up": "terminal::ScrollLineUp", "ctrl-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } }, @@ -179,7 +181,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json index eefd59e5bd1aa48125d0c6e3d662f3cb4e270be7..1d689a6f5841a011768113257afbed2c447669ed 100644 --- a/assets/keymaps/linux/sublime_text.json +++ b/assets/keymaps/linux/sublime_text.json @@ -22,8 +22,8 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", @@ -55,20 +55,20 @@ "alt-right": "editor::MoveToNextSubwordEnd", "alt-left": "editor::MoveToPreviousSubwordStart", "alt-shift-right": "editor::SelectToNextSubwordEnd", - "alt-shift-left": "editor::SelectToPreviousSubwordStart" - } + "alt-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "ctrl-r": "outline::Toggle" - } + "ctrl-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "ctrl-k ctrl-z": "git::Restore" - } + "ctrl-k ctrl-z": "git::Restore", + }, }, { "context": "Pane", @@ -83,15 +83,15 @@ "alt-6": ["pane::ActivateItem", 5], "alt-7": ["pane::ActivateItem", 6], "alt-8": ["pane::ActivateItem", 7], - "alt-9": "pane::ActivateLastItem" - } + "alt-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", "bindings": { "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // "ctrl-0": "project_panel::ToggleFocus", // normally resets zoom - "shift-ctrl-r": "project_symbols::Toggle" - } - } + "shift-ctrl-r": "project_symbols::Toggle", + }, + }, ] diff --git a/assets/keymaps/macos/atom.json b/assets/keymaps/macos/atom.json index ca015b667faa05db53d8fdc3bd82352d9bcc62aa..bf049fd3cb3eca8fe8049fa4e0810f82b10a5bbc 100644 --- a/assets/keymaps/macos/atom.json +++ b/assets/keymaps/macos/atom.json @@ -4,16 +4,16 @@ "bindings": { "ctrl-alt-cmd-l": "workspace::Reload", "cmd-k cmd-p": "workspace::ActivatePreviousPane", - "cmd-k cmd-n": "workspace::ActivateNextPane" - } + "cmd-k cmd-n": "workspace::ActivateNextPane", + }, }, { "context": "Editor", "bindings": { "cmd-shift-backspace": "editor::DeleteToBeginningOfLine", "cmd-k cmd-u": "editor::ConvertToUpperCase", - "cmd-k cmd-l": "editor::ConvertToLowerCase" - } + "cmd-k cmd-l": "editor::ConvertToLowerCase", + }, }, { "context": "Editor && mode == full", @@ -33,8 +33,8 @@ "ctrl-cmd-down": "editor::MoveLineDown", "cmd-\\": "workspace::ToggleLeftDock", "ctrl-shift-m": "markdown::OpenPreviewToTheSide", - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "BufferSearchBar", @@ -42,8 +42,8 @@ "cmd-g": ["editor::SelectNext", { "replace_newest": true }], "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], "cmd-f3": "search::SelectNextMatch", - "cmd-shift-f3": "search::SelectPreviousMatch" - } + "cmd-shift-f3": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", @@ -51,8 +51,8 @@ "cmd-\\": "workspace::ToggleLeftDock", "cmd-k cmd-b": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-r": "project_symbols::Toggle" - } + "cmd-shift-r": "project_symbols::Toggle", + }, }, { "context": "Pane", @@ -67,8 +67,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "ProjectPanel", @@ -77,8 +77,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "cmd-x": "project_panel::Cut", "cmd-c": "project_panel::Copy", - "cmd-v": "project_panel::Paste" - } + "cmd-v": "project_panel::Paste", + }, }, { "context": "ProjectPanel && not_editing", @@ -92,7 +92,7 @@ "d": "project_panel::Duplicate", "home": "menu::SelectFirst", "end": "menu::SelectLast", - "shift-a": "project_panel::NewDirectory" - } - } + "shift-a": "project_panel::NewDirectory", + }, + }, ] diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index 97abc7dd819485850107eca6762fc1ed60ec0515..93e259db37ac718d2e0258d83e4de436a0a378fd 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -8,8 +8,8 @@ "cmd-shift-i": "agent::ToggleFocus", "cmd-l": "agent::ToggleFocus", "cmd-shift-l": "agent::ToggleFocus", - "cmd-shift-j": "agent::OpenSettings" - } + "cmd-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -20,19 +20,19 @@ "cmd-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "cmd-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "cmd-k": "assistant::InlineAssist", - "cmd-shift-k": "assistant::InsertIntoEditor" - } + "cmd-shift-k": "assistant::InsertIntoEditor", + }, }, { "context": "InlineAssistEditor", "use_key_equivalents": true, "bindings": { "cmd-shift-backspace": "editor::Cancel", - "cmd-enter": "menu::Confirm" + "cmd-enter": "menu::Confirm", // "alt-enter": // Quick Question // "cmd-shift-enter": // Full File Context // "cmd-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -48,7 +48,7 @@ "cmd-shift-backspace": "editor::Cancel", "cmd-r": "agent::NewThread", "cmd-shift-v": "editor::Paste", - "cmd-shift-k": "assistant::InsertIntoEditor" + "cmd-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "cmd-t": // new thread tab @@ -57,28 +57,29 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "cmd-enter": "agent::KeepAll", - "cmd-backspace": "agent::RejectAll" - } + "cmd-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "cmd-right": "editor::AcceptPartialEditPrediction" - } + "cmd-right": "editor::AcceptNextWordEditPrediction", + "cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "cmd-k": "assistant::InlineAssist" - } - } + "cmd-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index ea831c0c059ea082d002f3af01b8d97be9e86616..2f11e2ce00e8b60a0f1c85b5aeb204e866491a45 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -6,8 +6,8 @@ { "context": "!GitPanel", "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, }, { // Workaround to avoid falling back to default bindings. @@ -15,8 +15,8 @@ // NOTE: must be declared before the `Editor` override. "context": "Editor", "bindings": { - "ctrl-g": null // currently activates `go_to_line::Toggle` when there is nothing to cancel - } + "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel + }, }, { "context": "Editor", @@ -79,8 +79,8 @@ "ctrl-s": "buffer_search::Deploy", // isearch-forward "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -116,22 +116,22 @@ "alt->": "editor::SelectToEnd", "ctrl-home": "editor::SelectToBeginning", "ctrl-end": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, // Example setting for using emacs-style tab // (i.e. indent the current line / selection or perform symbol completion depending on context) @@ -161,8 +161,8 @@ "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { // Workaround to enable using native emacs from the Zed terminal. @@ -182,22 +182,22 @@ "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 364f489167f5abb9af21d9f005586bde08439850..9946d8b124957349181db659259174d906d08d3a 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -13,8 +13,8 @@ "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", "shift-f9": "debugger::Start", - "alt-shift-f9": "debugger::Start" - } + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -60,28 +60,30 @@ "cmd-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "cmd-shift-u": "editor::ToggleCase" - } + "cmd-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", "bindings": { "cmd-f12": "outline::Toggle", "cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }], - "cmd-shift-o": "file_finder::Toggle", "cmd-l": "go_to_line::Toggle", + "cmd-e": "file_finder::Toggle", + "cmd-shift-o": "file_finder::Toggle", + "cmd-shift-n": "file_finder::Toggle", "alt-enter": "editor::ToggleCodeActions", "ctrl-space": "editor::ShowCompletions", "cmd-j": "editor::Hover", "cmd-p": "editor::ShowSignatureHelp", - "cmd-\\": "assistant::InlineAssist" - } + "cmd-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -93,8 +95,8 @@ "ctrl-alt-c": "search::ToggleCaseSensitive", "ctrl-alt-e": "search::ToggleSelection", "ctrl-alt-w": "search::ToggleWholeWord", - "ctrl-alt-x": "search::ToggleRegex" - } + "ctrl-alt-x": "search::ToggleRegex", + }, }, { "context": "Workspace", @@ -116,8 +118,8 @@ "cmd-1": "project_panel::ToggleFocus", "cmd-5": "debug_panel::ToggleFocus", "cmd-6": "diagnostics::Deploy", - "cmd-7": "outline_panel::ToggleFocus" - } + "cmd-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -131,15 +133,15 @@ "cmd-7": "outline_panel::ToggleFocus", "cmd-8": null, // Services (bottom dock) "cmd-9": null, // Git History (bottom dock) - "cmd-0": "git_panel::ToggleFocus" - } + "cmd-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "cmd-shift-k": "git::Push" - } + "cmd-shift-k": "git::Push", + }, }, { "context": "Pane", @@ -147,8 +149,8 @@ "cmd-alt-left": "pane::GoBack", "cmd-alt-right": "pane::GoForward", "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem" - } + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -159,8 +161,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -170,8 +172,8 @@ "cmd-up": "terminal::ScrollLineUp", "cmd-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "cmd-1": "workspace::CloseActiveDock" } }, @@ -182,7 +184,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/macos/sublime_text.json b/assets/keymaps/macos/sublime_text.json index d1bffca755b611d9046d4b7e794d2303835227a2..f4ae1ce5dda4e2c0dd21e97bd3a411dd4a4f3663 100644 --- a/assets/keymaps/macos/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -22,8 +22,8 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", @@ -57,20 +57,20 @@ "ctrl-right": "editor::MoveToNextSubwordEnd", "ctrl-left": "editor::MoveToPreviousSubwordStart", "ctrl-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-shift-left": "editor::SelectToPreviousSubwordStart" - } + "ctrl-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "cmd-k cmd-z": "git::Restore" - } + "cmd-k cmd-z": "git::Restore", + }, }, { "context": "Pane", @@ -85,8 +85,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", @@ -95,7 +95,7 @@ "cmd-t": "file_finder::Toggle", "shift-cmd-r": "project_symbols::Toggle", // Currently busted: https://github.com/zed-industries/feedback/issues/898 - "ctrl-0": "project_panel::ToggleFocus" - } - } + "ctrl-0": "project_panel::ToggleFocus", + }, + }, ] diff --git a/assets/keymaps/macos/textmate.json b/assets/keymaps/macos/textmate.json index f91f39b7f5c079f81b5fcf8e28e2092a33ff1aa4..90450e60af7147f1394eb6cb4c1efc389edad2d0 100644 --- a/assets/keymaps/macos/textmate.json +++ b/assets/keymaps/macos/textmate.json @@ -2,8 +2,8 @@ { "bindings": { "cmd-shift-o": "projects::OpenRecent", - "cmd-alt-tab": "project_panel::ToggleFocus" - } + "cmd-alt-tab": "project_panel::ToggleFocus", + }, }, { "context": "Editor && mode == full", @@ -15,8 +15,8 @@ "cmd-enter": "editor::NewlineBelow", "cmd-alt-enter": "editor::NewlineAbove", "cmd-shift-l": "editor::SelectLine", - "cmd-shift-t": "outline::Toggle" - } + "cmd-shift-t": "outline::Toggle", + }, }, { "context": "Editor", @@ -41,30 +41,30 @@ "ctrl-u": "editor::ConvertToUpperCase", "ctrl-shift-u": "editor::ConvertToLowerCase", "ctrl-alt-u": "editor::ConvertToUpperCamelCase", - "ctrl-_": "editor::ConvertToSnakeCase" - } + "ctrl-_": "editor::ConvertToSnakeCase", + }, }, { "context": "BufferSearchBar", "bindings": { "ctrl-s": "search::SelectNextMatch", - "ctrl-shift-s": "search::SelectPreviousMatch" - } + "ctrl-shift-s": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", "bindings": { "cmd-alt-ctrl-d": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-t": "project_symbols::Toggle" - } + "cmd-shift-t": "project_symbols::Toggle", + }, }, { "context": "Pane", "bindings": { "alt-cmd-r": "search::ToggleRegex", - "ctrl-tab": "project_panel::ToggleFocus" - } + "ctrl-tab": "project_panel::ToggleFocus", + }, }, { "context": "ProjectPanel", @@ -75,11 +75,11 @@ "return": "project_panel::Rename", "cmd-c": "project_panel::Copy", "cmd-v": "project_panel::Paste", - "cmd-alt-c": "project_panel::CopyPath" - } + "cmd-alt-c": "project_panel::CopyPath", + }, }, { "context": "Dock", - "bindings": {} - } + "bindings": {}, + }, ] diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json index 9b92fbe1a3844043e379647d1dd6c57e082fdf77..432bdc7004a4c66b52e20282aba924611b204aa1 100644 --- a/assets/keymaps/storybook.json +++ b/assets/keymaps/storybook.json @@ -27,7 +27,7 @@ "backspace": "editor::Backspace", "delete": "editor::Delete", "left": "editor::MoveLeft", - "right": "editor::MoveRight" - } - } + "right": "editor::MoveRight", + }, + }, ] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 34228530d2d398348d57d71ae41654dacb479712..6e5d3423872a7dd83234b28e67c5082b36bd858f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -180,10 +180,9 @@ "ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit", "ctrl-w space": "editor::OpenExcerptsSplit", "ctrl-w g space": "editor::OpenExcerptsSplit", - "ctrl-6": "pane::AlternateFile", "ctrl-^": "pane::AlternateFile", - ".": "vim::Repeat" - } + ".": "vim::Repeat", + }, }, { "context": "vim_mode == normal || vim_mode == visual || vim_mode == operator", @@ -224,8 +223,8 @@ "] r": "vim::GoToNextReference", // tree-sitter related commands "[ x": "vim::SelectLargerSyntaxNode", - "] x": "vim::SelectSmallerSyntaxNode" - } + "] x": "vim::SelectSmallerSyntaxNode", + }, }, { "context": "vim_mode == normal", @@ -262,16 +261,16 @@ "[ d": "editor::GoToPreviousDiagnostic", "] c": "editor::GoToHunk", "[ c": "editor::GoToPreviousHunk", - "g c": "vim::PushToggleComments" - } + "g c": "vim::PushToggleComments", + }, }, { "context": "VimControl && VimCount", "bindings": { "0": ["vim::Number", 0], ":": "vim::CountCommand", - "%": "vim::GoToPercentage" - } + "%": "vim::GoToPercentage", + }, }, { "context": "vim_mode == visual", @@ -323,8 +322,8 @@ "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", - "\"": "vim::PushRegister" - } + "\"": "vim::PushRegister", + }, }, { "context": "vim_mode == helix_select", @@ -344,8 +343,8 @@ "ctrl-pageup": "pane::ActivatePreviousItem", "ctrl-pagedown": "pane::ActivateNextItem", ".": "vim::Repeat", - "alt-.": "vim::RepeatFind" - } + "alt-.": "vim::RepeatFind", + }, }, { "context": "vim_mode == insert", @@ -375,8 +374,8 @@ "ctrl-r": "vim::PushRegister", "insert": "vim::ToggleReplace", "ctrl-o": "vim::TemporaryNormal", - "ctrl-s": "editor::ShowSignatureHelp" - } + "ctrl-s": "editor::ShowSignatureHelp", + }, }, { "context": "showing_completions", @@ -384,8 +383,8 @@ "ctrl-d": "vim::ScrollDown", "ctrl-u": "vim::ScrollUp", "ctrl-e": "vim::LineDown", - "ctrl-y": "vim::LineUp" - } + "ctrl-y": "vim::LineUp", + }, }, { "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu", @@ -410,23 +409,31 @@ "shift-s": "vim::SubstituteLine", "\"": "vim::PushRegister", "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem" - } + "ctrl-pageup": "pane::ActivatePreviousItem", + }, }, { "context": "VimControl && vim_mode == helix_normal && !menu", "bindings": { + "j": ["vim::Down", { "display_lines": true }], + "down": ["vim::Down", { "display_lines": true }], + "k": ["vim::Up", { "display_lines": true }], + "up": ["vim::Up", { "display_lines": true }], + "g j": "vim::Down", + "g down": "vim::Down", + "g k": "vim::Up", + "g up": "vim::Up", "escape": "vim::SwitchToHelixNormalMode", "i": "vim::HelixInsert", "a": "vim::HelixAppend", - "ctrl-[": "editor::Cancel" - } + "ctrl-[": "editor::Cancel", + }, }, { "context": "vim_mode == helix_select && !menu", "bindings": { - "escape": "vim::SwitchToHelixNormalMode" - } + "escape": "vim::SwitchToHelixNormalMode", + }, }, { "context": "(vim_mode == helix_normal || vim_mode == helix_select) && !menu", @@ -446,9 +453,9 @@ "shift-r": "editor::Paste", "`": "vim::ConvertToLowerCase", "alt-`": "vim::ConvertToUpperCase", - "insert": "vim::InsertBefore", + "insert": "vim::InsertBefore", // not a helix default "shift-u": "editor::Redo", - "ctrl-r": "vim::Redo", + "ctrl-r": "vim::Redo", // not a helix default "y": "vim::HelixYank", "p": "vim::HelixPaste", "shift-p": ["vim::HelixPaste", { "before": true }], @@ -477,6 +484,7 @@ "alt-p": "editor::SelectPreviousSyntaxNode", "alt-n": "editor::SelectNextSyntaxNode", + // Search "n": "vim::HelixSelectNext", "shift-n": "vim::HelixSelectPrevious", @@ -484,27 +492,32 @@ "g e": "vim::EndOfDocument", "g h": "vim::StartOfLine", "g l": "vim::EndOfLine", - "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" + "g s": "vim::FirstNonWhitespace", "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", - "g r": "editor::FindAllReferences", // zed specific + "g r": "editor::FindAllReferences", "g n": "pane::ActivateNextItem", - "shift-l": "pane::ActivateNextItem", + "shift-l": "pane::ActivateNextItem", // not a helix default "g p": "pane::ActivatePreviousItem", - "shift-h": "pane::ActivatePreviousItem", - "g .": "vim::HelixGotoLastModification", // go to last modification + "shift-h": "pane::ActivatePreviousItem", // not a helix default + "g .": "vim::HelixGotoLastModification", + "g o": "editor::ToggleSelectedDiffHunks", // Zed specific + "g shift-o": "git::ToggleStaged", // Zed specific + "g shift-r": "git::Restore", // Zed specific + "g u": "git::StageAndNext", // Zed specific + "g shift-u": "git::UnstageAndNext", // Zed specific // Window mode + "space w v": "pane::SplitDown", + "space w s": "pane::SplitRight", "space w h": "workspace::ActivatePaneLeft", - "space w l": "workspace::ActivatePaneRight", - "space w k": "workspace::ActivatePaneUp", "space w j": "workspace::ActivatePaneDown", + "space w k": "workspace::ActivatePaneUp", + "space w l": "workspace::ActivatePaneRight", "space w q": "pane::CloseActiveItem", - "space w s": "pane::SplitRight", - "space w r": "pane::SplitRight", - "space w v": "pane::SplitDown", - "space w d": "pane::SplitDown", + "space w r": "pane::SplitRight", // not a helix default + "space w d": "pane::SplitDown", // not a helix default // Space mode "space f": "file_finder::Toggle", @@ -518,6 +531,7 @@ "space c": "editor::ToggleComments", "space p": "editor::Paste", "space y": "editor::Copy", + "space /": "pane::DeploySearch", // Other ":": "command_palette::Toggle", @@ -525,24 +539,22 @@ "]": ["vim::PushHelixNext", { "around": true }], "[": ["vim::PushHelixPrevious", { "around": true }], "g q": "vim::PushRewrap", - "g w": "vim::PushRewrap" - // "tab": "pane::ActivateNextItem", - // "shift-tab": "pane::ActivatePrevItem", - } + "g w": "vim::PushRewrap", // not a helix default & clashes with helix `goto_word` + }, }, { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ShowWordCompletions", - "ctrl-n": "editor::ShowWordCompletions" - } + "ctrl-n": "editor::ShowWordCompletions", + }, }, { "context": "(vim_mode == insert || vim_mode == normal) && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, { "context": "vim_mode == replace", @@ -558,8 +570,8 @@ "backspace": "vim::UndoReplace", "tab": "vim::Tab", "enter": "vim::Enter", - "insert": "vim::InsertBefore" - } + "insert": "vim::InsertBefore", + }, }, { "context": "vim_mode == waiting", @@ -571,14 +583,14 @@ "escape": "vim::ClearOperators", "ctrl-k": ["vim::PushDigraph", {}], "ctrl-v": ["vim::PushLiteral", {}], - "ctrl-q": ["vim::PushLiteral", {}] - } + "ctrl-q": ["vim::PushLiteral", {}], + }, }, { "context": "Editor && vim_mode == waiting && (vim_operator == ys || vim_operator == cs)", "bindings": { - "escape": "vim::SwitchToNormalMode" - } + "escape": "vim::SwitchToNormalMode", + }, }, { "context": "vim_mode == operator", @@ -586,8 +598,8 @@ "ctrl-c": "vim::ClearOperators", "ctrl-[": "vim::ClearOperators", "escape": "vim::ClearOperators", - "g c": "vim::Comment" - } + "g c": "vim::Comment", + }, }, { "context": "vim_operator == a || vim_operator == i || vim_operator == cs || vim_operator == helix_next || vim_operator == helix_previous", @@ -624,14 +636,14 @@ "shift-i": ["vim::IndentObj", { "include_below": true }], "f": "vim::Method", "c": "vim::Class", - "e": "vim::EntireFile" - } + "e": "vim::EntireFile", + }, }, { "context": "vim_operator == helix_m", "bindings": { - "m": "vim::Matching" - } + "m": "vim::Matching", + }, }, { "context": "vim_operator == helix_next", @@ -648,8 +660,8 @@ "x": "editor::SelectSmallerSyntaxNode", "d": "editor::GoToDiagnostic", "c": "editor::GoToHunk", - "space": "vim::InsertEmptyLineBelow" - } + "space": "vim::InsertEmptyLineBelow", + }, }, { "context": "vim_operator == helix_previous", @@ -666,8 +678,8 @@ "x": "editor::SelectLargerSyntaxNode", "d": "editor::GoToPreviousDiagnostic", "c": "editor::GoToPreviousHunk", - "space": "vim::InsertEmptyLineAbove" - } + "space": "vim::InsertEmptyLineAbove", + }, }, { "context": "vim_operator == c", @@ -675,8 +687,8 @@ "c": "vim::CurrentLine", "x": "vim::Exchange", "d": "editor::Rename", // zed specific - "s": ["vim::PushChangeSurrounds", {}] - } + "s": ["vim::PushChangeSurrounds", {}], + }, }, { "context": "vim_operator == d", @@ -688,36 +700,36 @@ "shift-o": "git::ToggleStaged", "p": "git::Restore", // "d p" "u": "git::StageAndNext", // "d u" - "shift-u": "git::UnstageAndNext" // "d shift-u" - } + "shift-u": "git::UnstageAndNext", // "d shift-u" + }, }, { "context": "vim_operator == gu", "bindings": { "g u": "vim::CurrentLine", - "u": "vim::CurrentLine" - } + "u": "vim::CurrentLine", + }, }, { "context": "vim_operator == gU", "bindings": { "g shift-u": "vim::CurrentLine", - "shift-u": "vim::CurrentLine" - } + "shift-u": "vim::CurrentLine", + }, }, { "context": "vim_operator == g~", "bindings": { "g ~": "vim::CurrentLine", - "~": "vim::CurrentLine" - } + "~": "vim::CurrentLine", + }, }, { "context": "vim_operator == g?", "bindings": { "g ?": "vim::CurrentLine", - "?": "vim::CurrentLine" - } + "?": "vim::CurrentLine", + }, }, { "context": "vim_operator == gq", @@ -725,66 +737,66 @@ "g q": "vim::CurrentLine", "q": "vim::CurrentLine", "g w": "vim::CurrentLine", - "w": "vim::CurrentLine" - } + "w": "vim::CurrentLine", + }, }, { "context": "vim_operator == y", "bindings": { "y": "vim::CurrentLine", "v": "vim::PushForcedMotion", - "s": ["vim::PushAddSurrounds", {}] - } + "s": ["vim::PushAddSurrounds", {}], + }, }, { "context": "vim_operator == ys", "bindings": { - "s": "vim::CurrentLine" - } + "s": "vim::CurrentLine", + }, }, { "context": "vim_operator == >", "bindings": { - ">": "vim::CurrentLine" - } + ">": "vim::CurrentLine", + }, }, { "context": "vim_operator == <", "bindings": { - "<": "vim::CurrentLine" - } + "<": "vim::CurrentLine", + }, }, { "context": "vim_operator == eq", "bindings": { - "=": "vim::CurrentLine" - } + "=": "vim::CurrentLine", + }, }, { "context": "vim_operator == sh", "bindings": { - "!": "vim::CurrentLine" - } + "!": "vim::CurrentLine", + }, }, { "context": "vim_operator == gc", "bindings": { - "c": "vim::CurrentLine" - } + "c": "vim::CurrentLine", + }, }, { "context": "vim_operator == gR", "bindings": { "r": "vim::CurrentLine", - "shift-r": "vim::CurrentLine" - } + "shift-r": "vim::CurrentLine", + }, }, { "context": "vim_operator == cx", "bindings": { "x": "vim::CurrentLine", - "c": "vim::ClearExchange" - } + "c": "vim::ClearExchange", + }, }, { "context": "vim_mode == literal", @@ -826,15 +838,15 @@ "tab": ["vim::Literal", ["tab", "\u0009"]], // zed extensions: "backspace": ["vim::Literal", ["backspace", "\u0008"]], - "delete": ["vim::Literal", ["delete", "\u007F"]] - } + "delete": ["vim::Literal", ["delete", "\u007F"]], + }, }, { "context": "BufferSearchBar && !in_replace", "bindings": { "enter": "vim::SearchSubmit", - "escape": "buffer_search::Dismiss" - } + "escape": "buffer_search::Dismiss", + }, }, { "context": "VimControl && !menu || !Editor && !Terminal", @@ -857,6 +869,8 @@ "ctrl-w shift-right": "workspace::SwapPaneRight", "ctrl-w shift-up": "workspace::SwapPaneUp", "ctrl-w shift-down": "workspace::SwapPaneDown", + "ctrl-w x": "workspace::SwapPaneAdjacent", + "ctrl-w ctrl-x": "workspace::SwapPaneAdjacent", "ctrl-w shift-h": "workspace::MovePaneLeft", "ctrl-w shift-l": "workspace::MovePaneRight", "ctrl-w shift-k": "workspace::MovePaneUp", @@ -893,15 +907,19 @@ "ctrl-w ctrl-n": "workspace::NewFileSplitHorizontal", "ctrl-w n": "workspace::NewFileSplitHorizontal", "g t": "vim::GoToTab", - "g shift-t": "vim::GoToPreviousTab" - } + "g shift-t": "vim::GoToPreviousTab", + }, }, { "context": "!Editor && !Terminal", "bindings": { ":": "command_palette::Toggle", - "g /": "pane::DeploySearch" - } + "g /": "pane::DeploySearch", + "] b": "pane::ActivateNextItem", + "[ b": "pane::ActivatePreviousItem", + "] shift-b": "pane::ActivateLastItem", + "[ shift-b": ["pane::ActivateItem", 0], + }, }, { // netrw compatibility @@ -951,17 +969,45 @@ "6": ["vim::Number", 6], "7": ["vim::Number", 7], "8": ["vim::Number", 8], - "9": ["vim::Number", 9] - } + "9": ["vim::Number", 9], + }, }, { "context": "OutlinePanel && not_editing", "bindings": { - "j": "menu::SelectNext", - "k": "menu::SelectPrevious", + "h": "outline_panel::CollapseSelectedEntry", + "j": "vim::MenuSelectNext", + "k": "vim::MenuSelectPrevious", + "down": "vim::MenuSelectNext", + "up": "vim::MenuSelectPrevious", + "l": "outline_panel::ExpandSelectedEntry", "shift-g": "menu::SelectLast", - "g g": "menu::SelectFirst" - } + "g g": "menu::SelectFirst", + "-": "outline_panel::SelectParent", + "enter": "editor::ToggleFocus", + "/": "menu::Cancel", + "ctrl-u": "outline_panel::ScrollUp", + "ctrl-d": "outline_panel::ScrollDown", + "z t": "outline_panel::ScrollCursorTop", + "z z": "outline_panel::ScrollCursorCenter", + "z b": "outline_panel::ScrollCursorBottom", + "0": ["vim::Number", 0], + "1": ["vim::Number", 1], + "2": ["vim::Number", 2], + "3": ["vim::Number", 3], + "4": ["vim::Number", 4], + "5": ["vim::Number", 5], + "6": ["vim::Number", 6], + "7": ["vim::Number", 7], + "8": ["vim::Number", 8], + "9": ["vim::Number", 9], + }, + }, + { + "context": "OutlinePanel && editing", + "bindings": { + "enter": "menu::Cancel", + }, }, { "context": "GitPanel && ChangesList", @@ -976,8 +1022,8 @@ "x": "git::ToggleStaged", "shift-x": "git::StageAll", "g x": "git::StageRange", - "shift-u": "git::UnstageAll" - } + "shift-u": "git::UnstageAll", + }, }, { "context": "Editor && mode == auto_height && VimControl", @@ -988,8 +1034,8 @@ "#": null, "*": null, "n": null, - "shift-n": null - } + "shift-n": null, + }, }, { "context": "Picker > Editor", @@ -998,29 +1044,29 @@ "ctrl-u": "editor::DeleteToBeginningOfLine", "ctrl-w": "editor::DeleteToPreviousWordStart", "ctrl-p": "menu::SelectPrevious", - "ctrl-n": "menu::SelectNext" - } + "ctrl-n": "menu::SelectNext", + }, }, { "context": "GitCommit > Editor && VimControl && vim_mode == normal", "bindings": { "ctrl-c": "menu::Cancel", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Editor && edit_prediction", "bindings": { // This is identical to the binding in the base keymap, but the vim bindings above to // "vim::Tab" shadow it, so it needs to be bound again. - "tab": "editor::AcceptEditPrediction" - } + "tab": "editor::AcceptEditPrediction", + }, }, { "context": "MessageEditor > Editor && VimControl", "bindings": { - "enter": "agent::Chat" - } + "enter": "agent::Chat", + }, }, { "context": "os != macos && Editor && edit_prediction_conflict", @@ -1028,8 +1074,8 @@ // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This // is because alt-tab may not be available, as it is often used for window switching on Linux // and Windows. - "alt-l": "editor::AcceptEditPrediction" - } + "alt-l": "editor::AcceptEditPrediction", + }, }, { "context": "SettingsWindow > NavigationMenu && !search", @@ -1039,7 +1085,16 @@ "k": "settings_editor::FocusPreviousNavEntry", "j": "settings_editor::FocusNextNavEntry", "g g": "settings_editor::FocusFirstNavEntry", - "shift-g": "settings_editor::FocusLastNavEntry" - } - } + "shift-g": "settings_editor::FocusLastNavEntry", + }, + }, + { + "context": "MarkdownPreview", + "bindings": { + "ctrl-u": "markdown::ScrollPageUp", + "ctrl-d": "markdown::ScrollPageDown", + "ctrl-y": "markdown::ScrollUp", + "ctrl-e": "markdown::ScrollDown", + }, + }, ] diff --git a/assets/prompts/content_prompt_v2.hbs b/assets/prompts/content_prompt_v2.hbs new file mode 100644 index 0000000000000000000000000000000000000000..826aada8c04863c21d756cf99beb64e582ed4906 --- /dev/null +++ b/assets/prompts/content_prompt_v2.hbs @@ -0,0 +1,40 @@ +{{#if language_name}} +Here's a file of {{language_name}} that the user is going to ask you to make an edit to. +{{else}} +Here's a file of text that the user is going to ask you to make an edit to. +{{/if}} + +The section you'll need to rewrite is marked with tags. + + +{{{document_content}}} + + +{{#if is_truncated}} +The context around the relevant section has been truncated (possibly in the middle of a line) for brevity. +{{/if}} + +And here's the section to rewrite based on that prompt again for reference: + + +{{{rewrite_section}}} + + +{{#if diagnostic_errors}} +Below are the diagnostic errors visible to the user. If the user requests problems to be fixed, use this information, but do not try to fix these errors if the user hasn't asked you to. + +{{#each diagnostic_errors}} + + {{line_number}} + {{error_message}} + {{code_content}} + +{{/each}} +{{/if}} + +Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding {{content_type}} will be preserved. + +Start at the indentation level in the original file in the rewritten {{content_type}}. + +IMPORTANT: You MUST use one of the provided tools to make the rewrite or to provide an explanation as to why the user's request cannot be fulfilled. You MUST NOT send back unstructured text. If you need to make a statement or ask a question you MUST use one of the tools to do so. +It is an error if you try to make a change that cannot be made simply by editing the rewrite_section. diff --git a/assets/settings/default.json b/assets/settings/default.json index c8ffd31617df7d057e89329c2db70c6b6aa21e95..154fe2d6e34e6573e95e7ffedbb46df8bbf10634 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -12,7 +12,7 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" + "dark": "One Dark", }, "icon_theme": "Zed (Default)", // The name of a base set of key bindings to use. @@ -29,7 +29,7 @@ // Features that can be globally enabled or disabled "features": { // Which edit prediction provider to use. - "edit_prediction_provider": "zed" + "edit_prediction_provider": "zed", }, // The name of a font to use for rendering text in the editor // ".ZedMono" currently aliases to Lilex @@ -69,7 +69,7 @@ // The OpenType features to enable for text in the UI "ui_font_features": { // Disable ligatures: - "calt": false + "calt": false, }, // The weight of the UI font in standard CSS units from 100 to 900. "ui_font_weight": 400, @@ -87,7 +87,7 @@ "border_size": 0.0, // Opacity of the inactive panes. 0 means transparent, 1 means opaque. // Values are clamped to the [0.0, 1.0] range. - "inactive_opacity": 1.0 + "inactive_opacity": 1.0, }, // Layout mode of the bottom dock. Defaults to "contained" // choices: contained, full, left_aligned, right_aligned @@ -103,12 +103,12 @@ "left_padding": 0.2, // The relative width of the right padding of the central pane from the // workspace when the centered layout is used. - "right_padding": 0.2 + "right_padding": 0.2, }, // Image viewer settings "image_viewer": { // The unit for image file sizes: "binary" (KiB, MiB) or decimal (KB, MB) - "unit": "binary" + "unit": "binary", }, // Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. // @@ -296,7 +296,7 @@ // When true, enables drag and drop text selection in buffer. "enabled": true, // The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. - "delay": 300 + "delay": 300, }, // What to do when go to definition yields no results. // @@ -400,14 +400,14 @@ // Visible characters used to render whitespace when show_whitespaces is enabled. "whitespace_map": { "space": "•", - "tab": "→" + "tab": "→", }, // Settings related to calls in Zed "calls": { // Join calls with the microphone live by default "mute_on_join": false, // Share your project when you are the first to join a channel - "share_on_join": false + "share_on_join": false, }, // Toolbar related settings "toolbar": { @@ -420,7 +420,7 @@ // Whether to show agent review buttons in the editor toolbar. "agent_review": true, // Whether to show code action buttons in the editor toolbar. - "code_actions": false + "code_actions": false, }, // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). "use_system_window_tabs": false, @@ -436,10 +436,12 @@ "show_onboarding_banner": true, // Whether to show user picture in the titlebar. "show_user_picture": true, + // Whether to show the user menu in the titlebar. + "show_user_menu": true, // Whether to show the sign in button in the titlebar. "show_sign_in": true, // Whether to show the menus in the titlebar. - "show_menus": false + "show_menus": false, }, "audio": { // Opt into the new audio system. @@ -472,7 +474,7 @@ // the future we will migrate by setting this to false // // You need to rejoin a call for this setting to apply - "experimental.legacy_audio_compatible": true + "experimental.legacy_audio_compatible": true, }, // Scrollbar related settings "scrollbar": { @@ -511,8 +513,8 @@ // When false, forcefully disables the horizontal scrollbar. Otherwise, obey other settings. "horizontal": true, // When false, forcefully disables the vertical scrollbar. Otherwise, obey other settings. - "vertical": true - } + "vertical": true, + }, }, // Minimap related settings "minimap": { @@ -560,7 +562,7 @@ // 3. "gutter" or "none" to not highlight the current line in the minimap. "current_line_highlight": null, // Maximum number of columns to display in the minimap. - "max_width_columns": 80 + "max_width_columns": 80, }, // Enable middle-click paste on Linux. "middle_click_paste": true, @@ -583,7 +585,7 @@ // Whether to show fold buttons in the gutter. "folds": true, // Minimum number of characters to reserve space for in the gutter. - "min_line_number_digits": 4 + "min_line_number_digits": 4, }, "indent_guides": { // Whether to show indent guides in the editor. @@ -604,7 +606,7 @@ // // 1. "disabled" // 2. "indent_aware" - "background_coloring": "disabled" + "background_coloring": "disabled", }, // Whether the editor will scroll beyond the last line. "scroll_beyond_last_line": "one_page", @@ -623,7 +625,7 @@ "fast_scroll_sensitivity": 4.0, "sticky_scroll": { // Whether to stick scopes to the top of the editor. - "enabled": false + "enabled": false, }, "relative_line_numbers": "disabled", // If 'search_wrap' is disabled, search result do not wrap around the end of the file. @@ -641,7 +643,7 @@ // Whether to interpret the search query as a regular expression. "regex": false, // Whether to center the cursor on each search match when navigating. - "center_on_match": false + "center_on_match": false, }, // When to populate a new search's query based on the text under the cursor. // This setting can take the following three values: @@ -684,8 +686,8 @@ "shift": false, "alt": false, "platform": false, - "function": false - } + "function": false, + }, }, // Whether to resize all the panels in a dock when resizing the dock. // Can be a combination of "left", "right" and "bottom". @@ -733,7 +735,7 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Which files containing diagnostic errors/warnings to mark in the project panel. // This setting can take the following three values: @@ -756,7 +758,7 @@ // "always" // 2. Never show indent guides: // "never" - "show": "always" + "show": "always", }, // Sort order for entries in the project panel. // This setting can take three values: @@ -781,8 +783,8 @@ // Whether to automatically open files after pasting or duplicating them. "on_paste": true, // Whether to automatically open files dropped from external sources. - "on_drop": true - } + "on_drop": true, + }, }, "outline_panel": { // Whether to show the outline panel button in the status bar @@ -815,7 +817,7 @@ // "always" // 2. Never show indent guides: // "never" - "show": "always" + "show": "always", }, // Scrollbar-related settings "scrollbar": { @@ -832,11 +834,11 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Default depth to expand outline items in the current file. // Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper. - "expand_outlines_with_depth": 100 + "expand_outlines_with_depth": 100, }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. @@ -844,7 +846,7 @@ // Where to dock the collaboration panel. Can be 'left' or 'right'. "dock": "left", // Default width of the collaboration panel. - "default_width": 240 + "default_width": 240, }, "git_panel": { // Whether to show the git panel button in the status bar. @@ -870,18 +872,22 @@ // // Default: false "collapse_untracked_diff": false, + /// Whether to show entries with tree or flat view in the panel + /// + /// Default: false + "tree_view": false, "scrollbar": { // When to show the scrollbar in the git panel. // // Choices: always, auto, never, system // Default: inherits editor scrollbar settings // "show": null - } + }, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. // For example: typing `:wave:` gets replaced with `👋`. - "auto_replace_emoji_shortcode": true + "auto_replace_emoji_shortcode": true, }, "notification_panel": { // Whether to show the notification panel button in the status bar. @@ -889,9 +895,11 @@ // Where to dock the notification panel. Can be 'left' or 'right'. "dock": "right", // Default width of the notification panel. - "default_width": 380 + "default_width": 380, }, "agent": { + // Whether the inline assistant should use streaming tools, when available + "inline_assistant_use_streaming_tools": true, // Whether the agent is enabled. "enabled": true, // What completion mode to start new threads in, if available. Can be 'normal' or 'burn'. @@ -900,6 +908,8 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "right", + // Where to dock the agents panel. Can be 'left' or 'right'. + "agents_panel_dock": "left", // Default width when the agent panel is docked to the left or right. "default_width": 640, // Default height when the agent panel is docked to the bottom. @@ -911,7 +921,7 @@ // The provider to use. "provider": "zed.dev", // The model to use. - "model": "claude-sonnet-4" + "model": "claude-sonnet-4", }, // Additional parameters for language model requests. When making a request to a model, parameters will be taken // from the last entry in this list that matches the model's provider and name. In each entry, both provider @@ -962,12 +972,14 @@ "now": true, "find_path": true, "read_file": true, + "restore_file_from_disk": true, + "save_file": true, "open": true, "grep": true, "terminal": true, "thinking": true, - "web_search": true - } + "web_search": true, + }, }, "ask": { "name": "Ask", @@ -984,14 +996,14 @@ "open": true, "grep": true, "thinking": true, - "web_search": true - } + "web_search": true, + }, }, "minimal": { "name": "Minimal", "enable_all_context_servers": false, - "tools": {} - } + "tools": {}, + }, }, // Where to show notifications when the agent has either completed // its response, or else needs confirmation before it can run a @@ -1020,7 +1032,7 @@ // Minimum number of lines to display in the agent message editor. // // Default: 4 - "message_editor_min_lines": 4 + "message_editor_min_lines": 4, }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, @@ -1055,7 +1067,7 @@ // Whether or not to show the navigation history buttons. "show_nav_history_buttons": true, // Whether or not to show the tab bar buttons. - "show_tab_bar_buttons": true + "show_tab_bar_buttons": true, }, // Settings related to the editor's tabs "tabs": { @@ -1094,19 +1106,28 @@ // "errors" // 3. Mark files with errors and warnings: // "all" - "show_diagnostics": "off" + "show_diagnostics": "off", }, // Settings related to preview tabs. "preview_tabs": { // Whether preview tabs should be enabled. // Preview tabs allow you to open files in preview mode, where they close automatically - // when you switch to another file unless you explicitly pin them. + // when you open another preview tab. // This is useful for quickly viewing files without cluttering your workspace. "enabled": true, + // Whether to open tabs in preview mode when opened from the project panel with a single click. + "enable_preview_from_project_panel": true, // Whether to open tabs in preview mode when selected from the file finder. "enable_preview_from_file_finder": false, - // Whether a preview tab gets replaced when code navigation is used to navigate away from the tab. - "enable_preview_from_code_navigation": false + // Whether to open tabs in preview mode when opened from a multibuffer. + "enable_preview_from_multibuffer": true, + // Whether to open tabs in preview mode when code navigation is used to open a multibuffer. + "enable_preview_multibuffer_from_code_navigation": false, + // Whether to open tabs in preview mode when code navigation is used to open a single file. + "enable_preview_file_from_code_navigation": true, + // Whether to keep tabs in preview mode when code navigation is used to navigate away from them. + // If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. + "enable_keep_preview_on_code_navigation": false, }, // Settings related to the file finder. "file_finder": { @@ -1150,7 +1171,7 @@ // * "all": Use all gitignored files // * "indexed": Use only the files Zed had indexed // * "smart": Be smart and search for ignored when called from a gitignored worktree - "include_ignored": "smart" + "include_ignored": "smart", }, // Whether or not to remove any trailing whitespace from lines of a buffer // before saving it. @@ -1209,12 +1230,19 @@ "tab_size": 4, // What debuggers are preferred by default for all languages. "debuggers": [], + // Whether to enable word diff highlighting in the editor. + // + // When enabled, changed words within modified lines are highlighted + // to show exactly what changed. + // + // Default: true + "word_diff_enabled": true, // Control what info is collected by Zed. "telemetry": { // Send debug info like crash reports. "diagnostics": true, // Send anonymized usage data like what languages you're using Zed with. - "metrics": true + "metrics": true, }, // Whether to disable all AI features in Zed. // @@ -1248,7 +1276,7 @@ "enabled": true, // Minimum time to wait before pulling diagnostics from the language server(s). // 0 turns the debounce off. - "debounce_ms": 50 + "debounce_ms": 50, }, // Settings for inline diagnostics "inline": { @@ -1266,8 +1294,8 @@ "min_column": 0, // The minimum severity of the diagnostics to show inline. // Inherits editor's diagnostics' max severity settings when `null`. - "max_severity": null - } + "max_severity": null, + }, }, // Files or globs of files that will be excluded by Zed entirely. They will be skipped during file // scans, file searches, and not be displayed in the project file tree. Takes precedence over `file_scan_inclusions`. @@ -1281,7 +1309,7 @@ "**/.DS_Store", "**/Thumbs.db", "**/.classpath", - "**/.settings" + "**/.settings", ], // Files or globs of files that will be included by Zed, even when ignored by git. This is useful // for files that are not tracked by git, but are still important to your project. Note that globs @@ -1293,6 +1321,14 @@ "hidden_files": ["**/.*"], // Git gutter behavior configuration. "git": { + // Global switch to enable or disable all git integration features. + // If set to true, disables all git integration features. + // If set to false, individual git integration features below will be independently enabled or disabled. + "disable_git": false, + // Whether to enable git status tracking. + "enable_status": true, + // Whether to enable git diff display. + "enable_diff": true, // Control whether the git gutter is shown. May take 2 values: // 1. Show the gutter // "git_gutter": "tracked_files" @@ -1316,14 +1352,14 @@ // Whether or not to display the git commit summary on the same line. "show_commit_summary": false, // The minimum column number to show the inline blame information at - "min_column": 0 + "min_column": 0, }, "blame": { - "show_avatar": true + "show_avatar": true, }, // Control which information is shown in the branch picker. "branch_picker": { - "show_author_name": true + "show_author_name": true, }, // How git hunks are displayed visually in the editor. // This setting can take two values: @@ -1335,7 +1371,7 @@ "hunk_style": "staged_hollow", // Should the name or path be displayed first in the git view. // "path_style": "file_name_first" or "file_path_first" - "path_style": "file_name_first" + "path_style": "file_name_first", }, // The list of custom Git hosting providers. "git_hosting_providers": [ @@ -1350,6 +1386,8 @@ // "load_direnv": "direct" // 2. Load direnv configuration through the shell hook, works for POSIX shells and fish. // "load_direnv": "shell_hook" + // 3. Don't load direnv configuration at all. + // "load_direnv": "disabled" "load_direnv": "direct", "edit_predictions": { // A list of globs representing files that edit predictions should be disabled for. @@ -1367,7 +1405,7 @@ "**/secrets.yml", "**/.zed/settings.json", // zed project settings "/**/zed/settings.json", // zed user settings - "/**/zed/keymap.json" + "/**/zed/keymap.json", ], // When to show edit predictions previews in buffer. // This setting takes two possible values: @@ -1385,15 +1423,16 @@ "copilot": { "enterprise_uri": null, "proxy": null, - "proxy_no_verify": null + "proxy_no_verify": null, }, "codestral": { - "model": null, - "max_tokens": null + "api_url": "https://codestral.mistral.ai", + "model": "codestral-latest", + "max_tokens": 150, }, // Whether edit predictions are enabled when editing text threads in the agent panel. // This setting has no effect if globally disabled. - "enabled_in_text_threads": true + "enabled_in_text_threads": true, }, // Settings specific to journaling "journal": { @@ -1403,7 +1442,7 @@ // May take 2 values: // 1. hour12 // 2. hour24 - "hour_format": "hour12" + "hour_format": "hour12", }, // Status bar-related settings. "status_bar": { @@ -1414,7 +1453,7 @@ // Whether to show the cursor position button in the status bar. "cursor_position_button": true, // Whether to show active line endings button in the status bar. - "line_endings_button": false + "line_endings_button": false, }, // Settings specific to the terminal "terminal": { @@ -1535,8 +1574,8 @@ // Preferred Conda manager to use when activating Conda environments. // Values: "auto", "conda", "mamba", "micromamba" // Default: "auto" - "conda_manager": "auto" - } + "conda_manager": "auto", + }, }, "toolbar": { // Whether to display the terminal title in its toolbar's breadcrumbs. @@ -1544,7 +1583,7 @@ // // The shell running in the terminal needs to be configured to emit the title. // Example: `echo -e "\e]2;New Title\007";` - "breadcrumbs": false + "breadcrumbs": false, }, // Scrollbar-related settings "scrollbar": { @@ -1561,7 +1600,7 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Set the terminal's font size. If this option is not included, // the terminal will default to matching the buffer's font size. @@ -1624,30 +1663,26 @@ // surrounding symbols or quotes [ "(?x)", - "# optionally starts with 0-2 opening prefix symbols", - "[({\\[<]{0,2}", - "# which may be followed by an opening quote", - "(?[\"'`])?", - "# `path` is the shortest sequence of any non-space character", - "(?(?[^ ]+?", - " # which may end with a line and optionally a column,", - " (?:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?", - "))", - "# which must be followed by a matching quote", - "(?()\\k)", - "# and optionally a single closing symbol", - "[)}\\]>]?", - "# if line/column matched, may be followed by a description", - "(?():[^ 0-9][^ ]*)?", - "# which may be followed by trailing punctuation", - "[.,:)}\\]>]*", - "# and always includes trailing whitespace or end of line", - "([ ]+|$)" - ] + "(?", + " (", + " # multi-char path: first char (not opening delimiter or space)", + " [^({\\[<\"'`\\ ]", + " # middle chars: non-space, and colon/paren only if not followed by digit/paren", + " ([^\\ :(]|[:(][^0-9()])*", + " # last char: not closing delimiter or colon", + " [^()}\\]>\"'`.,;:\\ ]", + " |", + " # single-char path: not delimiter, punctuation, or space", + " [^(){}\\[\\]<>\"'`.,;:\\ ]", + " )", + " # optional line/column suffix (included in path for PathWithPosition::parse_str)", + " (:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:]?[0-9]+)?\\))?", + ")", + ], ], // Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. Specifying a // timeout of `0` will disable path hyperlinking in terminal. - "path_hyperlink_timeout_ms": 1 + "path_hyperlink_timeout_ms": 1, }, "code_actions_on_format": {}, // Settings related to running tasks. @@ -1663,7 +1698,7 @@ // * Zed task from history (e.g. one-off task was spawned before) // // Default: true - "prefer_lsp": true + "prefer_lsp": true, }, // An object whose keys are language names, and whose values // are arrays of filenames or extensions of files that should @@ -1678,9 +1713,14 @@ // } // "file_types": { - "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"], + "JSONC": [ + "**/.zed/*.json", + "**/.vscode/**/*.json", + "**/{zed,Zed}/{settings,keymap,tasks,debug}.json", + "tsconfig*.json", + ], "Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"], - "Shell Script": [".env.*"] + "Shell Script": [".env.*"], }, // Settings for which version of Node.js and NPM to use when installing // language servers and Copilot. @@ -1696,14 +1736,14 @@ // `path`, but not `npm_path`, Zed will assume that `npm` is located at // `${path}/../npm`. "path": null, - "npm_path": null + "npm_path": null, }, // The extensions that Zed should automatically install on startup. // // If you don't want any of these extensions, add this field to your settings // and change the value to `false`. "auto_install_extensions": { - "html": true + "html": true, }, // The capabilities granted to extensions. // @@ -1711,7 +1751,7 @@ "granted_extension_capabilities": [ { "kind": "process:exec", "command": "*", "args": ["**"] }, { "kind": "download_file", "host": "*", "path": ["**"] }, - { "kind": "npm:install", "package": "*" } + { "kind": "npm:install", "package": "*" }, ], // Controls how completions are processed for this language. "completions": { @@ -1762,7 +1802,7 @@ // 4. "replace_suffix" // Behaves like `"replace"` if the text after the cursor is a suffix of the completion, and like // `"insert"` otherwise. - "lsp_insert_mode": "replace_suffix" + "lsp_insert_mode": "replace_suffix", }, // Different settings for specific languages. "languages": { @@ -1770,113 +1810,116 @@ "language_servers": ["astro-language-server", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-astro"] - } + "plugins": ["prettier-plugin-astro"], + }, }, "Blade": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "C": { "format_on_save": "off", "use_on_type_format": false, "prettier": { - "allowed": false - } + "allowed": false, + }, }, "C++": { "format_on_save": "off", "use_on_type_format": false, "prettier": { - "allowed": false - } + "allowed": false, + }, + }, + "CSharp": { + "language_servers": ["roslyn", "!omnisharp", "..."], }, "CSS": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Dart": { - "tab_size": 2 + "tab_size": 2, }, "Diff": { "show_edit_predictions": false, "remove_trailing_whitespace_on_save": false, - "ensure_final_newline_on_save": false + "ensure_final_newline_on_save": false, }, "Elixir": { - "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "Elm": { - "tab_size": 4 + "tab_size": 4, }, "Erlang": { - "language_servers": ["erlang-ls", "!elp", "..."] + "language_servers": ["erlang-ls", "!elp", "..."], }, "Git Commit": { "allow_rewrap": "anywhere", "soft_wrap": "editor_width", - "preferred_line_length": 72 + "preferred_line_length": 72, }, "Go": { "hard_tabs": true, "code_actions_on_format": { - "source.organizeImports": true + "source.organizeImports": true, }, - "debuggers": ["Delve"] + "debuggers": ["Delve"], }, "GraphQL": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "HEEX": { - "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "HTML": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "HTML+ERB": { - "language_servers": ["herb", "!ruby-lsp", "..."] + "language_servers": ["herb", "!ruby-lsp", "..."], }, "Java": { "prettier": { "allowed": true, - "plugins": ["prettier-plugin-java"] - } + "plugins": ["prettier-plugin-java"], + }, }, "JavaScript": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JSON": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JSONC": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JS+ERB": { - "language_servers": ["!ruby-lsp", "..."] + "language_servers": ["!ruby-lsp", "..."], }, "Kotlin": { - "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."] + "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."], }, "LaTeX": { "formatter": "language_server", "language_servers": ["texlab", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-latex"] - } + "plugins": ["prettier-plugin-latex"], + }, }, "Markdown": { "format_on_save": "off", @@ -1884,136 +1927,145 @@ "remove_trailing_whitespace_on_save": false, "allow_rewrap": "anywhere", "soft_wrap": "editor_width", + "completions": { + "words": "disabled", + }, "prettier": { - "allowed": true - } + "allowed": true, + }, }, "PHP": { - "language_servers": ["phpactor", "!intelephense", "..."], + "language_servers": ["phpactor", "!intelephense", "!phptools", "..."], "prettier": { "allowed": true, "plugins": ["@prettier/plugin-php"], - "parser": "php" - } + "parser": "php", + }, }, "Plain Text": { "allow_rewrap": "anywhere", - "soft_wrap": "editor_width" + "soft_wrap": "editor_width", + "completions": { + "words": "disabled", + }, + }, + "Proto": { + "language_servers": ["buf", "!protols", "!protobuf-language-server", "..."], }, "Python": { "code_actions_on_format": { - "source.organizeImports.ruff": true + "source.organizeImports.ruff": true, }, "formatter": { "language_server": { - "name": "ruff" - } + "name": "ruff", + }, }, "debuggers": ["Debugpy"], - "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."] + "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."], }, "Ruby": { - "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."] + "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."], }, "Rust": { - "debuggers": ["CodeLLDB"] + "debuggers": ["CodeLLDB"], }, "SCSS": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Starlark": { - "language_servers": ["starpls", "!buck2-lsp", "..."] + "language_servers": ["starpls", "!buck2-lsp", "..."], }, "Svelte": { "language_servers": ["svelte-language-server", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-svelte"] - } + "plugins": ["prettier-plugin-svelte"], + }, }, "TSX": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Twig": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "TypeScript": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "SystemVerilog": { "format_on_save": "off", "language_servers": ["!slang", "..."], - "use_on_type_format": false + "use_on_type_format": false, }, "Vue.js": { "language_servers": ["vue-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "XML": { "prettier": { "allowed": true, - "plugins": ["@prettier/plugin-xml"] - } + "plugins": ["@prettier/plugin-xml"], + }, }, "YAML": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "YAML+ERB": { - "language_servers": ["!ruby-lsp", "..."] + "language_servers": ["!ruby-lsp", "..."], }, "Zig": { - "language_servers": ["zls", "..."] - } + "language_servers": ["zls", "..."], + }, }, // Different settings for specific language models. "language_models": { "anthropic": { - "api_url": "https://api.anthropic.com" + "api_url": "https://api.anthropic.com", }, "bedrock": {}, "google": { - "api_url": "https://generativelanguage.googleapis.com" + "api_url": "https://generativelanguage.googleapis.com", }, "ollama": { - "api_url": "http://localhost:11434" + "api_url": "http://localhost:11434", }, "openai": { - "api_url": "https://api.openai.com/v1" + "api_url": "https://api.openai.com/v1", }, "openai_compatible": {}, "open_router": { - "api_url": "https://openrouter.ai/api/v1" + "api_url": "https://openrouter.ai/api/v1", }, "lmstudio": { - "api_url": "http://localhost:1234/api/v0" + "api_url": "http://localhost:1234/api/v0", }, "deepseek": { - "api_url": "https://api.deepseek.com/v1" + "api_url": "https://api.deepseek.com/v1", }, "mistral": { - "api_url": "https://api.mistral.ai/v1" + "api_url": "https://api.mistral.ai/v1", }, "vercel": { - "api_url": "https://api.v0.dev/v1" + "api_url": "https://api.v0.dev/v1", }, "x_ai": { - "api_url": "https://api.x.ai/v1" + "api_url": "https://api.x.ai/v1", }, - "zed.dev": {} + "zed.dev": {}, }, "session": { // Whether or not to restore unsaved buffers on restart. @@ -2022,7 +2074,13 @@ // dirty files when closing the application. // // Default: true - "restore_unsaved_buffers": true + "restore_unsaved_buffers": true, + // Whether or not to skip worktree trust checks. + // When trusted, project settings are synchronized automatically, + // language and MCP servers are downloaded and started automatically. + // + // Default: false + "trust_all_worktrees": false, }, // Zed's Prettier integration settings. // Allows to enable/disable formatting with Prettier @@ -2040,11 +2098,11 @@ // "singleQuote": true // Forces Prettier integration to use a specific parser name when formatting files with the language // when set to a non-empty string. - "parser": "" + "parser": "", }, // Settings for auto-closing of JSX tags. "jsx_tag_auto_close": { - "enabled": true + "enabled": true, }, // LSP Specific settings. "lsp": { @@ -2065,19 +2123,19 @@ // Specify the DAP name as a key here. "CodeLLDB": { "env": { - "RUST_LOG": "info" - } - } + "RUST_LOG": "info", + }, + }, }, // Common language server settings. "global_lsp_settings": { // Whether to show the LSP servers button in the status bar. - "button": true + "button": true, }, // Jupyter settings "jupyter": { "enabled": true, - "kernel_selections": {} + "kernel_selections": {}, // Specify the language name as the key and the kernel name as the value. // "kernel_selections": { // "python": "conda-base" @@ -2091,7 +2149,7 @@ "max_columns": 128, // Maximum number of lines to keep in REPL's scrollback buffer. // Clamped with [4, 256] range. - "max_lines": 32 + "max_lines": 32, }, // Vim settings "vim": { @@ -2105,7 +2163,14 @@ // Specify the mode as the key and the shape as the value. // The mode can be one of the following: "normal", "replace", "insert", "visual". // The shape can be one of the following: "block", "bar", "underline", "hollow". - "cursor_shape": {} + "cursor_shape": {}, + }, + // Which-key popup settings + "which_key": { + // Whether to show the which-key popup when holding down key combinations. + "enabled": false, + // Delay in milliseconds before showing the which-key popup. + "delay_ms": 1000, }, // The server to connect to. If the environment variable // ZED_SERVER_URL is set, it will override this setting. @@ -2138,9 +2203,9 @@ "windows": { "languages": { "PHP": { - "language_servers": ["intelephense", "!phpactor", "..."] - } - } + "language_servers": ["intelephense", "!phpactor", "!phptools", "..."], + }, + }, }, // Whether to show full labels in line indicator or short ones // @@ -2199,7 +2264,7 @@ "dock": "bottom", "log_dap_communications": true, "format_dap_log_messages": true, - "button": true + "button": true, }, // Configures any number of settings profiles that are temporarily applied on // top of your existing user settings when selected from @@ -2226,5 +2291,5 @@ // Useful for filtering out noisy logs or enabling more verbose logging. // // Example: {"log": {"client": "warn"}} - "log": {} + "log": {}, } diff --git a/assets/settings/initial_debug_tasks.json b/assets/settings/initial_debug_tasks.json index af4512bd51aa82d57ce62e605b45ee61e8f98030..851289392a65aecfca17e00d4c123823ac9e21cb 100644 --- a/assets/settings/initial_debug_tasks.json +++ b/assets/settings/initial_debug_tasks.json @@ -8,7 +8,7 @@ "adapter": "Debugpy", "program": "$ZED_FILE", "request": "launch", - "cwd": "$ZED_WORKTREE_ROOT" + "cwd": "$ZED_WORKTREE_ROOT", }, { "label": "Debug active JavaScript file", @@ -16,7 +16,7 @@ "program": "$ZED_FILE", "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", - "type": "pwa-node" + "type": "pwa-node", }, { "label": "JavaScript debug terminal", @@ -24,6 +24,6 @@ "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", "console": "integratedTerminal", - "type": "pwa-node" - } + "type": "pwa-node", + }, ] diff --git a/assets/settings/initial_server_settings.json b/assets/settings/initial_server_settings.json index d6ec33e60128380378610a273a1bbdff1ecdbaa8..29aa569b105157df7ec48164e2066fdac72c7b41 100644 --- a/assets/settings/initial_server_settings.json +++ b/assets/settings/initial_server_settings.json @@ -3,5 +3,5 @@ // For a full list of overridable settings, and general information on settings, // see the documentation: https://zed.dev/docs/configuring-zed#settings-files { - "lsp": {} + "lsp": {}, } diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a79e98063237ca297a89b0d151bd48149061b7bb..5bedafbd3a1e75a755598e37cd673742e146fdcc 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -47,8 +47,8 @@ // Whether to show the task line in the output of the spawned task, defaults to `true`. "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. - "show_command": true + "show_command": true, // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] - } + }, ] diff --git a/assets/settings/initial_user_settings.json b/assets/settings/initial_user_settings.json index 5ac2063bdb481e057a2d124c1e72f998390b066b..8b573854895a03243803a71a91a35af647f45ca2 100644 --- a/assets/settings/initial_user_settings.json +++ b/assets/settings/initial_user_settings.json @@ -12,6 +12,6 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" - } + "dark": "One Dark", + }, } diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index 7c84c603bda7fd7590067ec9f566f3582ba6aefd..e2b7c3c91fca46ab0e4064719bea5c8793faaccc 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -45,6 +45,7 @@ "tab.inactive_background": "#1f2127ff", "tab.active_background": "#0d1016ff", "search.match_background": "#5ac2fe66", + "search.active_match_background": "#ea570166", "panel.background": "#1f2127ff", "panel.focused_border": "#5ac1feff", "pane.focused_border": null, @@ -436,6 +437,7 @@ "tab.inactive_background": "#ececedff", "tab.active_background": "#fcfcfcff", "search.match_background": "#3b9ee566", + "search.active_match_background": "#f88b3666", "panel.background": "#ececedff", "panel.focused_border": "#3b9ee5ff", "pane.focused_border": null, @@ -827,6 +829,7 @@ "tab.inactive_background": "#353944ff", "tab.active_background": "#242835ff", "search.match_background": "#73cffe66", + "search.active_match_background": "#fd722b66", "panel.background": "#353944ff", "panel.focused_border": null, "pane.focused_border": null, diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index a0f0a3ad637a4d212c8bf38f95f2e8424919d6bf..16ae188712f7a800ab4fb8a81a2d24cac99da56b 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -46,6 +46,7 @@ "tab.inactive_background": "#3a3735ff", "tab.active_background": "#282828ff", "search.match_background": "#83a59866", + "search.active_match_background": "#c09f3f66", "panel.background": "#3a3735ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, @@ -70,33 +71,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#282828ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#282828ff", + "terminal.dim_foreground": "#766b5dff", "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -452,6 +453,7 @@ "tab.inactive_background": "#393634ff", "tab.active_background": "#1d2021ff", "search.match_background": "#83a59866", + "search.active_match_background": "#c9653666", "panel.background": "#393634ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, @@ -476,33 +478,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#1d2021ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#1d2021ff", - "terminal.ansi.black": "#1d2021ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -858,6 +860,7 @@ "tab.inactive_background": "#3b3735ff", "tab.active_background": "#32302fff", "search.match_background": "#83a59866", + "search.active_match_background": "#aea85166", "panel.background": "#3b3735ff", "panel.focused_border": null, "pane.focused_border": null, @@ -882,33 +885,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#32302fff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#32302fff", - "terminal.ansi.black": "#32302fff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -1264,6 +1267,7 @@ "tab.inactive_background": "#ecddb4ff", "tab.active_background": "#fbf1c7ff", "search.match_background": "#0b667866", + "search.active_match_background": "#ba2d1166", "panel.background": "#ecddb4ff", "panel.focused_border": null, "pane.focused_border": null, @@ -1291,30 +1295,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#fbf1c7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#0b6678ff", - "terminal.ansi.dim_black": "#5f5650ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -1670,6 +1674,7 @@ "tab.inactive_background": "#ecddb5ff", "tab.active_background": "#f9f5d7ff", "search.match_background": "#0b667866", + "search.active_match_background": "#dc351466", "panel.background": "#ecddb5ff", "panel.focused_border": null, "pane.focused_border": null, @@ -1697,30 +1702,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f9f5d7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f9f5d7ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f9f5d7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -2076,6 +2081,7 @@ "tab.inactive_background": "#ecdcb3ff", "tab.active_background": "#f2e5bcff", "search.match_background": "#0b667866", + "search.active_match_background": "#d7331466", "panel.background": "#ecdcb3ff", "panel.focused_border": null, "pane.focused_border": null, @@ -2103,30 +2109,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f2e5bcff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f2e5bcff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f2e5bcff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 6849cd05dc70752216789ae04e81fad232f7b14b..13f94991ad44fc997144a3d44527dcbce5231504 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -45,6 +45,7 @@ "tab.inactive_background": "#2f343eff", "tab.active_background": "#282c33ff", "search.match_background": "#74ade866", + "search.active_match_background": "#e8af7466", "panel.background": "#2f343eff", "panel.focused_border": null, "pane.focused_border": null, @@ -67,37 +68,39 @@ "editor.active_wrap_guide": "#c8ccd41a", "editor.document_highlight.read_background": "#74ade81a", "editor.document_highlight.write_background": "#555a6366", - "terminal.background": "#282c33ff", - "terminal.foreground": "#dce0e5ff", + "terminal.background": "#282c34ff", + "terminal.foreground": "#abb2bfff", "terminal.bright_foreground": "#dce0e5ff", - "terminal.dim_foreground": "#282c33ff", - "terminal.ansi.black": "#282c33ff", - "terminal.ansi.bright_black": "#525561ff", - "terminal.ansi.dim_black": "#dce0e5ff", - "terminal.ansi.red": "#d07277ff", - "terminal.ansi.bright_red": "#673a3cff", - "terminal.ansi.dim_red": "#eab7b9ff", - "terminal.ansi.green": "#a1c181ff", - "terminal.ansi.bright_green": "#4d6140ff", - "terminal.ansi.dim_green": "#d1e0bfff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#e5c07bff", - "terminal.ansi.dim_yellow": "#f1dfc1ff", - "terminal.ansi.blue": "#74ade8ff", - "terminal.ansi.bright_blue": "#385378ff", - "terminal.ansi.dim_blue": "#bed5f4ff", - "terminal.ansi.magenta": "#b477cfff", - "terminal.ansi.bright_magenta": "#d6b4e4ff", - "terminal.ansi.dim_magenta": "#612a79ff", - "terminal.ansi.cyan": "#6eb4bfff", - "terminal.ansi.bright_cyan": "#3a565bff", - "terminal.ansi.dim_cyan": "#b9d9dfff", - "terminal.ansi.white": "#dce0e5ff", + "terminal.dim_foreground": "#636d83ff", + "terminal.ansi.black": "#282c34ff", + "terminal.ansi.bright_black": "#636d83ff", + "terminal.ansi.dim_black": "#3b3f4aff", + "terminal.ansi.red": "#e06c75ff", + "terminal.ansi.bright_red": "#EA858Bff", + "terminal.ansi.dim_red": "#a7545aff", + "terminal.ansi.green": "#98c379ff", + "terminal.ansi.bright_green": "#AAD581ff", + "terminal.ansi.dim_green": "#6d8f59ff", + "terminal.ansi.yellow": "#e5c07bff", + "terminal.ansi.bright_yellow": "#FFD885ff", + "terminal.ansi.dim_yellow": "#b8985bff", + "terminal.ansi.blue": "#61afefff", + "terminal.ansi.bright_blue": "#85C1FFff", + "terminal.ansi.dim_blue": "#457cadff", + "terminal.ansi.magenta": "#c678ddff", + "terminal.ansi.bright_magenta": "#D398EBff", + "terminal.ansi.dim_magenta": "#8d54a0ff", + "terminal.ansi.cyan": "#56b6c2ff", + "terminal.ansi.bright_cyan": "#6ED5DEff", + "terminal.ansi.dim_cyan": "#3c818aff", + "terminal.ansi.white": "#abb2bfff", "terminal.ansi.bright_white": "#fafafaff", - "terminal.ansi.dim_white": "#575d65ff", + "terminal.ansi.dim_white": "#8f969bff", "link_text.hover": "#74ade8ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", + "version_control.word_added": "#2EA04859", + "version_control.word_deleted": "#78081BCC", "version_control.deleted": "#e06c76ff", "version_control.conflict_marker.ours": "#a1c1811a", "version_control.conflict_marker.theirs": "#74ade81a", @@ -446,6 +449,7 @@ "tab.inactive_background": "#ebebecff", "tab.active_background": "#fafafaff", "search.match_background": "#5c79e266", + "search.active_match_background": "#d0a92366", "panel.background": "#ebebecff", "panel.focused_border": null, "pane.focused_border": null, @@ -469,36 +473,38 @@ "editor.document_highlight.read_background": "#5c78e225", "editor.document_highlight.write_background": "#a3a3a466", "terminal.background": "#fafafaff", - "terminal.foreground": "#242529ff", - "terminal.bright_foreground": "#242529ff", - "terminal.dim_foreground": "#fafafaff", - "terminal.ansi.black": "#242529ff", - "terminal.ansi.bright_black": "#747579ff", - "terminal.ansi.dim_black": "#97979aff", - "terminal.ansi.red": "#d36151ff", - "terminal.ansi.bright_red": "#f0b0a4ff", - "terminal.ansi.dim_red": "#6f312aff", - "terminal.ansi.green": "#669f59ff", - "terminal.ansi.bright_green": "#b2cfa9ff", - "terminal.ansi.dim_green": "#354d2eff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#826221ff", - "terminal.ansi.dim_yellow": "#786441ff", - "terminal.ansi.blue": "#5c78e2ff", - "terminal.ansi.bright_blue": "#b5baf2ff", - "terminal.ansi.dim_blue": "#2d3d75ff", - "terminal.ansi.magenta": "#984ea5ff", - "terminal.ansi.bright_magenta": "#cea6d3ff", - "terminal.ansi.dim_magenta": "#4b2a50ff", - "terminal.ansi.cyan": "#3a82b7ff", - "terminal.ansi.bright_cyan": "#a3bedaff", - "terminal.ansi.dim_cyan": "#254058ff", - "terminal.ansi.white": "#fafafaff", + "terminal.foreground": "#2a2c33ff", + "terminal.bright_foreground": "#2a2c33ff", + "terminal.dim_foreground": "#bbbbbbff", + "terminal.ansi.black": "#000000ff", + "terminal.ansi.bright_black": "#000000ff", + "terminal.ansi.dim_black": "#555555ff", + "terminal.ansi.red": "#de3e35ff", + "terminal.ansi.bright_red": "#de3e35ff", + "terminal.ansi.dim_red": "#9c2b26ff", + "terminal.ansi.green": "#3f953aff", + "terminal.ansi.bright_green": "#3f953aff", + "terminal.ansi.dim_green": "#2b6927ff", + "terminal.ansi.yellow": "#d2b67cff", + "terminal.ansi.bright_yellow": "#d2b67cff", + "terminal.ansi.dim_yellow": "#a48c5aff", + "terminal.ansi.blue": "#2f5af3ff", + "terminal.ansi.bright_blue": "#2f5af3ff", + "terminal.ansi.dim_blue": "#2140abff", + "terminal.ansi.magenta": "#950095ff", + "terminal.ansi.bright_magenta": "#a00095ff", + "terminal.ansi.dim_magenta": "#6a006aff", + "terminal.ansi.cyan": "#3f953aff", + "terminal.ansi.bright_cyan": "#3f953aff", + "terminal.ansi.dim_cyan": "#2b6927ff", + "terminal.ansi.white": "#bbbbbbff", "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#aaaaaaff", + "terminal.ansi.dim_white": "#888888ff", "link_text.hover": "#5c78e2ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", + "version_control.word_added": "#2EA04859", + "version_control.word_deleted": "#F85149CC", "version_control.deleted": "#e06c76ff", "conflict": "#a48819ff", "conflict.background": "#faf2e6ff", diff --git a/clippy.toml b/clippy.toml index 0ce7a6cd68d4e8210788eb7a67aa06c742cc8274..9dd246074a06c4db7b66eff7a83ef68e3612c378 100644 --- a/clippy.toml +++ b/clippy.toml @@ -14,6 +14,7 @@ disallowed-methods = [ { path = "std::process::Command::stderr", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stderr" }, { path = "serde_json::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892. Use `serde_json::from_slice` instead." }, { path = "serde_json_lenient::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892, Use `serde_json_lenient::from_slice` instead." }, + { path = "cocoa::foundation::NSString::alloc", reason = "NSString must be autoreleased to avoid memory leaks. Use `ns_string()` helper instead." }, ] disallowed-types = [ # { path = "std::collections::HashMap", replacement = "collections::HashMap" }, diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 8ef6f1a52c8b207658d59a1e6b877964df9e42ce..70f2e4d259f1611fb42ebc0b064d278c8b3b9c4d 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -46,6 +46,7 @@ url.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true +urlencoding.workspace = true [dev-dependencies] env_logger.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 56e33fda47f095eef1873f7a0724b021e88a0bdc..a994cc8e57e4456ec57092b2257269b104af74c7 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -43,6 +43,7 @@ pub struct UserMessage { pub content: ContentBlock, pub chunks: Vec, pub checkpoint: Option, + pub indented: bool, } #[derive(Debug)] @@ -73,6 +74,7 @@ impl UserMessage { #[derive(Debug, PartialEq)] pub struct AssistantMessage { pub chunks: Vec, + pub indented: bool, } impl AssistantMessage { @@ -123,6 +125,14 @@ pub enum AgentThreadEntry { } impl AgentThreadEntry { + pub fn is_indented(&self) -> bool { + match self { + Self::UserMessage(message) => message.indented, + Self::AssistantMessage(message) => message.indented, + Self::ToolCall(_) => false, + } + } + pub fn to_markdown(&self, cx: &App) -> String { match self { Self::UserMessage(message) => message.to_markdown(cx), @@ -182,6 +192,7 @@ pub struct ToolCall { pub locations: Vec, pub resolved_locations: Vec>, pub raw_input: Option, + pub raw_input_markdown: Option>, pub raw_output: Option, } @@ -201,17 +212,24 @@ impl ToolCall { }; let mut content = Vec::with_capacity(tool_call.content.len()); for item in tool_call.content { - content.push(ToolCallContent::from_acp( + if let Some(item) = ToolCallContent::from_acp( item, language_registry.clone(), path_style, terminals, cx, - )?); + )? { + content.push(item); + } } + let raw_input_markdown = tool_call + .raw_input + .as_ref() + .and_then(|input| markdown_for_raw_output(input, &language_registry, cx)); + let result = Self { - id: tool_call.id, + id: tool_call.tool_call_id, label: cx .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)), kind: tool_call.kind, @@ -220,6 +238,7 @@ impl ToolCall { resolved_locations: Vec::default(), status, raw_input: tool_call.raw_input, + raw_input_markdown, raw_output: tool_call.raw_output, }; Ok(result) @@ -241,6 +260,7 @@ impl ToolCall { locations, raw_input, raw_output, + .. } = fields; if let Some(kind) = kind { @@ -262,21 +282,29 @@ impl ToolCall { } if let Some(content) = content { - let new_content_len = content.len(); + let mut new_content_len = content.len(); let mut content = content.into_iter(); // Reuse existing content if we can for (old, new) in self.content.iter_mut().zip(content.by_ref()) { - old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + let valid_content = + old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + if !valid_content { + new_content_len -= 1; + } } for new in content { - self.content.push(ToolCallContent::from_acp( + if let Some(new) = ToolCallContent::from_acp( new, language_registry.clone(), path_style, terminals, cx, - )?) + )? { + self.content.push(new); + } else { + new_content_len -= 1; + } } self.content.truncate(new_content_len); } @@ -286,6 +314,7 @@ impl ToolCall { } if let Some(raw_input) = raw_input { + self.raw_input_markdown = markdown_for_raw_output(&raw_input, &language_registry, cx); self.raw_input = Some(raw_input); } @@ -347,13 +376,13 @@ impl ToolCall { let buffer = buffer.await.log_err()?; let position = buffer .update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); if let Some(row) = location.line { - let snapshot = buffer.snapshot(); let column = snapshot.indent_size_for_line(row).len; let point = snapshot.clip_point(Point::new(row, column), Bias::Left); snapshot.anchor_before(point) } else { - Anchor::MIN + Anchor::min_for_buffer(snapshot.remote_id()) } }) .ok()?; @@ -425,6 +454,7 @@ impl From for ToolCallStatus { acp::ToolCallStatus::InProgress => Self::InProgress, acp::ToolCallStatus::Completed => Self::Completed, acp::ToolCallStatus::Failed => Self::Failed, + _ => Self::Pending, } } } @@ -537,7 +567,7 @@ impl ContentBlock { .. }) => Self::resource_link_md(&uri, path_style), acp::ContentBlock::Image(image) => Self::image_md(&image), - acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(), + _ => String::new(), } } @@ -591,15 +621,17 @@ impl ToolCallContent { path_style: PathStyle, terminals: &HashMap>, cx: &mut App, - ) -> Result { + ) -> Result> { match content { - acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new( - content, - &language_registry, - path_style, - cx, - ))), - acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| { + acp::ToolCallContent::Content(acp::Content { content, .. }) => { + Ok(Some(Self::ContentBlock(ContentBlock::new( + content, + &language_registry, + path_style, + cx, + )))) + } + acp::ToolCallContent::Diff(diff) => Ok(Some(Self::Diff(cx.new(|cx| { Diff::finalized( diff.path.to_string_lossy().into_owned(), diff.old_text, @@ -607,12 +639,13 @@ impl ToolCallContent { language_registry, cx, ) - }))), - acp::ToolCallContent::Terminal { terminal_id } => terminals + })))), + acp::ToolCallContent::Terminal(acp::Terminal { terminal_id, .. }) => terminals .get(&terminal_id) .cloned() - .map(Self::Terminal) + .map(|terminal| Some(Self::Terminal(terminal))) .ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)), + _ => Ok(None), } } @@ -623,9 +656,9 @@ impl ToolCallContent { path_style: PathStyle, terminals: &HashMap>, cx: &mut App, - ) -> Result<()> { + ) -> Result { let needs_update = match (&self, &new) { - (Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => { + (Self::Diff(old_diff), acp::ToolCallContent::Diff(new_diff)) => { old_diff.read(cx).needs_update( new_diff.old_text.as_deref().unwrap_or(""), &new_diff.new_text, @@ -635,10 +668,14 @@ impl ToolCallContent { _ => true, }; - if needs_update { - *self = Self::from_acp(new, language_registry, path_style, terminals, cx)?; + if let Some(update) = Self::from_acp(new, language_registry, path_style, terminals, cx)? { + if needs_update { + *self = update; + } + Ok(true) + } else { + Ok(false) } - Ok(()) } pub fn to_markdown(&self, cx: &App) -> String { @@ -660,7 +697,7 @@ pub enum ToolCallUpdate { impl ToolCallUpdate { fn id(&self) -> &acp::ToolCallId { match self { - Self::UpdateFields(update) => &update.id, + Self::UpdateFields(update) => &update.tool_call_id, Self::UpdateDiff(diff) => &diff.id, Self::UpdateTerminal(terminal) => &terminal.id, } @@ -732,6 +769,7 @@ impl Plan { acp::PlanEntryStatus::Completed => { stats.completed += 1; } + _ => {} } } @@ -1154,6 +1192,7 @@ impl AcpThread { current_mode_id, .. }) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)), + _ => {} } Ok(()) } @@ -1163,6 +1202,16 @@ impl AcpThread { message_id: Option, chunk: acp::ContentBlock, cx: &mut Context, + ) { + self.push_user_content_block_with_indent(message_id, chunk, false, cx) + } + + pub fn push_user_content_block_with_indent( + &mut self, + message_id: Option, + chunk: acp::ContentBlock, + indented: bool, + cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); @@ -1173,8 +1222,10 @@ impl AcpThread { id, content, chunks, + indented: existing_indented, .. }) = last_entry + && *existing_indented == indented { *id = message_id.or(id.take()); content.append(chunk.clone(), &language_registry, path_style, cx); @@ -1189,6 +1240,7 @@ impl AcpThread { content, chunks: vec![chunk], checkpoint: None, + indented, }), cx, ); @@ -1200,12 +1252,26 @@ impl AcpThread { chunk: acp::ContentBlock, is_thought: bool, cx: &mut Context, + ) { + self.push_assistant_content_block_with_indent(chunk, is_thought, false, cx) + } + + pub fn push_assistant_content_block_with_indent( + &mut self, + chunk: acp::ContentBlock, + is_thought: bool, + indented: bool, + cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() - && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry + && let AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks, + indented: existing_indented, + }) = last_entry + && *existing_indented == indented { let idx = entries_len - 1; cx.emit(AcpThreadEvent::EntryUpdated(idx)); @@ -1234,6 +1300,7 @@ impl AcpThread { self.push_entry( AgentThreadEntry::AssistantMessage(AssistantMessage { chunks: vec![chunk], + indented, }), cx, ); @@ -1287,11 +1354,7 @@ impl AcpThread { label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)), kind: acp::ToolKind::Fetch, content: vec![ToolCallContent::ContentBlock(ContentBlock::new( - acp::ContentBlock::Text(acp::TextContent { - text: "Tool call not found".to_string(), - annotations: None, - meta: None, - }), + "Tool call not found".into(), &languages, path_style, cx, @@ -1300,6 +1363,7 @@ impl AcpThread { locations: Vec::new(), resolved_locations: Vec::new(), raw_input: None, + raw_input_markdown: None, raw_output: None, }; self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx); @@ -1315,7 +1379,7 @@ impl AcpThread { let location_updated = update.fields.locations.is_some(); call.update_fields(update.fields, languages, path_style, &self.terminals, cx)?; if location_updated { - self.resolve_locations(update.id, cx); + self.resolve_locations(update.tool_call_id, cx); } } ToolCallUpdate::UpdateDiff(update) => { @@ -1353,9 +1417,9 @@ impl AcpThread { ) -> Result<(), acp::Error> { let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); - let id = update.id.clone(); + let id = update.tool_call_id.clone(); - let agent = self.connection().telemetry_id(); + let agent_telemetry_id = self.connection().telemetry_id(); let session = self.session_id(); if let ToolCallStatus::Completed | ToolCallStatus::Failed = status { let status = if matches!(status, ToolCallStatus::Completed) { @@ -1363,7 +1427,12 @@ impl AcpThread { } else { "failed" }; - telemetry::event!("Agent Tool Call Completed", agent, session, status); + telemetry::event!( + "Agent Tool Call Completed", + agent_telemetry_id, + session, + status + ); } if let Some(ix) = self.index_for_tool_call(&id) { @@ -1518,16 +1587,16 @@ impl AcpThread { // some tools would (incorrectly) continue to auto-accept. if let Some(allow_once_option) = options.iter().find_map(|option| { if matches!(option.kind, acp::PermissionOptionKind::AllowOnce) { - Some(option.id.clone()) + Some(option.option_id.clone()) } else { None } }) { self.upsert_tool_call_inner(tool_call, ToolCallStatus::Pending, cx)?; return Ok(async { - acp::RequestPermissionOutcome::Selected { - option_id: allow_once_option, - } + acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( + allow_once_option, + )) } .boxed()); } @@ -1543,7 +1612,9 @@ impl AcpThread { let fut = async { match rx.await { - Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, + Ok(option) => acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(option), + ), Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, } } @@ -1570,6 +1641,7 @@ impl AcpThread { acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => { ToolCallStatus::InProgress } + _ => ToolCallStatus::InProgress, }; let curr_status = mem::replace(&mut call.status, new_status); @@ -1648,14 +1720,7 @@ impl AcpThread { message: &str, cx: &mut Context, ) -> BoxFuture<'static, Result<()>> { - self.send( - vec![acp::ContentBlock::Text(acp::TextContent { - text: message.to_string(), - annotations: None, - meta: None, - })], - cx, - ) + self.send(vec![message.into()], cx) } pub fn send( @@ -1669,11 +1734,7 @@ impl AcpThread { self.project.read(cx).path_style(cx), cx, ); - let request = acp::PromptRequest { - prompt: message.clone(), - session_id: self.session_id.clone(), - meta: None, - }; + let request = acp::PromptRequest::new(self.session_id.clone(), message.clone()); let git_store = self.project.read(cx).git_store().clone(); let message_id = if self.connection.truncate(&self.session_id, cx).is_some() { @@ -1690,6 +1751,7 @@ impl AcpThread { content: block, chunks: message, checkpoint: None, + indented: false, }), cx, ); @@ -1765,7 +1827,7 @@ impl AcpThread { result, Ok(Ok(acp::PromptResponse { stop_reason: acp::StopReason::Cancelled, - meta: None, + .. })) ); @@ -1781,7 +1843,7 @@ impl AcpThread { // Handle refusal - distinguish between user prompt and tool call refusals if let Ok(Ok(acp::PromptResponse { stop_reason: acp::StopReason::Refusal, - meta: _, + .. })) = result { if let Some((user_msg_ix, _)) = this.last_user_message() { @@ -2017,7 +2079,7 @@ impl AcpThread { })?; Ok(project.open_buffer(path, cx)) }) - .map_err(|e| acp::Error::internal_error().with_data(e.to_string())) + .map_err(|e| acp::Error::internal_error().data(e.to_string())) .flatten()?; let buffer = load.await?; @@ -2050,7 +2112,7 @@ impl AcpThread { let start_position = Point::new(line, 0); if start_position > max_point { - return Err(acp::Error::invalid_params().with_data(format!( + return Err(acp::Error::invalid_params().data(format!( "Attempting to read beyond the end of the file, line {}:{}", max_point.row + 1, max_point.column @@ -2120,7 +2182,7 @@ impl AcpThread { position: edits .last() .map(|(range, _)| range.end) - .unwrap_or(Anchor::MIN), + .unwrap_or(Anchor::min_for_buffer(buffer.read(cx).remote_id())), }), cx, ); @@ -2202,7 +2264,7 @@ impl AcpThread { let language_registry = project.read(cx).languages().clone(); let is_windows = project.read(cx).path_style(cx).is_windows(); - let terminal_id = acp::TerminalId(Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(Uuid::new_v4().to_string()); let terminal_task = cx.spawn({ let terminal_id = terminal_id.clone(); async move |_this, cx| { @@ -2412,7 +2474,7 @@ mod tests { .await .unwrap(); - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); // Send Output BEFORE Created - should be buffered by acp_thread thread.update(cx, |thread, cx| { @@ -2474,7 +2536,7 @@ mod tests { .await .unwrap(); - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); // Send Output BEFORE Created thread.update(cx, |thread, cx| { @@ -2492,11 +2554,7 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); @@ -2553,15 +2611,7 @@ mod tests { // Test creating a new user message thread.update(cx, |thread, cx| { - thread.push_user_content_block( - None, - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "Hello, ".to_string(), - meta: None, - }), - cx, - ); + thread.push_user_content_block(None, "Hello, ".into(), cx); }); thread.update(cx, |thread, cx| { @@ -2577,15 +2627,7 @@ mod tests { // Test appending to existing user message let message_1_id = UserMessageId::new(); thread.update(cx, |thread, cx| { - thread.push_user_content_block( - Some(message_1_id.clone()), - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "world!".to_string(), - meta: None, - }), - cx, - ); + thread.push_user_content_block(Some(message_1_id.clone()), "world!".into(), cx); }); thread.update(cx, |thread, cx| { @@ -2600,26 +2642,14 @@ mod tests { // Test creating new user message after assistant message thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "Assistant response".to_string(), - meta: None, - }), - false, - cx, - ); + thread.push_assistant_content_block("Assistant response".into(), false, cx); }); let message_2_id = UserMessageId::new(); thread.update(cx, |thread, cx| { thread.push_user_content_block( Some(message_2_id.clone()), - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "New user message".to_string(), - meta: None, - }), + "New user message".into(), cx, ); }); @@ -2647,27 +2677,22 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { - content: "Thinking ".into(), - meta: None, - }), + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "Thinking ".into(), + )), cx, ) .unwrap(); thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { - content: "hard!".into(), - meta: None, - }), + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "hard!".into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() }, @@ -2735,10 +2760,7 @@ mod tests { .unwrap() .await .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() }, @@ -2960,7 +2982,7 @@ mod tests { .await .unwrap_err(); - assert_eq!(err.code, acp::ErrorCode::RESOURCE_NOT_FOUND.code); + assert_eq!(err.code, acp::ErrorCode::ResourceNotFound); } #[gpui::test] @@ -2969,7 +2991,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let id = acp::ToolCallId("test".into()); + let id = acp::ToolCallId::new("test"); let connection = Rc::new(FakeAgentConnection::new().on_user_message({ let id = id.clone(); @@ -2979,26 +3001,17 @@ mod tests { thread .update(&mut cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: id.clone(), - title: "Label".into(), - kind: acp::ToolKind::Fetch, - status: acp::ToolCallStatus::InProgress, - content: vec![], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(id.clone(), "Label") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::InProgress), + ), cx, ) }) .unwrap() .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3040,14 +3053,10 @@ mod tests { thread .update(cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( id, - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - ..Default::default() - }, - meta: None, - }), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), cx, ) }) @@ -3079,33 +3088,21 @@ mod tests { thread .update(&mut cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("test".into()), - title: "Label".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/test/test.txt".into(), - old_text: None, - new_text: "foo".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("test", "Label") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff(acp::Diff::new( + "/test/test.txt", + "foo", + ))]), + ), cx, ) }) .unwrap() .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3158,18 +3155,14 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: content.text.to_uppercase().into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3325,34 +3318,22 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool1".into()), - title: "Test Tool".into(), - kind: acp::ToolKind::Fetch, - status: acp::ToolCallStatus::Completed, - content: vec![], - locations: vec![], - raw_input: Some(serde_json::json!({"query": "test"})), - raw_output: Some( - serde_json::json!({"result": "inappropriate content"}), - ), - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool1", "Test Tool") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::Completed) + .raw_input(serde_json::json!({"query": "test"})) + .raw_output(serde_json::json!({"result": "inappropriate content"})), + ), cx, ) .unwrap(); })?; // Now return refusal because of the tool result - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } else { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } } .boxed_local() @@ -3380,16 +3361,7 @@ mod tests { }); // Send a user message - this will trigger tool call and then refusal - let send_task = thread.update(cx, |thread, cx| { - thread.send( - vec![acp::ContentBlock::Text(acp::TextContent { - text: "Hello".into(), - annotations: None, - meta: None, - })], - cx, - ) - }); + let send_task = thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx)); cx.background_executor.spawn(send_task).detach(); cx.run_until_parked(); @@ -3435,21 +3407,11 @@ mod tests { let refuse_next = refuse_next.clone(); move |_request, _thread, _cx| { if refuse_next.load(SeqCst) { - async move { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }) - } - .boxed_local() + async move { Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } + .boxed_local() } else { - async move { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) - } - .boxed_local() + async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } + .boxed_local() } } })); @@ -3506,10 +3468,7 @@ mod tests { let refuse_next = refuse_next.clone(); async move { if refuse_next.load(SeqCst) { - return Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }); + return Ok(acp::PromptResponse::new(acp::StopReason::Refusal)); } let acp::ContentBlock::Text(content) = &request.prompt[0] else { @@ -3518,18 +3477,14 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: content.text.to_uppercase().into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3654,8 +3609,8 @@ mod tests { } impl AgentConnection for FakeAgentConnection { - fn telemetry_id(&self) -> &'static str { - "fake" + fn telemetry_id(&self) -> SharedString { + "fake".into() } fn auth_methods(&self) -> &[acp::AuthMethod] { @@ -3668,13 +3623,12 @@ mod tests { _cwd: &Path, cx: &mut App, ) -> Task>> { - let session_id = acp::SessionId( + let session_id = acp::SessionId::new( rand::rng() .sample_iter(&distr::Alphanumeric) .take(7) .map(char::from) - .collect::() - .into(), + .collect::(), ); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { @@ -3684,12 +3638,12 @@ mod tests { project, action_log, session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }); @@ -3718,10 +3672,7 @@ mod tests { let thread = thread.clone(); cx.spawn(async move |cx| handler(params, thread, cx.clone()).await) } else { - Task::ready(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - })) + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) } } @@ -3776,17 +3727,13 @@ mod tests { .unwrap(); // Try to update a tool call that doesn't exist - let nonexistent_id = acp::ToolCallId("nonexistent-tool-call".into()); + let nonexistent_id = acp::ToolCallId::new("nonexistent-tool-call"); thread.update(cx, |thread, cx| { let result = thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { - id: nonexistent_id.clone(), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - ..Default::default() - }, - meta: None, - }), + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + nonexistent_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), cx, ); @@ -3861,7 +3808,7 @@ mod tests { .unwrap(); // Create 2 terminals BEFORE the checkpoint that have completed running - let terminal_id_1 = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id_1 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal_1 = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -3900,17 +3847,13 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id_1.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); }); - let terminal_id_2 = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id_2 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal_2 = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -3949,11 +3892,7 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id_2.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); @@ -3973,7 +3912,7 @@ mod tests { // Create a terminal AFTER the checkpoint we'll restore to. // This simulates the AI agent starting a long-running terminal command. - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -4015,21 +3954,15 @@ mod tests { thread.update(cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("terminal-tool-1".into()), - title: "Running command".into(), - kind: acp::ToolKind::Execute, - status: acp::ToolCallStatus::InProgress, - content: vec![acp::ToolCallContent::Terminal { - terminal_id: terminal_id.clone(), - }], - locations: vec![], - raw_input: Some( - serde_json::json!({"command": "sleep 1000", "cd": "/test"}), - ), - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("terminal-tool-1", "Running command") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new( + terminal_id.clone(), + ))]) + .raw_input(serde_json::json!({"command": "sleep 1000", "cd": "/test"})), + ), cx, ) .unwrap(); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 80bec0ee9d351711bdf435cfe63eb99eb1e499e3..598d0428174eb2fc124739a18ddeff1098521cb7 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -20,7 +20,7 @@ impl UserMessageId { } pub trait AgentConnection { - fn telemetry_id(&self) -> &'static str; + fn telemetry_id(&self) -> SharedString; fn new_thread( self: Rc, @@ -202,6 +202,21 @@ pub trait AgentModelSelector: 'static { fn should_render_footer(&self) -> bool { false } + + /// Whether this selector supports the favorites feature. + /// Only the native agent uses the model ID format that maps to settings. + fn supports_favorites(&self) -> bool { + false + } +} + +/// Icon for a model in the model selector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentModelIcon { + /// A built-in icon from Zed's icon set. + Named(IconName), + /// Path to a custom SVG icon file. + Path(SharedString), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -209,7 +224,7 @@ pub struct AgentModelInfo { pub id: acp::ModelId, pub name: SharedString, pub description: Option, - pub icon: Option, + pub icon: Option, } impl From for AgentModelInfo { @@ -239,6 +254,10 @@ impl AgentModelList { AgentModelList::Grouped(groups) => groups.is_empty(), } } + + pub fn is_flat(&self) -> bool { + matches!(self, AgentModelList::Flat(_)) + } } #[cfg(feature = "test-support")] @@ -322,8 +341,8 @@ mod test_support { } impl AgentConnection for StubAgentConnection { - fn telemetry_id(&self) -> &'static str { - "stub" + fn telemetry_id(&self) -> SharedString { + "stub".into() } fn auth_methods(&self) -> &[acp::AuthMethod] { @@ -336,7 +355,7 @@ mod test_support { _cwd: &Path, cx: &mut gpui::App, ) -> Task>> { - let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); + let session_id = acp::SessionId::new(self.sessions.lock().len().to_string()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( @@ -345,12 +364,12 @@ mod test_support { project, action_log, session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }); @@ -389,10 +408,7 @@ mod test_support { response_tx.replace(tx); cx.spawn(async move |_| { let stop_reason = rx.await?; - Ok(acp::PromptResponse { - stop_reason, - meta: None, - }) + Ok(acp::PromptResponse::new(stop_reason)) }) } else { for update in self.next_prompt_updates.lock().drain(..) { @@ -400,7 +416,7 @@ mod test_support { let update = update.clone(); let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update - && let Some(options) = self.permission_requests.get(&tool_call.id) + && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id) { Some((tool_call.clone(), options.clone())) } else { @@ -429,10 +445,7 @@ mod test_support { cx.spawn(async move |_| { try_join_all(tasks).await?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }) } } diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 055b2f7fb86ffe9d7f12459b6b16405ce77815a0..cae1aad90810c217324659d29c065af443494933 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -50,9 +50,14 @@ impl Diff { let hunk_ranges = { let buffer = buffer.read(cx); let diff = diff.read(cx); - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) - .collect::>() + diff.hunks_intersecting_range( + Anchor::min_for_buffer(buffer.remote_id()) + ..Anchor::max_for_buffer(buffer.remote_id()), + buffer, + cx, + ) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) + .collect::>() }; multibuffer.set_excerpts_for_path( @@ -161,7 +166,7 @@ impl Diff { } pub fn has_revealed_range(&self, cx: &App) -> bool { - self.multibuffer().read(cx).excerpt_paths().next().is_some() + self.multibuffer().read(cx).paths().next().is_some() } pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool { @@ -316,7 +321,12 @@ impl PendingDiff { let buffer = self.new_buffer.read(cx); let diff = self.diff.read(cx); let mut ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) + .hunks_intersecting_range( + Anchor::min_for_buffer(buffer.remote_id()) + ..Anchor::max_for_buffer(buffer.remote_id()), + buffer, + cx, + ) .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) .collect::>(); ranges.extend( diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b78eac4903a259a1044892fb2c8233f7e973f025..3e2e53fb7fbdf581b45566bd747cfcbfc1c0a004 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -4,12 +4,14 @@ use file_icons::FileIcons; use prompt_store::{PromptId, UserPromptId}; use serde::{Deserialize, Serialize}; use std::{ + borrow::Cow, fmt, ops::RangeInclusive, path::{Path, PathBuf}, }; use ui::{App, IconName, SharedString}; use url::Url; +use urlencoding::decode; use util::paths::PathStyle; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -74,11 +76,13 @@ impl MentionUri { let path = url.path(); match url.scheme() { "file" => { - let path = if path_style.is_windows() { + let normalized = if path_style.is_windows() { path.trim_start_matches("/") } else { path }; + let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized)); + let path = decoded.as_ref(); if let Some(fragment) = url.fragment() { let line_range = parse_line_range(fragment)?; @@ -108,7 +112,7 @@ impl MentionUri { if let Some(thread_id) = path.strip_prefix("/agent/thread/") { let name = single_query_param(&url, "name")?.context("Missing thread name")?; Ok(Self::Thread { - id: acp::SessionId(thread_id.into()), + id: acp::SessionId::new(thread_id), name, }) } else if let Some(path) = path.strip_prefix("/agent/text-thread/") { @@ -406,6 +410,19 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), selection_uri); } + #[test] + fn test_parse_file_uri_with_non_ascii() { + let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"); + let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); + match &parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt"))); + } + _ => panic!("Expected File variant"), + } + assert_eq!(parsed.to_uri().to_string(), file_uri); + } + #[test] fn test_parse_untitled_selection_uri() { let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10"); diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 8b08868616e19b0d1855558a057af8eebc314e4a..f70e044fbc1b380768dbcd807f1833f6fb5cd48b 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -75,11 +75,9 @@ impl Terminal { let exit_status = exit_status.map(portable_pty::ExitStatus::from); - acp::TerminalExitStatus { - exit_code: exit_status.as_ref().map(|e| e.exit_code()), - signal: exit_status.and_then(|e| e.signal().map(Into::into)), - meta: None, - } + acp::TerminalExitStatus::new() + .exit_code(exit_status.as_ref().map(|e| e.exit_code())) + .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))) }) .shared(), } @@ -103,25 +101,19 @@ impl Terminal { if let Some(output) = self.output.as_ref() { let exit_status = output.exit_status.map(portable_pty::ExitStatus::from); - acp::TerminalOutputResponse { - output: output.content.clone(), - truncated: output.original_content_len > output.content.len(), - exit_status: Some(acp::TerminalExitStatus { - exit_code: exit_status.as_ref().map(|e| e.exit_code()), - signal: exit_status.and_then(|e| e.signal().map(Into::into)), - meta: None, - }), - meta: None, - } + acp::TerminalOutputResponse::new( + output.content.clone(), + output.original_content_len > output.content.len(), + ) + .exit_status( + acp::TerminalExitStatus::new() + .exit_code(exit_status.as_ref().map(|e| e.exit_code())) + .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))), + ) } else { let (current_content, original_len) = self.truncated_output(cx); - - acp::TerminalOutputResponse { - truncated: current_content.len() < original_len, - output: current_content, - exit_status: None, - meta: None, - } + let truncated = current_content.len() < original_len; + acp::TerminalOutputResponse::new(current_content, truncated) } } @@ -195,8 +187,10 @@ pub async fn create_terminal_entity( Default::default() }; - // Disables paging for `git` and hopefully other commands + // Disable pagers so agent/terminal commands don't hang behind interactive UIs env.insert("PAGER".into(), "".into()); + // Override user core.pager (e.g. delta) which Git prefers over PAGER + env.insert("GIT_PAGER".into(), "cat".into()); env.extend(env_vars); // Use remote shell or default system shell, as appropriate diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 7615784676c7d9ff1782a6e9537e608cb927154d..b0d30367da0634dc82f8db96fc099e268aa4790e 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -371,13 +371,13 @@ impl AcpTools { syntax: cx.theme().syntax().clone(), code_block_overflow_x_scroll: true, code_block: StyleRefinement { - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some( theme_settings.buffer_font.family.clone(), ), font_size: Some((base_size * 0.8).into()), ..Default::default() - }), + }, ..Default::default() }, ..Default::default() @@ -528,7 +528,7 @@ impl Render for AcpTools { .with_sizing_behavior(gpui::ListSizingBehavior::Auto) .size_full(), ) - .vertical_scrollbar_for(connection.list_state.clone(), window, cx) + .vertical_scrollbar_for(&connection.list_state, window, cx) .into_any() } } diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 78265007a5abe3e724166610013ade776d82dbeb..6eb18a4f12325f0c181928f99b4eb921265dbf9c 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -409,9 +409,11 @@ impl ActionLog { let new_diff_base = new_diff_base.clone(); async move { let mut unreviewed_edits = Patch::default(); - for hunk in diff_snapshot - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer_snapshot) - { + for hunk in diff_snapshot.hunks_intersecting_range( + Anchor::min_for_buffer(buffer_snapshot.remote_id()) + ..Anchor::max_for_buffer(buffer_snapshot.remote_id()), + &buffer_snapshot, + ) { let old_range = new_diff_base .offset_to_point(hunk.diff_base_byte_range.start) ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end); @@ -732,12 +734,10 @@ impl ActionLog { cx: &mut Context, ) -> Task<()> { let futures = self.changed_buffers(cx).into_keys().map(|buffer| { - let reject = self.reject_edits_in_ranges( - buffer, - vec![Anchor::MIN..Anchor::MAX], - telemetry.clone(), - cx, - ); + let buffer_ranges = vec![Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + )]; + let reject = self.reject_edits_in_ranges(buffer, buffer_ranges, telemetry.clone(), cx); async move { reject.await.log_err(); @@ -777,7 +777,7 @@ impl ActionLog { #[derive(Clone)] pub struct ActionLogTelemetry { - pub agent_telemetry_id: &'static str, + pub agent_telemetry_id: SharedString, pub session_id: Arc, } @@ -2010,7 +2010,8 @@ mod tests { // User accepts the single hunk action_log.update(cx, |log, cx| { - log.keep_edits_in_range(buffer.clone(), Anchor::MIN..Anchor::MAX, None, cx) + let buffer_range = Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()); + log.keep_edits_in_range(buffer.clone(), buffer_range, None, cx) }); cx.run_until_parked(); assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); @@ -2031,7 +2032,14 @@ mod tests { // User rejects the hunk action_log .update(cx, |log, cx| { - log.reject_edits_in_ranges(buffer.clone(), vec![Anchor::MIN..Anchor::MAX], None, cx) + log.reject_edits_in_ranges( + buffer.clone(), + vec![Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + )], + None, + cx, + ) }) .await .unwrap(); diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index cacbbd6e4e4423e2560fb963ef59daddce2309dc..667033a1bb33ea0372b8a9d8b0bfb00b23f59347 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -83,6 +83,7 @@ ctor.workspace = true db = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } env_logger.workspace = true +eval_utils.workspace = true fs = { workspace = true, "features" = ["test-support"] } git = { workspace = true, "features" = ["test-support"] } gpui = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 404cd6549e5786b92c49379918346b83fcc0e0c1..4baa7f4ea4004d2137b5cddb255346fa91523091 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -5,12 +5,12 @@ mod legacy_thread; mod native_agent_server; pub mod outline; mod templates; -mod thread; -mod tools; - #[cfg(test)] mod tests; +mod thread; +mod tools; +use context_server::ContextServerId; pub use db::*; pub use history_store::*; pub use native_agent_server::NativeAgentServer; @@ -18,11 +18,11 @@ pub use templates::*; pub use thread::*; pub use tools::*; -use acp_thread::{AcpThread, AgentModelSelector}; +use acp_thread::{AcpThread, AgentModelSelector, UserMessageId}; use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; -use collections::{HashSet, IndexMap}; +use collections::{HashMap, HashSet, IndexMap}; use fs::Fs; use futures::channel::{mpsc, oneshot}; use futures::future::Shared; @@ -30,15 +30,15 @@ use futures::{StreamExt, future}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, }; -use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; +use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ - ProjectContext, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, + ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, + WorktreeContext, }; use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, update_settings_file}; use std::any::Any; -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; @@ -51,18 +51,6 @@ pub struct ProjectSnapshot { pub timestamp: DateTime, } -const RULES_FILE_NAMES: [&str; 9] = [ - ".rules", - ".cursorrules", - ".windsurfrules", - ".clinerules", - ".github/copilot-instructions.md", - "CLAUDE.md", - "AGENT.md", - "AGENTS.md", - "GEMINI.md", -]; - pub struct RulesLoadingError { pub message: SharedString, } @@ -105,7 +93,7 @@ impl LanguageModels { fn refresh_list(&mut self, cx: &App) { let providers = LanguageModelRegistry::global(cx) .read(cx) - .providers() + .visible_providers() .into_iter() .filter(|provider| provider.is_authenticated(cx)) .collect::>(); @@ -165,18 +153,21 @@ impl LanguageModels { id: Self::model_id(model), name: model.name().0, description: None, - icon: Some(provider.icon()), + icon: Some(match provider.icon() { + IconOrSvg::Svg(path) => acp_thread::AgentModelIcon::Path(path), + IconOrSvg::Icon(name) => acp_thread::AgentModelIcon::Named(name), + }), } } fn model_id(model: &Arc) -> acp::ModelId { - acp::ModelId(format!("{}/{}", model.provider_id().0, model.id().0).into()) + acp::ModelId::new(format!("{}/{}", model.provider_id().0, model.id().0)) } fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> { let authenticate_all_providers = LanguageModelRegistry::global(cx) .read(cx) - .providers() + .visible_providers() .iter() .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) .collect::>(); @@ -263,12 +254,24 @@ impl NativeAgent { .await; cx.new(|cx| { + let context_server_store = project.read(cx).context_server_store(); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + let mut subscriptions = vec![ cx.subscribe(&project, Self::handle_project_event), cx.subscribe( &LanguageModelRegistry::global(cx), Self::handle_models_updated_event, ), + cx.subscribe( + &context_server_store, + Self::handle_context_server_store_updated, + ), + cx.subscribe( + &context_server_registry, + Self::handle_context_server_registry_event, + ), ]; if let Some(prompt_store) = prompt_store.as_ref() { subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) @@ -277,16 +280,14 @@ impl NativeAgent { let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = watch::channel(()); Self { - sessions: HashMap::new(), + sessions: HashMap::default(), history, project_context: cx.new(|_| project_context), project_context_needs_refresh: project_context_needs_refresh_tx, _maintain_project_context: cx.spawn(async move |this, cx| { Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await }), - context_server_registry: cx.new(|cx| { - ContextServerRegistry::new(project.read(cx).context_server_store(), cx) - }), + context_server_registry, templates, models: LanguageModels::new(cx), project, @@ -355,6 +356,9 @@ impl NativeAgent { pending_save: Task::ready(()), }, ); + + self.update_available_commands(cx); + acp_thread } @@ -425,10 +429,7 @@ impl NativeAgent { .into_iter() .flat_map(|(contents, prompt_metadata)| match contents { Ok(contents) => Some(UserRulesContext { - uuid: match prompt_metadata.id { - prompt_store::PromptId::User { uuid } => uuid, - prompt_store::PromptId::EditWorkflow => return None, - }, + uuid: prompt_metadata.id.as_user()?, title: prompt_metadata.title.map(|title| title.to_string()), contents, }), @@ -622,6 +623,99 @@ impl NativeAgent { } } + fn handle_context_server_store_updated( + &mut self, + _store: Entity, + _event: &project::context_server_store::Event, + cx: &mut Context, + ) { + self.update_available_commands(cx); + } + + fn handle_context_server_registry_event( + &mut self, + _registry: Entity, + event: &ContextServerRegistryEvent, + cx: &mut Context, + ) { + match event { + ContextServerRegistryEvent::ToolsChanged => {} + ContextServerRegistryEvent::PromptsChanged => { + self.update_available_commands(cx); + } + } + } + + fn update_available_commands(&self, cx: &mut Context) { + let available_commands = self.build_available_commands(cx); + for session in self.sessions.values() { + if let Some(acp_thread) = session.acp_thread.upgrade() { + acp_thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AvailableCommandsUpdate( + acp::AvailableCommandsUpdate::new(available_commands.clone()), + ), + cx, + ) + .log_err(); + }); + } + } + } + + fn build_available_commands(&self, cx: &App) -> Vec { + let registry = self.context_server_registry.read(cx); + + let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default(); + for context_server_prompt in registry.prompts() { + *prompt_name_counts + .entry(context_server_prompt.prompt.name.as_str()) + .or_insert(0) += 1; + } + + registry + .prompts() + .flat_map(|context_server_prompt| { + let prompt = &context_server_prompt.prompt; + + let should_prefix = prompt_name_counts + .get(prompt.name.as_str()) + .copied() + .unwrap_or(0) + > 1; + + let name = if should_prefix { + format!("{}.{}", context_server_prompt.server_id, prompt.name) + } else { + prompt.name.clone() + }; + + let mut command = acp::AvailableCommand::new( + name, + prompt.description.clone().unwrap_or_default(), + ); + + match prompt.arguments.as_deref() { + Some([arg]) => { + let hint = format!("<{}>", arg.name); + + command = command.input(acp::AvailableCommandInput::Unstructured( + acp::UnstructuredCommandInput::new(hint), + )); + } + Some([]) | None => {} + Some(_) => { + // skip >1 argument commands since we don't support them yet + return None; + } + } + + Some(command) + }) + .collect() + } + pub fn load_thread( &mut self, id: acp::SessionId, @@ -720,6 +814,102 @@ impl NativeAgent { history.update(cx, |history, cx| history.reload(cx)).ok(); }); } + + fn send_mcp_prompt( + &self, + message_id: UserMessageId, + session_id: agent_client_protocol::SessionId, + prompt_name: String, + server_id: ContextServerId, + arguments: HashMap, + original_content: Vec, + cx: &mut Context, + ) -> Task> { + let server_store = self.context_server_registry.read(cx).server_store().clone(); + let path_style = self.project.read(cx).path_style(cx); + + cx.spawn(async move |this, cx| { + let prompt = + crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?; + + let (acp_thread, thread) = this.update(cx, |this, _cx| { + let session = this + .sessions + .get(&session_id) + .context("Failed to get session")?; + anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + })??; + + let mut last_is_user = true; + + thread.update(cx, |thread, cx| { + thread.push_acp_user_block( + message_id, + original_content.into_iter().skip(1), + path_style, + cx, + ); + })?; + + for message in prompt.messages { + let context_server::types::PromptMessage { role, content } = message; + let block = mcp_message_content_to_acp_content_block(content); + + match role { + context_server::types::Role::User => { + let id = acp_thread::UserMessageId::new(); + + acp_thread.update(cx, |acp_thread, cx| { + acp_thread.push_user_content_block_with_indent( + Some(id.clone()), + block.clone(), + true, + cx, + ); + anyhow::Ok(()) + })??; + + thread.update(cx, |thread, cx| { + thread.push_acp_user_block(id, [block], path_style, cx); + anyhow::Ok(()) + })??; + } + context_server::types::Role::Assistant => { + acp_thread.update(cx, |acp_thread, cx| { + acp_thread.push_assistant_content_block_with_indent( + block.clone(), + false, + true, + cx, + ); + anyhow::Ok(()) + })??; + + thread.update(cx, |thread, cx| { + thread.push_acp_agent_block(block, cx); + anyhow::Ok(()) + })??; + } + } + + last_is_user = role == context_server::types::Role::User; + } + + let response_stream = thread.update(cx, |thread, cx| { + if last_is_user { + thread.send_existing(cx) + } else { + // Resume if MCP prompt did not end with a user message + thread.resume(cx) + } + })??; + + cx.update(|cx| { + NativeAgentConnection::handle_thread_events(response_stream, acp_thread, cx) + })? + .await + }) + } } /// Wrapper struct that implements the AgentConnection trait @@ -789,28 +979,12 @@ impl NativeAgentConnection { } ThreadEvent::AgentText(text) => { acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - false, - cx, - ) + thread.push_assistant_content_block(text.into(), false, cx) })?; } ThreadEvent::AgentThinking(text) => { acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - true, - cx, - ) + thread.push_assistant_content_block(text.into(), true, cx) })?; } ThreadEvent::ToolCallAuthorization(ToolCallAuthorization { @@ -824,8 +998,9 @@ impl NativeAgentConnection { ) })??; cx.background_spawn(async move { - if let acp::RequestPermissionOutcome::Selected { option_id } = - outcome_task.await + if let acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome { option_id, .. }, + ) = outcome_task.await { response .send(option_id) @@ -852,10 +1027,7 @@ impl NativeAgentConnection { } ThreadEvent::Stop(stop_reason) => { log::debug!("Assistant message complete: {:?}", stop_reason); - return Ok(acp::PromptResponse { - stop_reason, - meta: None, - }); + return Ok(acp::PromptResponse::new(stop_reason)); } } } @@ -867,14 +1039,44 @@ impl NativeAgentConnection { } log::debug!("Response stream completed"); - anyhow::Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + anyhow::Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }) } } +struct Command<'a> { + prompt_name: &'a str, + arg_value: &'a str, + explicit_server_id: Option<&'a str>, +} + +impl<'a> Command<'a> { + fn parse(prompt: &'a [acp::ContentBlock]) -> Option { + let acp::ContentBlock::Text(text_content) = prompt.first()? else { + return None; + }; + let text = text_content.text.trim(); + let command = text.strip_prefix('/')?; + let (command, arg_value) = command + .split_once(char::is_whitespace) + .unwrap_or((command, "")); + + if let Some((server_id, prompt_name)) = command.split_once('.') { + Some(Self { + prompt_name, + arg_value, + explicit_server_id: Some(server_id), + }) + } else { + Some(Self { + prompt_name: command, + arg_value, + explicit_server_id: None, + }) + } + } +} + struct NativeAgentModelSelector { session_id: acp::SessionId, connection: NativeAgentConnection, @@ -965,11 +1167,15 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector { fn should_render_footer(&self) -> bool { true } + + fn supports_favorites(&self) -> bool { + true + } } impl acp_thread::AgentConnection for NativeAgentConnection { - fn telemetry_id(&self) -> &'static str { - "zed" + fn telemetry_id(&self) -> SharedString { + "zed".into() } fn new_thread( @@ -1040,6 +1246,47 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let session_id = params.session_id.clone(); log::info!("Received prompt request for session: {}", session_id); log::debug!("Prompt blocks count: {}", params.prompt.len()); + + if let Some(parsed_command) = Command::parse(¶ms.prompt) { + let registry = self.0.read(cx).context_server_registry.read(cx); + + let explicit_server_id = parsed_command + .explicit_server_id + .map(|server_id| ContextServerId(server_id.into())); + + if let Some(prompt) = + registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name) + { + let arguments = if !parsed_command.arg_value.is_empty() + && let Some(arg_name) = prompt + .prompt + .arguments + .as_ref() + .and_then(|args| args.first()) + .map(|arg| arg.name.clone()) + { + HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())]) + } else { + Default::default() + }; + + let prompt_name = prompt.prompt.name.clone(); + let server_id = prompt.server_id.clone(); + + return self.0.update(cx, |agent, cx| { + agent.send_mcp_prompt( + id, + session_id.clone(), + prompt_name, + server_id, + arguments, + params.prompt, + cx, + ) + }); + }; + }; + let path_style = self.0.read(cx).project.read(cx).path_style(cx); self.run_turn(session_id, cx, move |thread, cx| { @@ -1240,6 +1487,15 @@ impl TerminalHandle for AcpTerminalHandle { self.terminal .read_with(cx, |term, cx| term.current_output(cx)) } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } } #[cfg(test)] @@ -1374,10 +1630,12 @@ mod internal_tests { IndexMap::from_iter([( AgentModelGroupName("Fake".into()), vec![AgentModelInfo { - id: acp::ModelId("fake/fake".into()), + id: acp::ModelId::new("fake/fake"), name: "Fake".into(), description: None, - icon: Some(ui::IconName::ZedAssistant), + icon: Some(acp_thread::AgentModelIcon::Named( + ui::IconName::ZedAssistant + )), }] )]) ); @@ -1435,7 +1693,7 @@ mod internal_tests { // Select a model let selector = connection.model_selector(&session_id).unwrap(); - let model_id = acp::ModelId("fake/fake".into()); + let model_id = acp::ModelId::new("fake/fake"); cx.update(|cx| selector.select_model(model_id.clone(), cx)) .await .unwrap(); @@ -1521,20 +1779,14 @@ mod internal_tests { thread.send( vec![ "What does ".into(), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: "b.md".into(), - uri: MentionUri::File { + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "b.md", + MentionUri::File { abs_path: path!("/a/b.md").into(), } .to_uri() .to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), + )), " mean?".into(), ], cx, @@ -1633,3 +1885,35 @@ mod internal_tests { }); } } + +fn mcp_message_content_to_acp_content_block( + content: context_server::types::MessageContent, +) -> acp::ContentBlock { + match content { + context_server::types::MessageContent::Text { + text, + annotations: _, + } => text.into(), + context_server::types::MessageContent::Image { + data, + mime_type, + annotations: _, + } => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)), + context_server::types::MessageContent::Audio { + data, + mime_type, + annotations: _, + } => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)), + context_server::types::MessageContent::Resource { + resource, + annotations: _, + } => { + let mut link = + acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string()); + if let Some(mime_type) = resource.mime_type { + link = link.mime_type(mime_type); + } + acp::ContentBlock::ResourceLink(link) + } + } +} diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 6b6312e48176c93fbfb12f97e26c7943c6cbf89a..7a88c5870574cae424bd1fff50f1d20cdb00fa44 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -366,7 +366,7 @@ impl ThreadsDatabase { for (id, summary, updated_at) in rows { threads.push(DbThreadMetadata { - id: acp::SessionId(id), + id: acp::SessionId::new(id), title: summary.into(), updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), }); @@ -424,4 +424,20 @@ impl ThreadsDatabase { Ok(()) }) } + + pub fn delete_threads(&self) -> Task> { + let connection = self.connection.clone(); + + self.executor.spawn(async move { + let connection = connection.lock(); + + let mut delete = connection.exec_bound::<()>(indoc! {" + DELETE FROM threads + "})?; + + delete(())?; + + Ok(()) + }) + } } diff --git a/crates/agent/src/edit_agent.rs b/crates/agent/src/edit_agent.rs index e5b1d1e3871ecb0070f60f5f382196482e24963a..5ea04729a49afae944c5e7ca88ad67791e18b6f3 100644 --- a/crates/agent/src/edit_agent.rs +++ b/crates/agent/src/edit_agent.rs @@ -172,14 +172,14 @@ impl EditAgent { project.set_agent_location( Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX, + position: language::Anchor::max_for_buffer(buffer.read(cx).remote_id()), }), cx, ) }); output_events_tx .unbounded_send(EditAgentOutputEvent::Edited( - language::Anchor::MIN..language::Anchor::MAX, + Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), )) .ok(); })?; @@ -187,7 +187,7 @@ impl EditAgent { while let Some(event) = parse_rx.next().await { match event? { CreateFileParserEvent::NewTextChunk { chunk } => { - cx.update(|cx| { + let buffer_id = cx.update(|cx| { buffer.update(cx, |buffer, cx| buffer.append(chunk, cx)); self.action_log .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); @@ -195,15 +195,18 @@ impl EditAgent { project.set_agent_location( Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX, + position: language::Anchor::max_for_buffer( + buffer.read(cx).remote_id(), + ), }), cx, ) }); + buffer.read(cx).remote_id() })?; output_events_tx .unbounded_send(EditAgentOutputEvent::Edited( - language::Anchor::MIN..language::Anchor::MAX, + Anchor::min_max_range_for_buffer(buffer_id), )) .ok(); } @@ -1200,7 +1203,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); @@ -1218,7 +1223,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); @@ -1236,7 +1243,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); @@ -1254,7 +1263,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); @@ -1269,7 +1280,9 @@ mod tests { project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::MAX + position: language::Anchor::max_for_buffer( + cx.update(|cx| buffer.read(cx).remote_id()) + ), }) ); } diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index 81dce33d0394b5757be4934031f31b6f17233e9c..01c81e0103a2d3624c7e8eb9b9c587726fcc4876 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -4,7 +4,7 @@ use crate::{ }; use Role::*; use client::{Client, UserStore}; -use collections::HashMap; +use eval_utils::{EvalOutput, EvalOutputProcessor, OutcomeKind}; use fs::FakeFs; use futures::{FutureExt, future::LocalBoxFuture}; use gpui::{AppContext, TestAppContext, Timer}; @@ -20,16 +20,62 @@ use rand::prelude::*; use reqwest_client::ReqwestClient; use serde_json::json; use std::{ - cmp::Reverse, fmt::{self, Display}, - io::Write as _, path::Path, str::FromStr, - sync::mpsc, time::Duration, }; use util::path; +#[derive(Default, Clone, Debug)] +struct EditAgentOutputProcessor { + mismatched_tag_threshold: f32, + cumulative_tags: usize, + cumulative_mismatched_tags: usize, + eval_outputs: Vec>, +} + +fn mismatched_tag_threshold(mismatched_tag_threshold: f32) -> EditAgentOutputProcessor { + EditAgentOutputProcessor { + mismatched_tag_threshold, + cumulative_tags: 0, + cumulative_mismatched_tags: 0, + eval_outputs: Vec::new(), + } +} + +#[derive(Clone, Debug)] +struct EditEvalMetadata { + tags: usize, + mismatched_tags: usize, +} + +impl EvalOutputProcessor for EditAgentOutputProcessor { + type Metadata = EditEvalMetadata; + + fn process(&mut self, output: &EvalOutput) { + if matches!(output.outcome, OutcomeKind::Passed | OutcomeKind::Failed) { + self.cumulative_mismatched_tags += output.metadata.mismatched_tags; + self.cumulative_tags += output.metadata.tags; + self.eval_outputs.push(output.clone()); + } + } + + fn assert(&mut self) { + let mismatched_tag_ratio = + self.cumulative_mismatched_tags as f32 / self.cumulative_tags as f32; + if mismatched_tag_ratio > self.mismatched_tag_threshold { + for eval_output in &self.eval_outputs { + println!("{}", eval_output.data); + } + panic!( + "Too many mismatched tags: {:?}", + self.cumulative_mismatched_tags + ); + } + } +} + #[test] #[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_extract_handle_command_output() { @@ -55,22 +101,19 @@ fn eval_extract_handle_command_output() { include_str!("evals/fixtures/extract_handle_command_output/possible-07.diff"), ]; let edit_description = "Extract `handle_command_output` method from `run_git_blame`."; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and extract a method in - the final stanza of `run_git_blame` to deal with command failures, - call it `handle_command_output` and take the std::process::Output as the only parameter. - Do not document the method and do not add any comments. + Read the `{input_file_path}` file and extract a method in + the final stanza of `run_git_blame` to deal with command failures, + call it `handle_command_output` and take the std::process::Output as the only parameter. + Do not document the method and do not add any comments. - Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. - "})], + Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. + "})], ), message( Assistant, @@ -102,9 +145,9 @@ fn eval_extract_handle_command_output() { ), ], Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs), - ), - ); + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); } #[test] @@ -122,18 +165,16 @@ fn eval_delete_run_git_blame() { let input_file_content = include_str!("evals/fixtures/delete_run_git_blame/before.rs"); let output_file_content = include_str!("evals/fixtures/delete_run_git_blame/after.rs"); let edit_description = "Delete the `run_git_blame` function."; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and delete `run_git_blame`. Just that - one function, not its usages. - "})], + Read the `{input_file_path}` file and delete `run_git_blame`. Just that + one function, not its usages. + "})], ), message( Assistant, @@ -166,8 +207,8 @@ fn eval_delete_run_git_blame() { ], Some(input_file_content.into()), EvalAssertion::assert_eq(output_file_content), - ), - ); + )) + }); } #[test] @@ -185,18 +226,16 @@ fn eval_translate_doc_comments() { let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/translate_doc_comments/before.rs"); let edit_description = "Translate all doc comments to Italian"; - eval( - 200, - 1., - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(200, 1., mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the {input_file_path} file and edit it (without overwriting it), - translating all the doc comments to italian. - "})], + Read the {input_file_path} file and edit it (without overwriting it), + translating all the doc comments to italian. + "})], ), message( Assistant, @@ -229,8 +268,8 @@ fn eval_translate_doc_comments() { ], Some(input_file_content.into()), EvalAssertion::judge_diff("Doc comments were translated to Italian"), - ), - ); + )) + }); } #[test] @@ -249,33 +288,31 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { let input_file_content = include_str!("evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs"); let edit_description = "Update compile_parser_to_wasm to use wasi-sdk instead of emscripten"; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. - Use `ureq` to download the SDK for the current platform and architecture. - Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. - Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) - that's inside of the archive. - Don't re-download the SDK if that executable already exists. - - Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} - - Here are the available wasi-sdk assets: - - wasi-sdk-25.0-x86_64-macos.tar.gz - - wasi-sdk-25.0-arm64-macos.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-windows.tar.gz - "})], + Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. + Use `ureq` to download the SDK for the current platform and architecture. + Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. + Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) + that's inside of the archive. + Don't re-download the SDK if that executable already exists. + + Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} + + Here are the available wasi-sdk assets: + - wasi-sdk-25.0-x86_64-macos.tar.gz + - wasi-sdk-25.0-arm64-macos.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-windows.tar.gz + "})], ), message( Assistant, @@ -352,11 +389,11 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { ], Some(input_file_content.into()), EvalAssertion::judge_diff(indoc! {" - - The compile_parser_to_wasm method has been changed to use wasi-sdk - - ureq is used to download the SDK for current platform and architecture - "}), - ), - ); + - The compile_parser_to_wasm method has been changed to use wasi-sdk + - ureq is used to download the SDK for current platform and architecture + "}), + )) + }); } #[test] @@ -380,11 +417,8 @@ fn eval_disable_cursor_blinking() { include_str!("evals/fixtures/disable_cursor_blinking/possible-03.diff"), include_str!("evals/fixtures/disable_cursor_blinking/possible-04.diff"), ]; - eval( - 100, - 0.51, - 0.05, - EvalInput::from_conversation( + eval_utils::eval(100, 0.51, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text("Let's research how to cursor blinking works.")]), message( @@ -421,10 +455,10 @@ fn eval_disable_cursor_blinking() { message( User, [text(indoc! {" - Comment out the lines that interact with the BlinkManager. - Keep the outer `update` blocks, but comments everything that's inside (including if statements). - Don't add additional comments. - "})], + Comment out the lines that interact with the BlinkManager. + Keep the outer `update` blocks, but comments everything that's inside (including if statements). + Don't add additional comments. + "})], ), message( Assistant, @@ -440,9 +474,9 @@ fn eval_disable_cursor_blinking() { ), ], Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs), - ), - ); + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); } #[test] @@ -467,20 +501,16 @@ fn eval_from_pixels_constructor() { let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/from_pixels_constructor/before.rs"); let edit_description = "Implement from_pixels constructor and add tests."; - eval( - 100, - 0.95, - // For whatever reason, this eval produces more mismatched tags. - // Increasing for now, let's see if we can bring this down. - 0.25, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.25), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(indoc! {" - Introduce a new `from_pixels` constructor in Canvas and - also add tests for it in the same file. - "})], + Introduce a new `from_pixels` constructor in Canvas and + also add tests for it in the same file. + "})], ), message( Assistant, @@ -545,92 +575,92 @@ fn eval_from_pixels_constructor() { "tool_4", "grep", indoc! {" - Found 6 matches: + Found 6 matches: - ## Matches in font-kit/src/loaders/core_text.rs + ## Matches in font-kit/src/loaders/core_text.rs - ### mod test › L926-936 - ``` - mod test { - use super::Font; - use crate::properties::{Stretch, Weight}; + ### mod test › L926-936 + ``` + mod test { + use super::Font; + use crate::properties::{Stretch, Weight}; - #[cfg(feature = \"source\")] - use crate::source::SystemSource; + #[cfg(feature = \"source\")] + use crate::source::SystemSource; - static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; + static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; - #[cfg(feature = \"source\")] - #[test] - ``` + #[cfg(feature = \"source\")] + #[test] + ``` - 55 lines remaining in ancestor node. Read the file to see all. + 55 lines remaining in ancestor node. Read the file to see all. - ### mod test › L947-951 - ``` - } + ### mod test › L947-951 + ``` + } - #[test] - fn test_core_text_to_css_font_weight() { - // Exact matches - ``` + #[test] + fn test_core_text_to_css_font_weight() { + // Exact matches + ``` - ### mod test › L959-963 - ``` - } + ### mod test › L959-963 + ``` + } - #[test] - fn test_core_text_to_css_font_stretch() { - // Exact matches - ``` + #[test] + fn test_core_text_to_css_font_stretch() { + // Exact matches + ``` - ## Matches in font-kit/src/loaders/freetype.rs + ## Matches in font-kit/src/loaders/freetype.rs - ### mod test › L1238-1248 - ``` - mod test { - use crate::loaders::freetype::Font; + ### mod test › L1238-1248 + ``` + mod test { + use crate::loaders::freetype::Font; - static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; - static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; + static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; + static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; - #[test] - fn get_pcf_postscript_name() { - let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); - assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); - } - ``` + #[test] + fn get_pcf_postscript_name() { + let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); + assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); + } + ``` - 1 lines remaining in ancestor node. Read the file to see all. + 1 lines remaining in ancestor node. Read the file to see all. - ## Matches in font-kit/src/sources/core_text.rs + ## Matches in font-kit/src/sources/core_text.rs - ### mod test › L265-275 - ``` - mod test { - use crate::properties::{Stretch, Weight}; + ### mod test › L265-275 + ``` + mod test { + use crate::properties::{Stretch, Weight}; - #[test] - fn test_css_to_core_text_font_weight() { - // Exact matches - assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); - assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); - assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); - assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); + #[test] + fn test_css_to_core_text_font_weight() { + // Exact matches + assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); + assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); + assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); + assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); - ``` + ``` - 27 lines remaining in ancestor node. Read the file to see all. + 27 lines remaining in ancestor node. Read the file to see all. - ### mod test › L278-282 - ``` - } + ### mod test › L278-282 + ``` + } - #[test] - fn test_css_to_core_text_font_stretch() { - // Exact matches - ``` - "}, + #[test] + fn test_css_to_core_text_font_stretch() { + // Exact matches + ``` + "}, )], ), message( @@ -648,11 +678,11 @@ fn eval_from_pixels_constructor() { ], Some(input_file_content.into()), EvalAssertion::judge_diff(indoc! {" - - The diff contains a new `from_pixels` constructor - - The diff contains new tests for the `from_pixels` constructor - "}), - ), - ); + - The diff contains a new `from_pixels` constructor + - The diff contains new tests for the `from_pixels` constructor + "}), + )) + }); } #[test] @@ -670,11 +700,9 @@ fn eval_zode() { let input_file_path = "root/zode.py"; let input_content = None; let edit_description = "Create the main Zode CLI script"; - eval( - 50, - 1., - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(50, 1., mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text(include_str!("evals/fixtures/zode/prompt.md"))]), message( @@ -733,7 +761,7 @@ fn eval_zode() { ], ), ], - input_content, + input_content.clone(), EvalAssertion::new(async move |sample, _, _cx| { let invalid_starts = [' ', '`', '\n']; let mut message = String::new(); @@ -758,8 +786,8 @@ fn eval_zode() { }) } }), - ), - ); + )) + }); } #[test] @@ -777,19 +805,17 @@ fn eval_add_overwrite_test() { let input_file_path = "root/action_log.rs"; let input_file_content = include_str!("evals/fixtures/add_overwrite_test/before.rs"); let edit_description = "Add a new test for overwriting a file in action_log.rs"; - eval( - 200, - 0.5, // TODO: make this eval better - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(200, 0.5, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(indoc! {" - Introduce a new test in `action_log.rs` to test overwriting a file. - That is, a file already exists, but we call `buffer_created` as if the file were new. - Take inspiration from all the other tests in the file. - "})], + Introduce a new test in `action_log.rs` to test overwriting a file. + That is, a file already exists, but we call `buffer_created` as if the file were new. + Take inspiration from all the other tests in the file. + "})], ), message( Assistant, @@ -809,81 +835,81 @@ fn eval_add_overwrite_test() { "tool_1", "read_file", indoc! {" - pub struct ActionLog [L13-20] - tracked_buffers [L15] - edited_since_project_diagnostics_check [L17] - project [L19] - impl ActionLog [L22-498] - pub fn new [L24-30] - pub fn project [L32-34] - pub fn checked_project_diagnostics [L37-39] - pub fn has_edited_files_since_project_diagnostics_check [L42-44] - fn track_buffer_internal [L46-101] - fn handle_buffer_event [L103-116] - fn handle_buffer_edited [L118-123] - fn handle_buffer_file_changed [L125-158] - async fn maintain_diff [L160-264] - pub fn buffer_read [L267-269] - pub fn buffer_created [L272-276] - pub fn buffer_edited [L279-287] - pub fn will_delete_buffer [L289-304] - pub fn keep_edits_in_range [L306-364] - pub fn reject_edits_in_ranges [L366-459] - pub fn keep_all_edits [L461-473] - pub fn changed_buffers [L476-482] - pub fn stale_buffers [L485-497] - fn apply_non_conflicting_edits [L500-561] - fn diff_snapshots [L563-585] - fn point_to_row_edit [L587-614] - enum ChangeAuthor [L617-620] - User [L618] - Agent [L619] - enum TrackedBufferStatus [L623-627] - Created [L624] - Modified [L625] - Deleted [L626] - struct TrackedBuffer [L629-641] - buffer [L630] - base_text [L631] - unreviewed_changes [L632] - status [L633] - version [L634] - diff [L635] - snapshot [L636] - diff_update [L637] - _open_lsp_handle [L638] - _maintain_diff [L639] - _subscription [L640] - impl TrackedBuffer [L643-657] - fn has_changes [L644-650] - fn schedule_diff_update [L652-656] - pub struct ChangedBuffer [L659-661] - pub diff [L660] - mod tests [L664-1574] - fn init_logger [L678-682] - fn init_test [L684-691] - async fn test_keep_edits [L694-769] - async fn test_deletions [L772-854] - async fn test_overlapping_user_edits [L857-951] - async fn test_creating_files [L954-1010] - async fn test_deleting_files [L1013-1120] - async fn test_reject_edits [L1123-1255] - async fn test_reject_multiple_edits [L1258-1331] - async fn test_reject_deleted_file [L1334-1388] - async fn test_reject_created_file [L1391-1443] - async fn test_random_diffs [L1446-1535] - fn quiesce [L1510-1534] - struct HunkStatus [L1538-1542] - range [L1539] - diff_status [L1540] - old_text [L1541] - fn unreviewed_hunks [L1544-1573] - - Showing symbols 1-69 (total symbols: 69) - - Using the line numbers in this outline, you can call this tool again while specifying - the start_line and end_line fields to see the implementations of symbols in the outline. - "}, + pub struct ActionLog [L13-20] + tracked_buffers [L15] + edited_since_project_diagnostics_check [L17] + project [L19] + impl ActionLog [L22-498] + pub fn new [L24-30] + pub fn project [L32-34] + pub fn checked_project_diagnostics [L37-39] + pub fn has_edited_files_since_project_diagnostics_check [L42-44] + fn track_buffer_internal [L46-101] + fn handle_buffer_event [L103-116] + fn handle_buffer_edited [L118-123] + fn handle_buffer_file_changed [L125-158] + async fn maintain_diff [L160-264] + pub fn buffer_read [L267-269] + pub fn buffer_created [L272-276] + pub fn buffer_edited [L279-287] + pub fn will_delete_buffer [L289-304] + pub fn keep_edits_in_range [L306-364] + pub fn reject_edits_in_ranges [L366-459] + pub fn keep_all_edits [L461-473] + pub fn changed_buffers [L476-482] + pub fn stale_buffers [L485-497] + fn apply_non_conflicting_edits [L500-561] + fn diff_snapshots [L563-585] + fn point_to_row_edit [L587-614] + enum ChangeAuthor [L617-620] + User [L618] + Agent [L619] + enum TrackedBufferStatus [L623-627] + Created [L624] + Modified [L625] + Deleted [L626] + struct TrackedBuffer [L629-641] + buffer [L630] + base_text [L631] + unreviewed_changes [L632] + status [L633] + version [L634] + diff [L635] + snapshot [L636] + diff_update [L637] + _open_lsp_handle [L638] + _maintain_diff [L639] + _subscription [L640] + impl TrackedBuffer [L643-657] + fn has_changes [L644-650] + fn schedule_diff_update [L652-656] + pub struct ChangedBuffer [L659-661] + pub diff [L660] + mod tests [L664-1574] + fn init_logger [L678-682] + fn init_test [L684-691] + async fn test_keep_edits [L694-769] + async fn test_deletions [L772-854] + async fn test_overlapping_user_edits [L857-951] + async fn test_creating_files [L954-1010] + async fn test_deleting_files [L1013-1120] + async fn test_reject_edits [L1123-1255] + async fn test_reject_multiple_edits [L1258-1331] + async fn test_reject_deleted_file [L1334-1388] + async fn test_reject_created_file [L1391-1443] + async fn test_random_diffs [L1446-1535] + fn quiesce [L1510-1534] + struct HunkStatus [L1538-1542] + range [L1539] + diff_status [L1540] + old_text [L1541] + fn unreviewed_hunks [L1544-1573] + + Showing symbols 1-69 (total symbols: 69) + + Using the line numbers in this outline, you can call this tool again while specifying + the start_line and end_line fields to see the implementations of symbols in the outline. + "}, )], ), message( @@ -963,17 +989,17 @@ fn eval_add_overwrite_test() { Assistant, [ text(indoc! {" - Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: + Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: - 1. Create a file - 2. Open a buffer for it - 3. Track it in the action log - 4. Modify the buffer - 5. Call `buffer_created` again as if the file were new - 6. Check that the changes are properly tracked + 1. Create a file + 2. Open a buffer for it + 3. Track it in the action log + 4. Modify the buffer + 5. Call `buffer_created` again as if the file were new + 6. Check that the changes are properly tracked - Let's write the test: - "}), + Let's write the test: + "}), tool_use( "tool_5", "edit_file", @@ -990,8 +1016,8 @@ fn eval_add_overwrite_test() { EvalAssertion::judge_diff( "A new test for overwritten files was created, without changing any previous test", ), - ), - ); + )) + }); } #[test] @@ -1016,20 +1042,18 @@ fn eval_create_empty_file() { let input_file_content = None; let expected_output_content = String::new(); - eval( - 100, - 0.99, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.99, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text("Create a second empty todo file ")]), message( Assistant, [ text(formatdoc! {" - I'll help you create a second empty todo file. - First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. - "}), + I'll help you create a second empty todo file. + First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. + "}), tool_use( "toolu_01GAF8TtsgpjKxCr8fgQLDgR", "list_directory", @@ -1051,8 +1075,8 @@ fn eval_create_empty_file() { Assistant, [ text(formatdoc! {" - I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: - "}), + I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: + "}), tool_use( "toolu_01Tb3iQ9griqSYMmVuykQPWU", "edit_file", @@ -1065,12 +1089,12 @@ fn eval_create_empty_file() { ], ), ], - input_file_content, + input_file_content.clone(), // Bad behavior is to write something like // "I'll create an empty TODO3 file as requested." - EvalAssertion::assert_eq(expected_output_content), - ), - ); + EvalAssertion::assert_eq(expected_output_content.clone()), + )) + }); } fn message( @@ -1312,115 +1336,45 @@ impl EvalAssertion { } } -fn eval( - iterations: usize, - expected_pass_ratio: f32, - mismatched_tag_threshold: f32, - mut eval: EvalInput, -) { - let mut evaluated_count = 0; - let mut failed_count = 0; - report_progress(evaluated_count, failed_count, iterations); - - let (tx, rx) = mpsc::channel(); - - // Cache the last message in the conversation, and run one instance of the eval so that - // all the next ones are cached. - eval.conversation.last_mut().unwrap().cache = true; - run_eval(eval.clone(), tx.clone()); - - let executor = gpui::background_executor(); - let semaphore = Arc::new(smol::lock::Semaphore::new(32)); - for _ in 1..iterations { - let eval = eval.clone(); - let tx = tx.clone(); - let semaphore = semaphore.clone(); - executor - .spawn(async move { - let _guard = semaphore.acquire().await; - run_eval(eval, tx) - }) - .detach(); - } - drop(tx); - - let mut failed_evals = HashMap::default(); - let mut errored_evals = HashMap::default(); - let mut eval_outputs = Vec::new(); - let mut cumulative_parser_metrics = EditParserMetrics::default(); - while let Ok(output) = rx.recv() { - match output { - Ok(output) => { - cumulative_parser_metrics += output.sample.edit_output.parser_metrics.clone(); - eval_outputs.push(output.clone()); - if output.assertion.score < 80 { - failed_count += 1; - failed_evals - .entry(output.sample.text_after.clone()) - .or_insert(Vec::new()) - .push(output); - } - } - Err(error) => { - failed_count += 1; - *errored_evals.entry(format!("{:?}", error)).or_insert(0) += 1; - } - } - - evaluated_count += 1; - report_progress(evaluated_count, failed_count, iterations); - } - - let actual_pass_ratio = (iterations - failed_count) as f32 / iterations as f32; - println!("Actual pass ratio: {}\n", actual_pass_ratio); - if actual_pass_ratio < expected_pass_ratio { - let mut errored_evals = errored_evals.into_iter().collect::>(); - errored_evals.sort_by_key(|(_, count)| Reverse(*count)); - for (error, count) in errored_evals { - println!("Eval errored {} times. Error: {}", count, error); - } - - let mut failed_evals = failed_evals.into_iter().collect::>(); - failed_evals.sort_by_key(|(_, evals)| Reverse(evals.len())); - for (_buffer_output, failed_evals) in failed_evals { - let eval_output = failed_evals.first().unwrap(); - println!("Eval failed {} times", failed_evals.len()); - println!("{}", eval_output); - } - - panic!( - "Actual pass ratio: {}\nExpected pass ratio: {}", - actual_pass_ratio, expected_pass_ratio - ); - } - - let mismatched_tag_ratio = - cumulative_parser_metrics.mismatched_tags as f32 / cumulative_parser_metrics.tags as f32; - if mismatched_tag_ratio > mismatched_tag_threshold { - for eval_output in eval_outputs { - println!("{}", eval_output); - } - panic!("Too many mismatched tags: {:?}", cumulative_parser_metrics); - } -} - -fn run_eval(eval: EvalInput, tx: mpsc::Sender>) { +fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput { let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); let mut cx = TestAppContext::build(dispatcher, None); - let output = cx.executor().block_test(async { + let result = cx.executor().block_test(async { let test = EditAgentTest::new(&mut cx).await; test.eval(eval, &mut cx).await }); - tx.send(output).unwrap(); + cx.quit(); + match result { + Ok(output) => eval_utils::EvalOutput { + data: output.to_string(), + outcome: if output.assertion.score < 80 { + eval_utils::OutcomeKind::Failed + } else { + eval_utils::OutcomeKind::Passed + }, + metadata: EditEvalMetadata { + tags: output.sample.edit_output.parser_metrics.tags, + mismatched_tags: output.sample.edit_output.parser_metrics.mismatched_tags, + }, + }, + Err(e) => eval_utils::EvalOutput { + data: format!("{e:?}"), + outcome: eval_utils::OutcomeKind::Error, + metadata: EditEvalMetadata { + tags: 0, + mismatched_tags: 0, + }, + }, + } } #[derive(Clone)] -struct EvalOutput { +struct EditEvalOutput { sample: EvalSample, assertion: EvalAssertionOutcome, } -impl Display for EvalOutput { +impl Display for EditEvalOutput { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { writeln!(f, "Score: {:?}", self.assertion.score)?; if let Some(message) = self.assertion.message.as_ref() { @@ -1439,22 +1393,6 @@ impl Display for EvalOutput { } } -fn report_progress(evaluated_count: usize, failed_count: usize, iterations: usize) { - let passed_count = evaluated_count - failed_count; - let passed_ratio = if evaluated_count == 0 { - 0.0 - } else { - passed_count as f64 / evaluated_count as f64 - }; - print!( - "\r\x1b[KEvaluated {}/{} ({:.2}% passed)", - evaluated_count, - iterations, - passed_ratio * 100.0 - ); - std::io::stdout().flush().unwrap(); -} - struct EditAgentTest { agent: EditAgent, project: Entity, @@ -1550,7 +1488,10 @@ impl EditAgentTest { }) } - async fn eval(&self, eval: EvalInput, cx: &mut TestAppContext) -> Result { + async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result { + // Make sure the last message in the conversation is cached. + eval.conversation.last_mut().unwrap().cache = true; + let path = self .project .read_with(cx, |project, cx| { @@ -1656,7 +1597,7 @@ impl EditAgentTest { .run(&sample, self.judge_model.clone(), cx) .await?; - Ok(EvalOutput { assertion, sample }) + Ok(EditEvalOutput { assertion, sample }) } } diff --git a/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md b/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md index 902e43857c3214cde68372f1c9ff5f8015528ae2..29755d441f7a4f74709c1ac414e2a9a73fe6ac21 100644 --- a/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md +++ b/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md @@ -2,12 +2,12 @@ - We're starting from a completely blank project - Like Aider/Claude Code you take the user's initial prompt and then call the LLM and perform tool calls in a loop until the ultimate goal is achieved. - Unlike Aider or Claude code, it's not intended to be interactive. Once the initial prompt is passed in, there will be no further input from the user. -- The system you will build must reach the stated goal just by performing too calls and calling the LLM +- The system you will build must reach the stated goal just by performing tool calls and calling the LLM - I want you to build this in python. Use the anthropic python sdk and the model context protocol sdk. Use a virtual env and pip to install dependencies - Follow the anthropic guidance on tool calls: https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview - Use this Anthropic model: `claude-3-7-sonnet-20250219` - Use this Anthropic API Key: `sk-ant-api03-qweeryiofdjsncmxquywefidopsugus` -- One of the most important pieces to this is having good too calls. We will be using the tools provided by the Claude MCP server. You can start this server using `claude mcp serve` and then you will need to write code that acts as an MCP **client** to connect to this mcp server via MCP. Likely you want to start this using a subprocess. The JSON schema showing the tools available via this sdk are available below. Via this MCP server you have access to all the tools that zode needs: Bash, GlobTool, GrepTool, LS, View, Edit, Replace, WebFetchTool +- One of the most important pieces to this is having good tool calls. We will be using the tools provided by the Claude MCP server. You can start this server using `claude mcp serve` and then you will need to write code that acts as an MCP **client** to connect to this mcp server via MCP. Likely you want to start this using a subprocess. The JSON schema showing the tools available via this sdk are available below. Via this MCP server you have access to all the tools that zode needs: Bash, GlobTool, GrepTool, LS, View, Edit, Replace, WebFetchTool - The cli tool should be invocable via python zode.py file.md where file.md is any possible file that contains the users prompt. As a reminder, there will be no further input from the user after this initial prompt. Zode must take it from there and call the LLM and tools until the user goal is accomplished - Try and keep all code in zode.py and make heavy use of the asks I mentioned - Once you’ve implemented this, you must run python zode.py eval/instructions.md to see how well our new agent tool does! diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index 3bfbd99677feed5db53d96d2fa96316ac49abce4..c455f73316e3fc7a641fa8a31ac0ad766a2ae584 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -188,6 +188,15 @@ impl HistoryStore { }) } + pub fn delete_threads(&mut self, cx: &mut Context) -> Task> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.delete_threads().await?; + this.update(cx, |this, cx| this.reload(cx)) + }) + } + pub fn delete_text_thread( &mut self, path: Arc, @@ -207,14 +216,10 @@ impl HistoryStore { } pub fn reload(&self, cx: &mut Context) { - let database_future = ThreadsDatabase::connect(cx); + let database_connection = ThreadsDatabase::connect(cx); cx.spawn(async move |this, cx| { - let threads = database_future - .await - .map_err(|err| anyhow!(err))? - .list_threads() - .await?; - + let database = database_connection.await; + let threads = database.map_err(|err| anyhow!(err))?.list_threads().await?; this.update(cx, |this, cx| { if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES { for thread in threads @@ -335,7 +340,8 @@ impl HistoryStore { fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { cx.background_spawn(async move { if cfg!(any(feature = "test-support", test)) { - anyhow::bail!("history store does not persist in tests"); + log::warn!("history store does not persist in tests"); + return Ok(VecDeque::new()); } let json = KEY_VALUE_STORE .read_kvp(RECENTLY_OPENED_THREADS_KEY)? @@ -345,9 +351,9 @@ impl HistoryStore { .into_iter() .take(MAX_RECENTLY_OPENED_ENTRIES) .flat_map(|entry| match entry { - SerializedRecentOpen::AcpThread(id) => Some(HistoryEntryId::AcpThread( - acp::SessionId(id.as_str().into()), - )), + SerializedRecentOpen::AcpThread(id) => { + Some(HistoryEntryId::AcpThread(acp::SessionId::new(id.as_str()))) + } SerializedRecentOpen::TextThread(file_name) => Some( HistoryEntryId::TextThread(text_threads_dir().join(file_name).into()), ), diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 4c78c5a3f85b6628f9784fe7ecbadc8531b017d0..a9ade8141a678329e0dd8dad9808e55eee3c382b 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -21,10 +21,6 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn telemetry_id(&self) -> &'static str { - "zed" - } - fn name(&self) -> SharedString { "Zed Agent".into() } diff --git a/crates/agent/src/outline.rs b/crates/agent/src/outline.rs index 40a84bc28b4402d8251e164a423a2309127c50ce..77af4849ffd19c1630331f5c755ff372cb69aeba 100644 --- a/crates/agent/src/outline.rs +++ b/crates/agent/src/outline.rs @@ -66,11 +66,9 @@ pub async fn get_buffer_content_or_outline( let outline_text = render_outline(outline_items, None, 0, usize::MAX).await?; let text = if let Some(path) = path { - format!( - "# File outline for {path} (file too large to show full content)\n\n{outline_text}", - ) + format!("# File outline for {path}\n\n{outline_text}",) } else { - format!("# File outline (file too large to show full content)\n\n{outline_text}",) + format!("# File outline\n\n{outline_text}",) }; Ok(BufferContent { text, diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 4620647135631fdb367b0dc2604e89770a938c07..2477e46a85183813f61bb60d7e3de7f119a4f00c 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -16,7 +16,7 @@ You are a highly skilled software engineer with extensive knowledge in many prog 3. DO NOT use tools to access items that are already available in the context section. 4. Use only the tools that are currently available. 5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off. -6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers. +6. When running commands that may run indefinitely or for a long time (such as build scripts, tests, servers, or file watchers), specify `timeout_ms` to bound runtime. If the command times out, the user can always ask you to run it again with a longer timeout or no timeout if they're willing to wait or cancel manually. 7. Avoid HTML entity escaping - use plain characters instead. ## Searching and Reading diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index efba471f1a927446aa96b1c1426c60b42b725b89..45028902e467fe67945ddf444c9ae417dcaed654 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -9,14 +9,16 @@ use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; use futures::{ - StreamExt, + FutureExt as _, StreamExt, channel::{ mpsc::{self, UnboundedReceiver}, oneshot, }, + future::{Fuse, Shared}, }; use gpui::{ - App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, + App, AppContext, AsyncApp, Entity, Task, TestAppContext, UpdateGlobal, + http_client::FakeHttpClient, }; use indoc::indoc; use language_model::{ @@ -35,12 +37,109 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; use settings::{Settings, SettingsStore}; -use std::{path::Path, rc::Rc, sync::Arc, time::Duration}; +use std::{ + path::Path, + pin::Pin, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use util::path; mod test_tools; use test_tools::*; +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); +} + +struct FakeTerminalHandle { + killed: Arc, + wait_for_exit: Shared>, + output: acp::TerminalOutputResponse, + id: acp::TerminalId, +} + +impl FakeTerminalHandle { + fn new_never_exits(cx: &mut App) -> Self { + let killed = Arc::new(AtomicBool::new(false)); + + let killed_for_task = killed.clone(); + let wait_for_exit = cx + .spawn(async move |cx| { + loop { + if killed_for_task.load(Ordering::SeqCst) { + return acp::TerminalExitStatus::new(); + } + cx.background_executor() + .timer(Duration::from_millis(1)) + .await; + } + }) + .shared(); + + Self { + killed, + wait_for_exit, + output: acp::TerminalOutputResponse::new("partial output".to_string(), false), + id: acp::TerminalId::new("fake_terminal".to_string()), + } + } + + fn was_killed(&self) -> bool { + self.killed.load(Ordering::SeqCst) + } +} + +impl crate::TerminalHandle for FakeTerminalHandle { + fn id(&self, _cx: &AsyncApp) -> Result { + Ok(self.id.clone()) + } + + fn current_output(&self, _cx: &AsyncApp) -> Result { + Ok(self.output.clone()) + } + + fn wait_for_exit(&self, _cx: &AsyncApp) -> Result>> { + Ok(self.wait_for_exit.clone()) + } + + fn kill(&self, _cx: &AsyncApp) -> Result<()> { + self.killed.store(true, Ordering::SeqCst); + Ok(()) + } +} + +struct FakeThreadEnvironment { + handle: Rc, +} + +impl crate::ThreadEnvironment for FakeThreadEnvironment { + fn create_terminal( + &self, + _command: String, + _cwd: Option, + _output_byte_limit: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + Task::ready(Ok(self.handle.clone() as Rc)) + } +} + +fn always_allow_tools(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); +} + #[gpui::test] async fn test_echo(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -71,6 +170,120 @@ async fn test_echo(cx: &mut TestAppContext) { assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); } +#[gpui::test] +async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: Some(5), + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + let mut task_future: Pin>>>> = Box::pin(task.fuse()); + + let deadline = std::time::Instant::now() + Duration::from_millis(500); + loop { + if let Some(result) = task_future.as_mut().now_or_never() { + let result = result.expect("terminal tool task should complete"); + + assert!( + handle.was_killed(), + "expected terminal handle to be killed on timeout" + ); + assert!( + result.contains("partial output"), + "expected result to include terminal output, got: {result}" + ); + return; + } + + if std::time::Instant::now() >= deadline { + panic!("timed out waiting for terminal tool task to complete"); + } + + cx.run_until_parked(); + cx.background_executor.timer(Duration::from_millis(1)).await; + } +} + +#[gpui::test] +#[ignore] +async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let _task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: None, + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + smol::Timer::after(Duration::from_millis(25)).await; + + assert!( + !handle.was_killed(), + "did not expect terminal handle to be killed without a timeout" + ); +} + #[gpui::test] async fn test_thinking(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -493,14 +706,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { // Approve the first tool_call_auth_1 .response - .send(tool_call_auth_1.options[1].id.clone()) + .send(tool_call_auth_1.options[1].option_id.clone()) .unwrap(); cx.run_until_parked(); // Reject the second tool_call_auth_2 .response - .send(tool_call_auth_1.options[2].id.clone()) + .send(tool_call_auth_1.options[2].option_id.clone()) .unwrap(); cx.run_until_parked(); @@ -510,14 +723,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { message.content, vec![ language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_1.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), output: Some("Allowed".into()) }), language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_2.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: true, content: "Permission to run tool denied by user".into(), @@ -543,7 +756,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; tool_call_auth_3 .response - .send(tool_call_auth_3.options[0].id.clone()) + .send(tool_call_auth_3.options[0].option_id.clone()) .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -552,7 +765,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { message.content, vec![language_model::MessageContent::ToolResult( LanguageModelToolResult { - tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_3.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), @@ -1353,20 +1566,20 @@ async fn test_cancellation(cx: &mut TestAppContext) { ThreadEvent::ToolCall(tool_call) => { assert_eq!(tool_call.title, expected_tools.remove(0)); if tool_call.title == "Echo" { - echo_id = Some(tool_call.id); + echo_id = Some(tool_call.tool_call_id); } } ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( acp::ToolCallUpdate { - id, + tool_call_id, fields: acp::ToolCallUpdateFields { status: Some(acp::ToolCallStatus::Completed), .. }, - meta: None, + .. }, - )) if Some(&id) == echo_id.as_ref() => { + )) if Some(&tool_call_id) == echo_id.as_ref() => { echo_completed = true; } _ => {} @@ -1995,11 +2208,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { .update(|cx| { connection.prompt( Some(acp_thread::UserMessageId::new()), - acp::PromptRequest { - session_id: session_id.clone(), - prompt: vec!["ghi".into()], - meta: None, - }, + acp::PromptRequest::new(session_id.clone(), vec!["ghi".into()]), cx, ) }) @@ -2056,68 +2265,50 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let tool_call = expect_tool_call(&mut events).await; assert_eq!( tool_call, - acp::ToolCall { - id: acp::ToolCallId("1".into()), - title: "Thinking".into(), - kind: acp::ToolKind::Think, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(json!({})), - raw_output: None, - meta: Some(json!({ "tool_name": "thinking" })), - } + acp::ToolCall::new("1", "Thinking") + .kind(acp::ToolKind::Think) + .raw_input(json!({})) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + "thinking".into() + )])) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - title: Some("Thinking".into()), - kind: Some(acp::ToolKind::Think), - raw_input: Some(json!({ "content": "Thinking hard!" })), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new() + .title("Thinking") + .kind(acp::ToolKind::Think) + .raw_input(json!({ "content": "Thinking hard!"})) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - content: Some(vec!["Thinking hard!".into()]), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new().content(vec!["Thinking hard!".into()]) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - raw_output: Some("Finished thinking.".into()), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .raw_output("Finished thinking.") + ) ); } @@ -2553,7 +2744,7 @@ fn setup_context_server( let mut settings = ProjectSettings::get_global(cx).clone(); settings.context_servers.insert( name.into(), - project::project_settings::ContextServerSettings::Custom { + project::project_settings::ContextServerSettings::Stdio { enabled: true, command: ContextServerCommand { path: "somebinary".into(), @@ -2618,3 +2809,181 @@ fn setup_context_server( cx.run_until_parked(); mcp_tool_calls_rx } + +#[gpui::test] +async fn test_tokens_before_message(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // First message + let message_1_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_1_id.clone(), ["First message"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // Before any response, tokens_before_message should return None for first message + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_1_id), + None, + "First message should have no tokens before it" + ); + }); + + // Complete first message with usage + fake_model.send_last_completion_stream_text_chunk("Response 1"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // First message still has no tokens before it + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_1_id), + None, + "First message should still have no tokens before it after response" + ); + }); + + // Second message + let message_2_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_2_id.clone(), ["Second message"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // Second message should have first message's input tokens before it + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_2_id), + Some(100), + "Second message should have 100 tokens before it (from first request)" + ); + }); + + // Complete second message + fake_model.send_last_completion_stream_text_chunk("Response 2"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 250, // Total for this request (includes previous context) + output_tokens: 75, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Third message + let message_3_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_3_id.clone(), ["Third message"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // Third message should have second message's input tokens (250) before it + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_3_id), + Some(250), + "Third message should have 250 tokens before it (from second request)" + ); + // Second message should still have 100 + assert_eq!( + thread.tokens_before_message(&message_2_id), + Some(100), + "Second message should still have 100 tokens before it" + ); + // First message still has none + assert_eq!( + thread.tokens_before_message(&message_1_id), + None, + "First message should still have no tokens before it" + ); + }); +} + +#[gpui::test] +async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // Set up three messages with responses + let message_1_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_1_id.clone(), ["Message 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Response 1"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let message_2_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_2_id.clone(), ["Message 2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Response 2"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 250, + output_tokens: 75, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Verify initial state + thread.read_with(cx, |thread, _| { + assert_eq!(thread.tokens_before_message(&message_2_id), Some(100)); + }); + + // Truncate at message 2 (removes message 2 and everything after) + thread + .update(cx, |thread, cx| thread.truncate(message_2_id.clone(), cx)) + .unwrap(); + cx.run_until_parked(); + + // After truncation, message_2_id no longer exists, so lookup should return None + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_2_id), + None, + "After truncation, message 2 no longer exists" + ); + // Message 1 still exists but has no tokens before it + assert_eq!( + thread.tokens_before_message(&message_1_id), + None, + "First message still has no tokens before it" + ); + }); +} diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 294c96b3ecb7800ab5b5f62749d335682efebd60..ef3ca23c3caf816a28e91e9e75b21f2cc80451e7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2,7 +2,8 @@ use crate::{ ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, - SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool, + RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool, + ThinkingTool, WebSearchTool, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; @@ -107,7 +108,13 @@ impl Message { pub fn to_request(&self) -> Vec { match self { - Message::User(message) => vec![message.to_request()], + Message::User(message) => { + if message.content.is_empty() { + vec![] + } else { + vec![message.to_request()] + } + } Message::Agent(message) => message.to_request(), Message::Resume => vec![LanguageModelRequestMessage { role: Role::User, @@ -530,6 +537,7 @@ pub trait TerminalHandle { fn id(&self, cx: &AsyncApp) -> Result; fn current_output(&self, cx: &AsyncApp) -> Result; fn wait_for_exit(&self, cx: &AsyncApp) -> Result>>; + fn kill(&self, cx: &AsyncApp) -> Result<()>; } pub trait ThreadEnvironment { @@ -619,12 +627,9 @@ pub struct Thread { impl Thread { fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities { let image = model.map_or(true, |model| model.supports_images()); - acp::PromptCapabilities { - meta: None, - image, - audio: false, - embedded_context: true, - } + acp::PromptCapabilities::new() + .image(image) + .embedded_context(true) } pub fn new( @@ -640,7 +645,7 @@ impl Thread { let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel(Self::prompt_capabilities(model.as_deref())); Self { - id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), + id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()), prompt_id: PromptId::new(), updated_at: Utc::now(), title: None, @@ -737,17 +742,11 @@ impl Thread { let Some(tool) = tool else { stream .0 - .unbounded_send(Ok(ThreadEvent::ToolCall(acp::ToolCall { - meta: None, - id: acp::ToolCallId(tool_use.id.to_string().into()), - title: tool_use.name.to_string(), - kind: acp::ToolKind::Other, - status: acp::ToolCallStatus::Failed, - content: Vec::new(), - locations: Vec::new(), - raw_input: Some(tool_use.input.clone()), - raw_output: None, - }))) + .unbounded_send(Ok(ThreadEvent::ToolCall( + acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) + .status(acp::ToolCallStatus::Failed) + .raw_input(tool_use.input.clone()), + ))) .ok(); return; }; @@ -777,8 +776,8 @@ impl Thread { stream.update_tool_call_fields( &tool_use.id, - acp::ToolCallUpdateFields { - status: Some( + acp::ToolCallUpdateFields::new() + .status( tool_result .as_ref() .map_or(acp::ToolCallStatus::Failed, |result| { @@ -788,10 +787,8 @@ impl Thread { acp::ToolCallStatus::Completed } }), - ), - raw_output: output, - ..Default::default() - }, + ) + .raw_output(output), ); } @@ -1012,6 +1009,8 @@ impl Thread { self.project.clone(), self.action_log.clone(), )); + self.add_tool(SaveFileTool::new(self.project.clone())); + self.add_tool(RestoreFileFromDiskTool::new(self.project.clone())); self.add_tool(TerminalTool::new(self.project.clone(), environment)); self.add_tool(ThinkingTool); self.add_tool(WebSearchTool); @@ -1096,6 +1095,28 @@ impl Thread { }) } + /// Get the total input token count as of the message before the given message. + /// + /// Returns `None` if: + /// - `target_id` is the first message (no previous message) + /// - The previous message hasn't received a response yet (no usage data) + /// - `target_id` is not found in the messages + pub fn tokens_before_message(&self, target_id: &UserMessageId) -> Option { + let mut previous_user_message_id: Option<&UserMessageId> = None; + + for message in &self.messages { + if let Message::User(user_msg) = message { + if &user_msg.id == target_id { + let prev_id = previous_user_message_id?; + let usage = self.request_token_usage.get(prev_id)?; + return Some(usage.input_tokens); + } + previous_user_message_id = Some(&user_msg.id); + } + } + None + } + /// Look up the active profile and resolve its preferred model if one is configured. fn resolve_profile_model( profile_id: &AgentProfileId, @@ -1148,20 +1169,64 @@ impl Thread { where T: Into, { + let content = content.into_iter().map(Into::into).collect::>(); + log::debug!("Thread::send content: {:?}", content); + + self.messages + .push(Message::User(UserMessage { id, content })); + cx.notify(); + + self.send_existing(cx) + } + + pub fn send_existing( + &mut self, + cx: &mut Context, + ) -> Result>> { let model = self.model().context("No language model configured")?; log::info!("Thread::send called with model: {}", model.name().0); self.advance_prompt_id(); - let content = content.into_iter().map(Into::into).collect::>(); - log::debug!("Thread::send content: {:?}", content); + log::debug!("Total messages in thread: {}", self.messages.len()); + self.run_turn(cx) + } + pub fn push_acp_user_block( + &mut self, + id: UserMessageId, + blocks: impl IntoIterator, + path_style: PathStyle, + cx: &mut Context, + ) { + let content = blocks + .into_iter() + .map(|block| UserMessageContent::from_content_block(block, path_style)) + .collect::>(); self.messages .push(Message::User(UserMessage { id, content })); cx.notify(); + } - log::debug!("Total messages in thread: {}", self.messages.len()); - self.run_turn(cx) + pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context) { + let text = match block { + acp::ContentBlock::Text(text_content) => text_content.text, + acp::ContentBlock::Image(_) => "[image]".to_string(), + acp::ContentBlock::Audio(_) => "[audio]".to_string(), + acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri, + acp::ContentBlock::Resource(resource) => match resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri, + acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri, + _ => "[resource]".to_string(), + }, + _ => "[unknown]".to_string(), + }; + + self.messages.push(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::Text(text)], + ..Default::default() + })); + cx.notify(); } #[cfg(feature = "eval")] @@ -1274,15 +1339,13 @@ impl Thread { event_stream.update_tool_call_fields( &tool_result.tool_use_id, - acp::ToolCallUpdateFields { - status: Some(if tool_result.is_error { + acp::ToolCallUpdateFields::new() + .status(if tool_result.is_error { acp::ToolCallStatus::Failed } else { acp::ToolCallStatus::Completed - }), - raw_output: tool_result.output.clone(), - ..Default::default() - }, + }) + .raw_output(tool_result.output.clone()), ); this.update(cx, |this, _cx| { this.pending_message() @@ -1560,12 +1623,10 @@ impl Thread { } else { event_stream.update_tool_call_fields( &tool_use.id, - acp::ToolCallUpdateFields { - title: Some(title.into()), - kind: Some(kind), - raw_input: Some(tool_use.input.clone()), - ..Default::default() - }, + acp::ToolCallUpdateFields::new() + .title(title.as_str()) + .kind(kind) + .raw_input(tool_use.input.clone()), ); } @@ -1587,10 +1648,9 @@ impl Thread { let fs = self.project.read(cx).fs().clone(); let tool_event_stream = ToolCallEventStream::new(tool_use.id.clone(), event_stream.clone(), Some(fs)); - tool_event_stream.update_fields(acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }); + tool_event_stream.update_fields( + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), + ); let supports_images = self.model().is_some_and(|model| model.supports_images()); let tool_result = tool.run(tool_use.input, tool_event_stream, cx); log::debug!("Running tool {}", tool_use.name); @@ -1665,6 +1725,10 @@ impl Thread { self.pending_summary_generation.is_some() } + pub fn is_generating_title(&self) -> bool { + self.pending_title_generation.is_some() + } + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { if let Some(summary) = self.summary.as_ref() { return Task::ready(Some(summary.clone())).shared(); @@ -1732,7 +1796,7 @@ impl Thread { task } - fn generate_title(&mut self, cx: &mut Context) { + pub fn generate_title(&mut self, cx: &mut Context) { let Some(model) = self.summarization_model.clone() else { return; }; @@ -1981,6 +2045,12 @@ impl Thread { self.running_turn.as_ref()?.tools.get(name).cloned() } + pub fn has_tool(&self, name: &str) -> bool { + self.running_turn + .as_ref() + .is_some_and(|turn| turn.tools.contains_key(name)) + } + fn build_request_messages( &self, available_tools: Vec, @@ -2381,19 +2451,13 @@ impl ThreadEventStream { kind: acp::ToolKind, input: serde_json::Value, ) -> acp::ToolCall { - acp::ToolCall { - meta: Some(serde_json::json!({ - "tool_name": tool_name - })), - id: acp::ToolCallId(id.to_string().into()), - title, - kind, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(input), - raw_output: None, - } + acp::ToolCall::new(id.to_string(), title) + .kind(kind) + .raw_input(input) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + tool_name.into(), + )])) } fn update_tool_call_fields( @@ -2403,12 +2467,7 @@ impl ThreadEventStream { ) { self.0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( - acp::ToolCallUpdate { - meta: None, - id: acp::ToolCallId(tool_use_id.to_string().into()), - fields, - } - .into(), + acp::ToolCallUpdate::new(tool_use_id.to_string(), fields).into(), ))) .ok(); } @@ -2471,7 +2530,7 @@ impl ToolCallEventStream { .0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp_thread::ToolCallUpdateDiff { - id: acp::ToolCallId(self.tool_use_id.to_string().into()), + id: acp::ToolCallId::new(self.tool_use_id.to_string()), diff, } .into(), @@ -2489,33 +2548,26 @@ impl ToolCallEventStream { .0 .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( ToolCallAuthorization { - tool_call: acp::ToolCallUpdate { - meta: None, - id: acp::ToolCallId(self.tool_use_id.to_string().into()), - fields: acp::ToolCallUpdateFields { - title: Some(title.into()), - ..Default::default() - }, - }, + tool_call: acp::ToolCallUpdate::new( + self.tool_use_id.to_string(), + acp::ToolCallUpdateFields::new().title(title.into()), + ), options: vec![ - acp::PermissionOption { - id: acp::PermissionOptionId("always_allow".into()), - name: "Always Allow".into(), - kind: acp::PermissionOptionKind::AllowAlways, - meta: None, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("allow".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("deny".into()), - name: "Deny".into(), - kind: acp::PermissionOptionKind::RejectOnce, - meta: None, - }, + acp::PermissionOption::new( + acp::PermissionOptionId::new("always_allow"), + "Always Allow", + acp::PermissionOptionKind::AllowAlways, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("deny"), + "Deny", + acp::PermissionOptionKind::RejectOnce, + ), ], response: response_tx, }, @@ -2660,7 +2712,15 @@ impl UserMessageContent { // TODO Self::Text("[blob]".to_string()) } + other => { + log::warn!("Unexpected content type: {:?}", other); + Self::Text("[unknown]".to_string()) + } }, + other => { + log::warn!("Unexpected content type: {:?}", other); + Self::Text("[unknown]".to_string()) + } } } } @@ -2668,32 +2728,15 @@ impl UserMessageContent { impl From for acp::ContentBlock { fn from(content: UserMessageContent) -> Self { match content { - UserMessageContent::Text(text) => acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - UserMessageContent::Image(image) => acp::ContentBlock::Image(acp::ImageContent { - data: image.source.to_string(), - mime_type: "image/png".to_string(), - meta: None, - annotations: None, - uri: None, - }), - UserMessageContent::Mention { uri, content } => { - acp::ContentBlock::Resource(acp::EmbeddedResource { - meta: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - meta: None, - mime_type: None, - text: content, - uri: uri.to_uri().to_string(), - }, - ), - annotations: None, - }) + UserMessageContent::Text(text) => text.into(), + UserMessageContent::Image(image) => { + acp::ContentBlock::Image(acp::ImageContent::new(image.source, "image/png")) } + UserMessageContent::Mention { uri, content } => acp::ContentBlock::Resource( + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(content, uri.to_uri().to_string()), + )), + ), } } } @@ -2701,7 +2744,6 @@ impl From for acp::ContentBlock { fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { LanguageModelImage { source: image_content.data.into(), - // TODO: make this optional? - size: gpui::Size::new(0.into(), 0.into()), + size: None, } } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 1d3c0d557716ec3a52f910971547df4ee764cab0..358903a32baa5ead9b073642015e6829501307a2 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -12,6 +12,9 @@ mod move_path_tool; mod now_tool; mod open_tool; mod read_file_tool; +mod restore_file_from_disk_tool; +mod save_file_tool; + mod terminal_tool; mod thinking_tool; mod web_search_tool; @@ -33,6 +36,9 @@ pub use move_path_tool::*; pub use now_tool::*; pub use open_tool::*; pub use read_file_tool::*; +pub use restore_file_from_disk_tool::*; +pub use save_file_tool::*; + pub use terminal_tool::*; pub use thinking_tool::*; pub use web_search_tool::*; @@ -88,6 +94,8 @@ tools! { NowTool, OpenTool, ReadFileTool, + RestoreFileFromDiskTool, + SaveFileTool, TerminalTool, ThinkingTool, WebSearchTool, diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 03a0ef84e73d4cbca83d61077d568ec58cd7ae2b..3b01b2feb7dd36615a8ba7c63d81a81694e0d268 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -2,12 +2,24 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream}; use agent_client_protocol::ToolKind; use anyhow::{Result, anyhow, bail}; use collections::{BTreeMap, HashMap}; -use context_server::ContextServerId; -use gpui::{App, Context, Entity, SharedString, Task}; +use context_server::{ContextServerId, client::NotificationSubscription}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; use project::context_server_store::{ContextServerStatus, ContextServerStore}; use std::sync::Arc; use util::ResultExt; +pub struct ContextServerPrompt { + pub server_id: ContextServerId, + pub prompt: context_server::types::Prompt, +} + +pub enum ContextServerRegistryEvent { + ToolsChanged, + PromptsChanged, +} + +impl EventEmitter for ContextServerRegistry {} + pub struct ContextServerRegistry { server_store: Entity, registered_servers: HashMap, @@ -16,7 +28,10 @@ pub struct ContextServerRegistry { struct RegisteredContextServer { tools: BTreeMap>, + prompts: BTreeMap, load_tools: Task>, + load_prompts: Task>, + _tools_updated_subscription: Option, } impl ContextServerRegistry { @@ -28,6 +43,7 @@ impl ContextServerRegistry { }; for server in server_store.read(cx).running_servers() { this.reload_tools_for_server(server.id(), cx); + this.reload_prompts_for_server(server.id(), cx); } this } @@ -56,6 +72,88 @@ impl ContextServerRegistry { .map(|(id, server)| (id, &server.tools)) } + pub fn prompts(&self) -> impl Iterator { + self.registered_servers + .values() + .flat_map(|server| server.prompts.values()) + } + + pub fn find_prompt( + &self, + server_id: Option<&ContextServerId>, + name: &str, + ) -> Option<&ContextServerPrompt> { + if let Some(server_id) = server_id { + self.registered_servers + .get(server_id) + .and_then(|server| server.prompts.get(name)) + } else { + self.registered_servers + .values() + .find_map(|server| server.prompts.get(name)) + } + } + + pub fn server_store(&self) -> &Entity { + &self.server_store + } + + fn get_or_register_server( + &mut self, + server_id: &ContextServerId, + cx: &mut Context, + ) -> &mut RegisteredContextServer { + self.registered_servers + .entry(server_id.clone()) + .or_insert_with(|| Self::init_registered_server(server_id, &self.server_store, cx)) + } + + fn init_registered_server( + server_id: &ContextServerId, + server_store: &Entity, + cx: &mut Context, + ) -> RegisteredContextServer { + let tools_updated_subscription = server_store + .read(cx) + .get_running_server(server_id) + .and_then(|server| { + let client = server.client()?; + + if !client.capable(context_server::protocol::ServerCapability::Tools) { + return None; + } + + let server_id = server.id(); + let this = cx.entity().downgrade(); + + Some(client.on_notification( + "notifications/tools/list_changed", + Box::new(move |_params, cx: AsyncApp| { + let server_id = server_id.clone(); + let this = this.clone(); + cx.spawn(async move |cx| { + this.update(cx, |this, cx| { + log::info!( + "Received tools/list_changed notification for server {}", + server_id + ); + this.reload_tools_for_server(server_id, cx); + }) + }) + .detach(); + }), + )) + }); + + RegisteredContextServer { + tools: BTreeMap::default(), + prompts: BTreeMap::default(), + load_tools: Task::ready(Ok(())), + load_prompts: Task::ready(Ok(())), + _tools_updated_subscription: tools_updated_subscription, + } + } + fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context) { let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else { return; @@ -63,17 +161,12 @@ impl ContextServerRegistry { let Some(client) = server.client() else { return; }; + if !client.capable(context_server::protocol::ServerCapability::Tools) { return; } - let registered_server = - self.registered_servers - .entry(server_id.clone()) - .or_insert(RegisteredContextServer { - tools: BTreeMap::default(), - load_tools: Task::ready(Ok(())), - }); + let registered_server = self.get_or_register_server(&server_id, cx); registered_server.load_tools = cx.spawn(async move |this, cx| { let response = client .request::(()) @@ -94,6 +187,49 @@ impl ContextServerRegistry { )); registered_server.tools.insert(tool.name(), tool); } + cx.emit(ContextServerRegistryEvent::ToolsChanged); + cx.notify(); + } + }) + }); + } + + fn reload_prompts_for_server(&mut self, server_id: ContextServerId, cx: &mut Context) { + let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else { + return; + }; + let Some(client) = server.client() else { + return; + }; + if !client.capable(context_server::protocol::ServerCapability::Prompts) { + return; + } + + let registered_server = self.get_or_register_server(&server_id, cx); + + registered_server.load_prompts = cx.spawn(async move |this, cx| { + let response = client + .request::(()) + .await; + + this.update(cx, |this, cx| { + let Some(registered_server) = this.registered_servers.get_mut(&server_id) else { + return; + }; + + registered_server.prompts.clear(); + if let Some(response) = response.log_err() { + for prompt in response.prompts { + let name: SharedString = prompt.name.clone().into(); + registered_server.prompts.insert( + name, + ContextServerPrompt { + server_id: server_id.clone(), + prompt, + }, + ); + } + cx.emit(ContextServerRegistryEvent::PromptsChanged); cx.notify(); } }) @@ -112,9 +248,17 @@ impl ContextServerRegistry { ContextServerStatus::Starting => {} ContextServerStatus::Running => { self.reload_tools_for_server(server_id.clone(), cx); + self.reload_prompts_for_server(server_id.clone(), cx); } ContextServerStatus::Stopped | ContextServerStatus::Error(_) => { - self.registered_servers.remove(server_id); + if let Some(registered_server) = self.registered_servers.remove(server_id) { + if !registered_server.tools.is_empty() { + cx.emit(ContextServerRegistryEvent::ToolsChanged); + } + if !registered_server.prompts.is_empty() { + cx.emit(ContextServerRegistryEvent::PromptsChanged); + } + } cx.notify(); } } @@ -251,3 +395,39 @@ impl AnyAgentTool for ContextServerTool { Ok(()) } } + +pub fn get_prompt( + server_store: &Entity, + server_id: &ContextServerId, + prompt_name: &str, + arguments: HashMap, + cx: &mut AsyncApp, +) -> Task> { + let server = match cx.update(|cx| server_store.read(cx).get_running_server(server_id)) { + Ok(server) => server, + Err(error) => return Task::ready(Err(error)), + }; + let Some(server) = server else { + return Task::ready(Err(anyhow::anyhow!("Context server not found"))); + }; + + let Some(protocol) = server.client() else { + return Task::ready(Err(anyhow::anyhow!("Context server not initialized"))); + }; + + let prompt_name = prompt_name.to_string(); + + cx.background_spawn(async move { + let response = protocol + .request::( + context_server::types::PromptsGetParams { + name: prompt_name, + arguments: (!arguments.is_empty()).then(|| arguments), + meta: None, + }, + ) + .await?; + + Ok(response) + }) +} diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index de2dd384693c8af3e04007895c843743c5ead722..3acb7f5951f3ca4b682dcabc62a0d54c35ab08d6 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -273,14 +273,9 @@ impl AgentTool for EditFileTool { }; let abs_path = project.read(cx).absolute_path(&project_path, cx); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path, - line: None, - meta: None, - }]), - ..Default::default() - }); + event_stream.update_fields( + ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]), + ); } let authorize = self.authorize(&input, &event_stream, cx); @@ -311,20 +306,39 @@ impl AgentTool for EditFileTool { // Check if the file has been modified since the agent last read it if let Some(abs_path) = abs_path.as_ref() { - let (last_read_mtime, current_mtime, is_dirty) = self.thread.update(cx, |thread, cx| { + let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| { let last_read = thread.file_read_times.get(abs_path).copied(); let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime()); let dirty = buffer.read(cx).is_dirty(); - (last_read, current, dirty) + let has_save = thread.has_tool("save_file"); + let has_restore = thread.has_tool("restore_file_from_disk"); + (last_read, current, dirty, has_save, has_restore) })?; // Check for unsaved changes first - these indicate modifications we don't know about if is_dirty { - anyhow::bail!( - "This file cannot be written to because it has unsaved changes. \ - Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \ - Ask the user to save that buffer's changes and to inform you when it's ok to proceed." - ); + let message = match (has_save_tool, has_restore_tool) { + (true, true) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." + } + (true, false) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ + If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." + } + (false, true) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." + } + (false, false) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ + then ask them to save or revert the file manually and inform you when it's ok to proceed." + } + }; + anyhow::bail!("{}", message); } // Check if the file was modified on disk since we last read it @@ -389,10 +403,7 @@ impl AgentTool for EditFileTool { range.start.to_point(&buffer.snapshot()).row }).ok(); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![ToolCallLocation { path: abs_path, line, meta: None }]), - ..Default::default() - }); + event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)])); } emitted_location = true; } @@ -2210,9 +2221,21 @@ mod tests { assert!(result.is_err(), "Edit should fail when buffer is dirty"); let error_msg = result.unwrap_err().to_string(); assert!( - error_msg.contains("cannot be written to because it has unsaved changes"), + error_msg.contains("This file has unsaved changes."), "Error should mention unsaved changes, got: {}", error_msg ); + assert!( + error_msg.contains("keep or discard"), + "Error should ask whether to keep or discard changes, got: {}", + error_msg + ); + // Since save_file and restore_file_from_disk tools aren't added to the thread, + // the error message should ask the user to manually save or revert + assert!( + error_msg.contains("save or revert the file manually"), + "Error should ask user to manually save or revert when tools aren't available, got: {}", + error_msg + ); } } diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 41954ee31b2a4529e75541e78eff278a521307da..2a33b14b4c87d87154e2aa1ee25363b397189f89 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -118,33 +118,29 @@ impl AgentTool for FindPathTool { let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len()) ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())]; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(if paginated_matches.is_empty() { - "No matches".into() - } else if paginated_matches.len() == 1 { - "1 match".into() - } else { - format!("{} matches", paginated_matches.len()) - }), - content: Some( - paginated_matches - .iter() - .map(|path| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: format!("file://{}", path.display()), - name: path.to_string_lossy().into(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + event_stream.update_fields( + acp::ToolCallUpdateFields::new() + .title(if paginated_matches.is_empty() { + "No matches".into() + } else if paginated_matches.len() == 1 { + "1 match".into() + } else { + format!("{} matches", paginated_matches.len()) + }) + .content( + paginated_matches + .iter() + .map(|path| { + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + path.to_string_lossy(), + format!("file://{}", path.display()), + )), + )) + }) + .collect::>(), + ), + ); Ok(FindPathToolOutput { offset: input.offset, @@ -177,7 +173,7 @@ fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task + /// If the project has the following root directories: + /// + /// - /a/b/backend + /// - /c/d/frontend + /// + /// Use "backend/**/*.rs" to search only Rust files in the backend root directory. + /// Use "frontend/src/**/*.ts" to search TypeScript files only in the frontend root directory (sub-directory "src"). + /// Use "**/*.rs" to search Rust files across all root directories. + /// pub include_pattern: Option, /// Optional starting position for paginated results (0-based). /// When not provided, starts from the beginning. @@ -132,8 +145,7 @@ impl AgentTool for GrepTool { let exclude_patterns = global_settings .file_scan_exclusions .sources() - .iter() - .chain(global_settings.private_files.sources().iter()); + .chain(global_settings.private_files.sources()); match PathMatcher::new(exclude_patterns, path_style) { Ok(matcher) => matcher, @@ -310,7 +322,6 @@ mod tests { use super::*; use gpui::{TestAppContext, UpdateGlobal}; - use language::{Language, LanguageConfig, LanguageMatcher}; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; @@ -552,7 +563,7 @@ mod tests { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| { - project.languages().add(rust_lang().into()) + project.languages().add(language::rust_lang()) }); project @@ -781,22 +792,6 @@ mod tests { }); } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../../languages/src/rust/outline.scm")) - .unwrap() - } - #[gpui::test] async fn test_grep_security_boundaries(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index eccb40737c744d57792655cadb925e18a68d2835..acfd4a16746fc1f78fd388f5dacf3e360f070ab5 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -17,6 +17,9 @@ use crate::{AgentTool, Thread, ToolCallEventStream, outline}; /// Reads the content of the given file in the project. /// /// - Never attempt to read a path that hasn't been previously mentioned. +/// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content. +/// This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line. +/// Do NOT retry reading the same file without line numbers if you receive an outline. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ReadFileToolInput { /// The relative path of the file to read. @@ -150,14 +153,10 @@ impl AgentTool for ReadFileTool { let file_path = input.path.clone(); - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: input.start_line.map(|line| line.saturating_sub(1)), - meta: None, - }]), - ..Default::default() - }); + event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ + acp::ToolCallLocation::new(&abs_path) + .line(input.start_line.map(|line| line.saturating_sub(1))), + ])); if image_store::is_image_file(&self.project, &project_path, cx) { return cx.spawn(async move |cx| { @@ -254,16 +253,15 @@ impl AgentTool for ReadFileTool { if buffer_content.is_outline { Ok(formatdoc! {" - This file was too big to read all at once. + SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers. - {} + IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline. + Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters. - Using the line numbers in this outline, you can call this tool again - while specifying the start_line and end_line fields to see the - implementations of symbols in the outline. + {} - Alternatively, you can fall back to the `grep` tool (if available) - to search the file for specific content.", buffer_content.text + NEXT STEPS: To read a specific symbol's implementation, call read_file with the same path plus start_line and end_line from the outline above. + For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text } .into()) } else { @@ -275,7 +273,9 @@ impl AgentTool for ReadFileTool { project.set_agent_location( Some(AgentLocation { buffer: buffer.downgrade(), - position: anchor.unwrap_or(text::Anchor::MIN), + position: anchor.unwrap_or_else(|| { + text::Anchor::min_for_buffer(buffer.read(cx).remote_id()) + }), }), cx, ); @@ -285,12 +285,9 @@ impl AgentTool for ReadFileTool { text, } .to_string(); - event_stream.update_fields(ToolCallUpdateFields { - content: Some(vec![acp::ToolCallContent::Content { - content: markdown.into(), - }]), - ..Default::default() - }) + event_stream.update_fields(ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new(markdown)), + ])); } })?; @@ -304,7 +301,6 @@ mod test { use super::*; use crate::{ContextServerRegistry, Templates, Thread}; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; - use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; use language_model::fake_provider::FakeLanguageModel; use project::{FakeFs, Project}; use prompt_store::ProjectContext; @@ -408,7 +404,7 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(Arc::new(rust_lang())); + language_registry.add(language::rust_lang()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); @@ -438,7 +434,7 @@ mod test { let content = result.to_str().unwrap(); assert_eq!( - content.lines().skip(4).take(6).collect::>(), + content.lines().skip(7).take(6).collect::>(), vec![ "struct Test0 [L1-4]", " a [L2]", @@ -473,7 +469,7 @@ mod test { pretty_assertions::assert_eq!( content .lines() - .skip(4) + .skip(7) .take(expected_content.len()) .collect::>(), expected_content @@ -598,49 +594,6 @@ mod test { }); } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query( - r#" - (line_comment) @annotation - - (struct_item - "struct" @context - name: (_) @name) @item - (enum_item - "enum" @context - name: (_) @name) @item - (enum_variant - name: (_) @name) @item - (field_declaration - name: (_) @name) @item - (impl_item - "impl" @context - trait: (_)? @name - "for"? @context - type: (_) @name - body: (_ "{" (_)* "}")) @item - (function_item - "fn" @context - name: (_) @name) @item - (mod_item - "mod" @context - name: (_) @name) @item - "#, - ) - .unwrap() - } - #[gpui::test] async fn test_read_file_security(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..f5723f6ee3ee46144152dd3ed2939ab2cfaca9c0 --- /dev/null +++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs @@ -0,0 +1,352 @@ +use agent_client_protocol as acp; +use anyhow::Result; +use collections::FxHashSet; +use gpui::{App, Entity, SharedString, Task}; +use language::Buffer; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::{AgentTool, ToolCallEventStream}; + +/// Discards unsaved changes in open buffers by reloading file contents from disk. +/// +/// Use this tool when: +/// - You attempted to edit files but they have unsaved changes the user does not want to keep. +/// - You want to reset files to the on-disk state before retrying an edit. +/// +/// Only use this tool after asking the user for permission, because it will discard unsaved changes. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct RestoreFileFromDiskToolInput { + /// The paths of the files to restore from disk. + pub paths: Vec, +} + +pub struct RestoreFileFromDiskTool { + project: Entity, +} + +impl RestoreFileFromDiskTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for RestoreFileFromDiskTool { + type Input = RestoreFileFromDiskToolInput; + type Output = String; + + fn name() -> &'static str { + "restore_file_from_disk" + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + match input { + Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(), + Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(), + Err(_) => "Restore files from disk".into(), + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + let input_paths = input.paths; + + cx.spawn(async move |cx| { + let mut buffers_to_reload: FxHashSet> = FxHashSet::default(); + + let mut restored_paths: Vec = Vec::new(); + let mut clean_paths: Vec = Vec::new(); + let mut not_found_paths: Vec = Vec::new(); + let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut reload_errors: Vec = Vec::new(); + + for path in input_paths { + let project_path = + project.read_with(cx, |project, cx| project.find_project_path(&path, cx)); + + let project_path = match project_path { + Ok(Some(project_path)) => project_path, + Ok(None) => { + not_found_paths.push(path); + continue; + } + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }; + + let open_buffer_task = + project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + + let buffer = match open_buffer_task { + Ok(task) => match task.await { + Ok(buffer) => buffer, + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }, + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }; + + let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) { + Ok(is_dirty) => is_dirty, + Err(error) => { + dirty_check_errors.push((path, error.to_string())); + continue; + } + }; + + if is_dirty { + buffers_to_reload.insert(buffer); + restored_paths.push(path); + } else { + clean_paths.push(path); + } + } + + if !buffers_to_reload.is_empty() { + let reload_task = project.update(cx, |project, cx| { + project.reload_buffers(buffers_to_reload, true, cx) + }); + + match reload_task { + Ok(task) => { + if let Err(error) = task.await { + reload_errors.push(error.to_string()); + } + } + Err(error) => { + reload_errors.push(error.to_string()); + } + } + } + + let mut lines: Vec = Vec::new(); + + if !restored_paths.is_empty() { + lines.push(format!("Restored {} file(s).", restored_paths.len())); + } + if !clean_paths.is_empty() { + lines.push(format!("{} clean.", clean_paths.len())); + } + + if !not_found_paths.is_empty() { + lines.push(format!("Not found ({}):", not_found_paths.len())); + for path in ¬_found_paths { + lines.push(format!("- {}", path.display())); + } + } + if !open_errors.is_empty() { + lines.push(format!("Open failed ({}):", open_errors.len())); + for (path, error) in &open_errors { + lines.push(format!("- {}: {}", path.display(), error)); + } + } + if !dirty_check_errors.is_empty() { + lines.push(format!( + "Dirty check failed ({}):", + dirty_check_errors.len() + )); + for (path, error) in &dirty_check_errors { + lines.push(format!("- {}: {}", path.display(), error)); + } + } + if !reload_errors.is_empty() { + lines.push(format!("Reload failed ({}):", reload_errors.len())); + for error in &reload_errors { + lines.push(format!("- {}", error)); + } + } + + if lines.is_empty() { + Ok("No paths provided.".to_string()) + } else { + Ok(lines.join("\n")) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fs::Fs; + use gpui::TestAppContext; + use language::LineEnding; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + #[gpui::test] + async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dirty.txt": "on disk: dirty\n", + "clean.txt": "on disk: clean\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone())); + + // Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk. + let dirty_project_path = project.read_with(cx, |project, cx| { + project + .find_project_path("root/dirty.txt", cx) + .expect("dirty.txt should exist in project") + }); + + let dirty_buffer = project + .update(cx, |project, cx| { + project.open_buffer(dirty_project_path, cx) + }) + .await + .unwrap(); + dirty_buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx); + }); + assert!( + dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "dirty.txt buffer should be dirty before restore" + ); + + // Ensure clean.txt is opened but remains clean. + let clean_project_path = project.read_with(cx, |project, cx| { + project + .find_project_path("root/clean.txt", cx) + .expect("clean.txt should exist in project") + }); + + let clean_buffer = project + .update(cx, |project, cx| { + project.open_buffer(clean_project_path, cx) + }) + .await + .unwrap(); + assert!( + !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "clean.txt buffer should start clean" + ); + + let output = cx + .update(|cx| { + tool.clone().run( + RestoreFileFromDiskToolInput { + paths: vec![ + PathBuf::from("root/dirty.txt"), + PathBuf::from("root/clean.txt"), + ], + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Output should mention restored + clean. + assert!( + output.contains("Restored 1 file(s)."), + "expected restored count line, got:\n{output}" + ); + assert!( + output.contains("1 clean."), + "expected clean count line, got:\n{output}" + ); + + // Effect: dirty buffer should be restored back to disk content and become clean. + let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!( + dirty_text, "on disk: dirty\n", + "dirty.txt buffer should be restored to disk contents" + ); + assert!( + !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "dirty.txt buffer should not be dirty after restore" + ); + + // Disk contents should be unchanged (restore-from-disk should not write). + let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap(); + assert_eq!(disk_dirty, "on disk: dirty\n"); + + // Sanity: clean buffer should remain clean and unchanged. + let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!(clean_text, "on disk: clean\n"); + assert!( + !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "clean.txt buffer should remain clean" + ); + + // Test empty paths case. + let output = cx + .update(|cx| { + tool.clone().run( + RestoreFileFromDiskToolInput { paths: vec![] }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + assert_eq!(output, "No paths provided."); + + // Test not-found path case (path outside the project root). + let output = cx + .update(|cx| { + tool.clone().run( + RestoreFileFromDiskToolInput { + paths: vec![PathBuf::from("nonexistent/path.txt")], + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + assert!( + output.contains("Not found (1):"), + "expected not-found header line, got:\n{output}" + ); + assert!( + output.contains("- nonexistent/path.txt"), + "expected not-found path bullet, got:\n{output}" + ); + + let _ = LineEnding::Unix; // keep import used if the buffer edit API changes + } +} diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..429352200109c52303c9f6f94a28a49136af1a61 --- /dev/null +++ b/crates/agent/src/tools/save_file_tool.rs @@ -0,0 +1,351 @@ +use agent_client_protocol as acp; +use anyhow::Result; +use collections::FxHashSet; +use gpui::{App, Entity, SharedString, Task}; +use language::Buffer; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::{AgentTool, ToolCallEventStream}; + +/// Saves files that have unsaved changes. +/// +/// Use this tool when you need to edit files but they have unsaved changes that must be saved first. +/// Only use this tool after asking the user for permission to save their unsaved changes. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct SaveFileToolInput { + /// The paths of the files to save. + pub paths: Vec, +} + +pub struct SaveFileTool { + project: Entity, +} + +impl SaveFileTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for SaveFileTool { + type Input = SaveFileToolInput; + type Output = String; + + fn name() -> &'static str { + "save_file" + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + match input { + Ok(input) if input.paths.len() == 1 => "Save file".into(), + Ok(input) => format!("Save {} files", input.paths.len()).into(), + Err(_) => "Save files".into(), + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + let input_paths = input.paths; + + cx.spawn(async move |cx| { + let mut buffers_to_save: FxHashSet> = FxHashSet::default(); + + let mut saved_paths: Vec = Vec::new(); + let mut clean_paths: Vec = Vec::new(); + let mut not_found_paths: Vec = Vec::new(); + let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut save_errors: Vec<(String, String)> = Vec::new(); + + for path in input_paths { + let project_path = + project.read_with(cx, |project, cx| project.find_project_path(&path, cx)); + + let project_path = match project_path { + Ok(Some(project_path)) => project_path, + Ok(None) => { + not_found_paths.push(path); + continue; + } + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }; + + let open_buffer_task = + project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + + let buffer = match open_buffer_task { + Ok(task) => match task.await { + Ok(buffer) => buffer, + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }, + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }; + + let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) { + Ok(is_dirty) => is_dirty, + Err(error) => { + dirty_check_errors.push((path, error.to_string())); + continue; + } + }; + + if is_dirty { + buffers_to_save.insert(buffer); + saved_paths.push(path); + } else { + clean_paths.push(path); + } + } + + // Save each buffer individually since there's no batch save API. + for buffer in buffers_to_save { + let path_for_buffer = match buffer.read_with(cx, |buffer, _| { + buffer + .file() + .map(|file| file.path().to_rel_path_buf()) + .map(|path| path.as_rel_path().as_unix_str().to_owned()) + }) { + Ok(path) => path.unwrap_or_else(|| "".to_string()), + Err(error) => { + save_errors.push(("".to_string(), error.to_string())); + continue; + } + }; + + let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx)); + + match save_task { + Ok(task) => { + if let Err(error) = task.await { + save_errors.push((path_for_buffer, error.to_string())); + } + } + Err(error) => { + save_errors.push((path_for_buffer, error.to_string())); + } + } + } + + let mut lines: Vec = Vec::new(); + + if !saved_paths.is_empty() { + lines.push(format!("Saved {} file(s).", saved_paths.len())); + } + if !clean_paths.is_empty() { + lines.push(format!("{} clean.", clean_paths.len())); + } + + if !not_found_paths.is_empty() { + lines.push(format!("Not found ({}):", not_found_paths.len())); + for path in ¬_found_paths { + lines.push(format!("- {}", path.display())); + } + } + if !open_errors.is_empty() { + lines.push(format!("Open failed ({}):", open_errors.len())); + for (path, error) in &open_errors { + lines.push(format!("- {}: {}", path.display(), error)); + } + } + if !dirty_check_errors.is_empty() { + lines.push(format!( + "Dirty check failed ({}):", + dirty_check_errors.len() + )); + for (path, error) in &dirty_check_errors { + lines.push(format!("- {}: {}", path.display(), error)); + } + } + if !save_errors.is_empty() { + lines.push(format!("Save failed ({}):", save_errors.len())); + for (path, error) in &save_errors { + lines.push(format!("- {}: {}", path, error)); + } + } + + if lines.is_empty() { + Ok("No paths provided.".to_string()) + } else { + Ok(lines.join("\n")) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fs::Fs; + use gpui::TestAppContext; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + #[gpui::test] + async fn test_save_file_output_and_effects(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dirty.txt": "on disk: dirty\n", + "clean.txt": "on disk: clean\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let tool = Arc::new(SaveFileTool::new(project.clone())); + + // Make dirty.txt dirty in-memory. + let dirty_project_path = project.read_with(cx, |project, cx| { + project + .find_project_path("root/dirty.txt", cx) + .expect("dirty.txt should exist in project") + }); + + let dirty_buffer = project + .update(cx, |project, cx| { + project.open_buffer(dirty_project_path, cx) + }) + .await + .unwrap(); + dirty_buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx); + }); + assert!( + dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "dirty.txt buffer should be dirty before save" + ); + + // Ensure clean.txt is opened but remains clean. + let clean_project_path = project.read_with(cx, |project, cx| { + project + .find_project_path("root/clean.txt", cx) + .expect("clean.txt should exist in project") + }); + + let clean_buffer = project + .update(cx, |project, cx| { + project.open_buffer(clean_project_path, cx) + }) + .await + .unwrap(); + assert!( + !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "clean.txt buffer should start clean" + ); + + let output = cx + .update(|cx| { + tool.clone().run( + SaveFileToolInput { + paths: vec![ + PathBuf::from("root/dirty.txt"), + PathBuf::from("root/clean.txt"), + ], + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Output should mention saved + clean. + assert!( + output.contains("Saved 1 file(s)."), + "expected saved count line, got:\n{output}" + ); + assert!( + output.contains("1 clean."), + "expected clean count line, got:\n{output}" + ); + + // Effect: dirty buffer should now be clean and disk should have new content. + assert!( + !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "dirty.txt buffer should not be dirty after save" + ); + + let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap(); + assert_eq!( + disk_dirty, "in memory: dirty\n", + "dirty.txt disk content should be updated" + ); + + // Sanity: clean buffer should remain clean and disk unchanged. + let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap(); + assert_eq!(disk_clean, "on disk: clean\n"); + + // Test empty paths case. + let output = cx + .update(|cx| { + tool.clone().run( + SaveFileToolInput { paths: vec![] }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + assert_eq!(output, "No paths provided."); + + // Test not-found path case. + let output = cx + .update(|cx| { + tool.clone().run( + SaveFileToolInput { + paths: vec![PathBuf::from("nonexistent/path.txt")], + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + assert!( + output.contains("Not found (1):"), + "expected not-found header line, got:\n{output}" + ); + assert!( + output.contains("- nonexistent/path.txt"), + "expected not-found path bullet, got:\n{output}" + ); + } +} diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 6d30c19152001deaef5deeacbdf266e28ac03d08..f3302fb1894612287bf04acfbfa301188bf853fb 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -1,6 +1,7 @@ use agent_client_protocol as acp; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; +use futures::FutureExt as _; +use gpui::{App, AppContext, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,6 +9,7 @@ use std::{ path::{Path, PathBuf}, rc::Rc, sync::Arc, + time::Duration, }; use util::markdown::MarkdownInlineCode; @@ -25,13 +27,17 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// /// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. /// +/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs. +/// /// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. - command: String, + pub command: String, /// Working directory for the command. This must be one of the root directories of the project. - cd: String, + pub cd: String, + /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. + pub timeout_ms: Option, } pub struct TerminalTool { @@ -112,12 +118,30 @@ impl AgentTool for TerminalTool { .await?; let terminal_id = terminal.id(cx)?; - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]), - ..Default::default() - }); + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)), + ])); + + let timeout = input.timeout_ms.map(Duration::from_millis); + + let exit_status = match timeout { + Some(timeout) => { + let wait_for_exit = terminal.wait_for_exit(cx)?; + let timeout_task = cx.background_spawn(async move { + smol::Timer::after(timeout).await; + }); + + futures::select! { + status = wait_for_exit.clone().fuse() => status, + _ = timeout_task.fuse() => { + terminal.kill(cx)?; + wait_for_exit.await + } + } + } + None => terminal.wait_for_exit(cx)?.await, + }; - let exit_status = terminal.wait_for_exit(cx)?.await; let output = terminal.current_output(cx)?; Ok(process_content(output, &input.command, exit_status)) diff --git a/crates/agent/src/tools/thinking_tool.rs b/crates/agent/src/tools/thinking_tool.rs index 0a68f7545f81ce3202c110b1435d33b57adf409c..96024326f6f1610f500972b1a98be45258e3966b 100644 --- a/crates/agent/src/tools/thinking_tool.rs +++ b/crates/agent/src/tools/thinking_tool.rs @@ -43,10 +43,8 @@ impl AgentTool for ThinkingTool { event_stream: ToolCallEventStream, _cx: &mut App, ) -> Task> { - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![input.content.into()]), - ..Default::default() - }); + event_stream + .update_fields(acp::ToolCallUpdateFields::new().content(vec![input.content.into()])); Task::ready(Ok("Finished thinking.".to_string())) } } diff --git a/crates/agent/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs index 03e9db6601579e082e4d83de50f1999209d9f197..eb4ebacea2a8e48d6efa9032f46b336ca30c39b6 100644 --- a/crates/agent/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -76,10 +76,8 @@ impl AgentTool for WebSearchTool { let response = match search_task.await { Ok(response) => response, Err(err) => { - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some("Web Search Failed".to_string()), - ..Default::default() - }); + event_stream + .update_fields(acp::ToolCallUpdateFields::new().title("Web Search Failed")); return Err(err); } }; @@ -107,26 +105,23 @@ fn emit_update(response: &WebSearchResponse, event_stream: &ToolCallEventStream) } else { format!("{} results", response.results.len()) }; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(format!("Searched the web: {result_text}")), - content: Some( - response - .results - .iter() - .map(|result| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: result.title.clone(), - uri: result.url.clone(), - title: Some(result.title.clone()), - description: Some(result.text.clone()), - mime_type: None, - annotations: None, - size: None, - meta: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + event_stream.update_fields( + acp::ToolCallUpdateFields::new() + .title(format!("Searched the web: {result_text}")) + .content( + response + .results + .iter() + .map(|result| { + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(result.title.clone(), result.url.clone()) + .title(result.title.clone()) + .description(result.text.clone()), + ), + )) + }) + .collect::>(), + ), + ); } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index a44bdd1f22478e92ace192c939561f855c2814bd..e99855fe8a7241468e93f01fe6c7b6fee161f600 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,6 +9,8 @@ use futures::io::BufReader; use project::Project; use project::agent_server_store::AgentServerCommand; use serde::Deserialize; +use settings::Settings as _; +use task::ShellBuilder; use util::ResultExt as _; use std::path::PathBuf; @@ -21,7 +23,7 @@ use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntit use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; -use terminal::terminal_settings::{AlternateScroll, CursorShape}; +use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings}; #[derive(Debug, Error)] #[error("Unsupported version")] @@ -29,7 +31,7 @@ pub struct UnsupportedVersion; pub struct AcpConnection { server_name: SharedString, - telemetry_id: &'static str, + telemetry_id: SharedString, connection: Rc, sessions: Rc>>, auth_methods: Vec, @@ -54,7 +56,6 @@ pub struct AcpSession { pub async fn connect( server_name: SharedString, - telemetry_id: &'static str, command: AgentServerCommand, root_dir: &Path, default_mode: Option, @@ -64,7 +65,6 @@ pub async fn connect( ) -> Result> { let conn = AcpConnection::stdio( server_name, - telemetry_id, command.clone(), root_dir, default_mode, @@ -76,12 +76,11 @@ pub async fn connect( Ok(Rc::new(conn) as _) } -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; +const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1; impl AcpConnection { pub async fn stdio( server_name: SharedString, - telemetry_id: &'static str, command: AgentServerCommand, root_dir: &Path, default_mode: Option, @@ -89,9 +88,11 @@ impl AcpConnection { is_remote: bool, cx: &mut AsyncApp, ) -> Result { - let mut child = util::command::new_smol_command(&command.path); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let mut child = + builder.build_command(Some(command.path.display().to_string()), &command.args); child - .args(command.args.iter().map(|arg| arg.as_str())) .envs(command.env.iter().flatten()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -174,34 +175,38 @@ impl AcpConnection { })?; let response = connection - .initialize(acp::InitializeRequest { - protocol_version: acp::VERSION, - client_capabilities: acp::ClientCapabilities { - fs: acp::FileSystemCapability { - read_text_file: true, - write_text_file: true, - meta: None, - }, - terminal: true, - meta: Some(serde_json::json!({ - // Experimental: Allow for rendering terminal output from the agents - "terminal_output": true, - "terminal-auth": true, - })), - }, - client_info: Some(acp::Implementation { - name: "zed".to_owned(), - title: release_channel.map(|c| c.to_owned()), - version, - }), - meta: None, - }) + .initialize( + acp::InitializeRequest::new(acp::ProtocolVersion::V1) + .client_capabilities( + acp::ClientCapabilities::new() + .fs(acp::FileSystemCapability::new() + .read_text_file(true) + .write_text_file(true)) + .terminal(true) + // Experimental: Allow for rendering terminal output from the agents + .meta(acp::Meta::from_iter([ + ("terminal_output".into(), true.into()), + ("terminal-auth".into(), true.into()), + ])), + ) + .client_info( + acp::Implementation::new("zed", version) + .title(release_channel.map(ToOwned::to_owned)), + ), + ) .await?; if response.protocol_version < MINIMUM_SUPPORTED_VERSION { return Err(UnsupportedVersion.into()); } + let telemetry_id = response + .agent_info + // Use the one the agent provides if we have one + .map(|info| info.name.into()) + // Otherwise, just use the name + .unwrap_or_else(|| server_name.clone()); + Ok(Self { auth_methods: response.auth_methods, root_dir: root_dir.to_owned(), @@ -236,8 +241,8 @@ impl Drop for AcpConnection { } impl AgentConnection for AcpConnection { - fn telemetry_id(&self) -> &'static str { - self.telemetry_id + fn telemetry_id(&self) -> SharedString { + self.telemetry_id.clone() } fn new_thread( @@ -253,14 +258,13 @@ impl AgentConnection for AcpConnection { let default_model = self.default_model.clone(); let cwd = cwd.to_path_buf(); let context_server_store = project.read(cx).context_server_store().read(cx); - let mcp_servers = - if project.read(cx).is_local() { - context_server_store - .configured_server_ids() - .iter() - .filter_map(|id| { - let configuration = context_server_store.configuration_for_server(id)?; - match &*configuration { + let mcp_servers = if project.read(cx).is_local() { + context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + match &*configuration { project::context_server_store::ContextServerConfiguration::Custom { command, .. @@ -268,53 +272,47 @@ impl AgentConnection for AcpConnection { | project::context_server_store::ContextServerConfiguration::Extension { command, .. - } => Some(acp::McpServer::Stdio { - name: id.0.to_string(), - command: command.path.clone(), - args: command.args.clone(), - env: if let Some(env) = command.env.as_ref() { - env.iter() - .map(|(name, value)| acp::EnvVariable { - name: name.clone(), - value: value.clone(), - meta: None, - }) - .collect() - } else { - vec![] - }, - }), + } => Some(acp::McpServer::Stdio( + acp::McpServerStdio::new(id.0.to_string(), &command.path) + .args(command.args.clone()) + .env(if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable::new(name, value)) + .collect() + } else { + vec![] + }), + )), project::context_server_store::ContextServerConfiguration::Http { url, headers, - } => Some(acp::McpServer::Http { - name: id.0.to_string(), - url: url.to_string(), - headers: headers.iter().map(|(name, value)| acp::HttpHeader { - name: name.clone(), - value: value.clone(), - meta: None, - }).collect(), - }), + } => Some(acp::McpServer::Http( + acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers( + headers + .iter() + .map(|(name, value)| acp::HttpHeader::new(name, value)) + .collect(), + ), + )), } - }) - .collect() - } else { - // In SSH projects, the external agent is running on the remote - // machine, and currently we only run MCP servers on the local - // machine. So don't pass any MCP servers to the agent in that case. - Vec::new() - }; + }) + .collect() + } else { + // In SSH projects, the external agent is running on the remote + // machine, and currently we only run MCP servers on the local + // machine. So don't pass any MCP servers to the agent in that case. + Vec::new() + }; cx.spawn(async move |cx| { let response = conn - .new_session(acp::NewSessionRequest { mcp_servers, cwd, meta: None }) + .new_session(acp::NewSessionRequest::new(cwd).mcp_servers(mcp_servers)) .await .map_err(|err| { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + if err.code == acp::ErrorCode::AuthRequired { let mut error = AuthRequired::new(); - if err.message != acp::ErrorCode::AUTH_REQUIRED.message { + if err.message != acp::ErrorCode::AuthRequired.to_string() { error = error.with_description(err.message); } @@ -341,11 +339,7 @@ impl AgentConnection for AcpConnection { let modes = modes.clone(); let conn = conn.clone(); async move |_| { - let result = conn.set_session_mode(acp::SetSessionModeRequest { - session_id, - mode_id: default_mode, - meta: None, - }) + let result = conn.set_session_mode(acp::SetSessionModeRequest::new(session_id, default_mode)) .await.log_err(); if result.is_none() { @@ -388,11 +382,7 @@ impl AgentConnection for AcpConnection { let models = models.clone(); let conn = conn.clone(); async move |_| { - let result = conn.set_session_model(acp::SetSessionModelRequest { - session_id, - model_id: default_model, - meta: None, - }) + let result = conn.set_session_model(acp::SetSessionModelRequest::new(session_id, default_model)) .await.log_err(); if result.is_none() { @@ -456,12 +446,8 @@ impl AgentConnection for AcpConnection { fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { let conn = self.connection.clone(); cx.foreground_executor().spawn(async move { - conn.authenticate(acp::AuthenticateRequest { - method_id: method_id.clone(), - meta: None, - }) - .await?; - + conn.authenticate(acp::AuthenticateRequest::new(method_id)) + .await?; Ok(()) }) } @@ -488,11 +474,11 @@ impl AgentConnection for AcpConnection { match result { Ok(response) => Ok(response), Err(err) => { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + if err.code == acp::ErrorCode::AuthRequired { return Err(anyhow!(acp::Error::auth_required())); } - if err.code != ErrorCode::INTERNAL_ERROR.code { + if err.code != ErrorCode::InternalError { anyhow::bail!(err) } @@ -515,10 +501,7 @@ impl AgentConnection for AcpConnection { && (details.contains("This operation was aborted") || details.contains("The user aborted a request")) { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Cancelled, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)) } else { Err(anyhow!(details)) } @@ -535,10 +518,7 @@ impl AgentConnection for AcpConnection { session.suppress_abort_err = true; } let conn = self.connection.clone(); - let params = acp::CancelNotification { - session_id: session_id.clone(), - meta: None, - }; + let params = acp::CancelNotification::new(session_id.clone()); cx.foreground_executor() .spawn(async move { conn.cancel(params).await }) .detach(); @@ -619,11 +599,7 @@ impl acp_thread::AgentSessionModes for AcpSessionModes { let state = self.state.clone(); cx.foreground_executor().spawn(async move { let result = connection - .set_session_mode(acp::SetSessionModeRequest { - session_id, - mode_id, - meta: None, - }) + .set_session_mode(acp::SetSessionModeRequest::new(session_id, mode_id)) .await; if result.is_err() { @@ -682,11 +658,7 @@ impl acp_thread::AgentModelSelector for AcpModelSelector { let state = self.state.clone(); cx.foreground_executor().spawn(async move { let result = connection - .set_session_model(acp::SetSessionModelRequest { - session_id, - model_id, - meta: None, - }) + .set_session_model(acp::SetSessionModelRequest::new(session_id, model_id)) .await; if result.is_err() { @@ -748,10 +720,7 @@ impl acp::Client for ClientDelegate { let outcome = task.await; - Ok(acp::RequestPermissionResponse { - outcome, - meta: None, - }) + Ok(acp::RequestPermissionResponse::new(outcome)) } async fn write_text_file( @@ -783,10 +752,7 @@ impl acp::Client for ClientDelegate { let content = task.await?; - Ok(acp::ReadTextFileResponse { - content, - meta: None, - }) + Ok(acp::ReadTextFileResponse::new(content)) } async fn session_notification( @@ -821,7 +787,7 @@ impl acp::Client for ClientDelegate { if let Some(terminal_info) = meta.get("terminal_info") { if let Some(id_str) = terminal_info.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); + let terminal_id = acp::TerminalId::new(id_str); let cwd = terminal_info .get("cwd") .and_then(|v| v.as_str().map(PathBuf::from)); @@ -837,7 +803,7 @@ impl acp::Client for ClientDelegate { let lower = cx.new(|cx| builder.subscribe(cx)); thread.on_terminal_provider_event( TerminalProviderEvent::Created { - terminal_id: terminal_id.clone(), + terminal_id, label: tc.title.clone(), cwd, output_byte_limit: None, @@ -862,15 +828,12 @@ impl acp::Client for ClientDelegate { if let Some(meta) = &tcu.meta { if let Some(term_out) = meta.get("terminal_output") { if let Some(id_str) = term_out.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); + let terminal_id = acp::TerminalId::new(id_str); if let Some(s) = term_out.get("data").and_then(|v| v.as_str()) { let data = s.as_bytes().to_vec(); let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { thread.on_terminal_provider_event( - TerminalProviderEvent::Output { - terminal_id: terminal_id.clone(), - data, - }, + TerminalProviderEvent::Output { terminal_id, data }, cx, ); }); @@ -881,21 +844,24 @@ impl acp::Client for ClientDelegate { // terminal_exit if let Some(term_exit) = meta.get("terminal_exit") { if let Some(id_str) = term_exit.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); - let status = acp::TerminalExitStatus { - exit_code: term_exit - .get("exit_code") - .and_then(|v| v.as_u64()) - .map(|i| i as u32), - signal: term_exit - .get("signal") - .and_then(|v| v.as_str().map(|s| s.to_string())), - meta: None, - }; + let terminal_id = acp::TerminalId::new(id_str); + let status = acp::TerminalExitStatus::new() + .exit_code( + term_exit + .get("exit_code") + .and_then(|v| v.as_u64()) + .map(|i| i as u32), + ) + .signal( + term_exit + .get("signal") + .and_then(|v| v.as_str().map(|s| s.to_string())), + ); + let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { - terminal_id: terminal_id.clone(), + terminal_id, status, }, cx, @@ -932,7 +898,7 @@ impl acp::Client for ClientDelegate { // Register with renderer let terminal_entity = thread.update(&mut self.cx.clone(), |thread, cx| { thread.register_terminal_created( - acp::TerminalId(uuid::Uuid::new_v4().to_string().into()), + acp::TerminalId::new(uuid::Uuid::new_v4().to_string()), format!("{} {}", args.command, args.args.join(" ")), args.cwd.clone(), args.output_byte_limit, @@ -942,10 +908,7 @@ impl acp::Client for ClientDelegate { })?; let terminal_id = terminal_entity.read_with(&self.cx, |terminal, _| terminal.id().clone())?; - Ok(acp::CreateTerminalResponse { - terminal_id, - meta: None, - }) + Ok(acp::CreateTerminalResponse::new(terminal_id)) } async fn kill_terminal_command( @@ -1006,10 +969,7 @@ impl acp::Client for ClientDelegate { })?? .await; - Ok(acp::WaitForTerminalExitResponse { - exit_status, - meta: None, - }) + Ok(acp::WaitForTerminalExitResponse::new(exit_status)) } } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index cf03b71a78b358d7b110c450f769f9645094baaa..46e8508e44f07e4fb3d613e30387d5afd3f38423 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -56,7 +56,6 @@ impl AgentServerDelegate { pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; fn name(&self) -> SharedString; - fn telemetry_id(&self) -> &'static str; fn default_mode(&self, _cx: &mut App) -> Option { None } diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index ac79ab7484de90a84ce3d6720f54bcec6addc6b5..e67ddd5c0698758fdec7c7796b26a1351e9990e5 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -22,10 +22,6 @@ pub struct AgentServerLoginCommand { } impl AgentServer for ClaudeCode { - fn telemetry_id(&self) -> &'static str { - "claude-code" - } - fn name(&self) -> SharedString { "Claude Code".into() } @@ -41,7 +37,7 @@ impl AgentServer for ClaudeCode { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -62,7 +58,7 @@ impl AgentServer for ClaudeCode { settings .as_ref() - .and_then(|s| s.default_model.clone().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { @@ -83,7 +79,6 @@ impl AgentServer for ClaudeCode { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -108,7 +103,6 @@ impl AgentServer for ClaudeCode { .await?; let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index ec01cd4e523b5696b2f09b5e51e7137fcfb16c91..c2b308e48b7a984b0374272c0059286e933916b3 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -23,10 +23,6 @@ pub(crate) mod tests { } impl AgentServer for Codex { - fn telemetry_id(&self) -> &'static str { - "codex" - } - fn name(&self) -> SharedString { "Codex".into() } @@ -42,7 +38,7 @@ impl AgentServer for Codex { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -63,7 +59,7 @@ impl AgentServer for Codex { settings .as_ref() - .and_then(|s| s.default_model.clone().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { @@ -84,7 +80,6 @@ impl AgentServer for Codex { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -110,7 +105,6 @@ impl AgentServer for Codex { let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index e7625c2cc06095c9a24a2537e4e83bced26d73f3..6b981ce8b8198b275e5d9aa05b6fb66431d22e08 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,4 +1,4 @@ -use crate::{AgentServerDelegate, load_proxy_env}; +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; @@ -20,11 +20,7 @@ impl CustomAgentServer { } } -impl crate::AgentServer for CustomAgentServer { - fn telemetry_id(&self) -> &'static str { - "custom" - } - +impl AgentServer for CustomAgentServer { fn name(&self) -> SharedString { self.name.clone() } @@ -44,7 +40,7 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_mode().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -80,7 +76,7 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_model().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { @@ -112,14 +108,12 @@ impl crate::AgentServer for CustomAgentServer { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); - cx.spawn(async move |cx| { let (command, root_dir, login) = store .update(cx, |store, cx| { @@ -139,7 +133,6 @@ impl crate::AgentServer for CustomAgentServer { .await?; let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 824b999bdaff46cf3ad3a570b62fecd596612563..9db7535b5e55d88d6856774c20365bbac46fc81e 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -82,26 +82,9 @@ where .update(cx, |thread, cx| { thread.send( vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "Read the file ".into(), - annotations: None, - meta: None, - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: "foo.rs".into(), - name: "foo.rs".into(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - acp::ContentBlock::Text(acp::TextContent { - text: " and tell me what the content of the println! is".into(), - annotations: None, - meta: None, - }), + "Read the file ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new("foo.rs", "foo.rs")), + " and tell me what the content of the println! is".into(), ], cx, ) @@ -429,7 +412,7 @@ macro_rules! common_e2e_tests { async fn tool_call_with_permission(cx: &mut ::gpui::TestAppContext) { $crate::e2e_tests::test_tool_call_with_permission( $server, - ::agent_client_protocol::PermissionOptionId($allow_option_id.into()), + ::agent_client_protocol::PermissionOptionId::new($allow_option_id), cx, ) .await; diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index c1b2efb081551f82752dc15a909eec64ff78d94e..5fea74746aec73f3ea7bb33562244e4a6eea5ba7 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -12,10 +12,6 @@ use project::agent_server_store::GEMINI_NAME; pub struct Gemini; impl AgentServer for Gemini { - fn telemetry_id(&self) -> &'static str { - "gemini-cli" - } - fn name(&self) -> SharedString { "Gemini CLI".into() } @@ -31,7 +27,6 @@ impl AgentServer for Gemini { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -66,7 +61,6 @@ impl AgentServer for Gemini { let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 8ddcac24fe054d1226f2bbac49498fd35d6ed1c3..0d7163549f0a4b172773c9ac95dcbc84b7212667 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -12,6 +12,7 @@ workspace = true path = "src/agent_settings.rs" [dependencies] +agent-client-protocol.workspace = true anyhow.workspace = true cloud_llm_client.workspace = true collections.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 084ac7c3e7a1be4920126f857145e64b65a255dd..b513ec1a70b6f7ab02382dfa312ea2d4d6a47234 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -2,14 +2,15 @@ mod agent_profile; use std::sync::Arc; -use collections::IndexMap; +use agent_client_protocol::ModelId; +use collections::{HashSet, IndexMap}; use gpui::{App, Pixels, px}; use language_model::LanguageModel; use project::DisableAiSettings; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ - DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, + DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, NotifyWhenAgentWaiting, RegisterSetting, Settings, }; @@ -24,13 +25,16 @@ pub struct AgentSettings { pub enabled: bool, pub button: bool, pub dock: DockPosition, + pub agents_panel_dock: DockSide, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, pub inline_assistant_model: Option, + pub inline_assistant_use_streaming_tools: bool, pub commit_message_model: Option, pub thread_summary_model: Option, pub inline_alternatives: Vec, + pub favorite_models: Vec, pub default_profile: AgentProfileId, pub default_view: DefaultAgentView, pub profiles: IndexMap, @@ -94,6 +98,13 @@ impl AgentSettings { pub fn set_message_editor_max_lines(&self) -> usize { self.message_editor_min_lines * 2 } + + pub fn favorite_model_ids(&self) -> HashSet { + self.favorite_models + .iter() + .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model))) + .collect() + } } #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] @@ -151,13 +162,18 @@ impl Settings for AgentSettings { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), + agents_panel_dock: agent.agents_panel_dock.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), default_model: Some(agent.default_model.unwrap()), inline_assistant_model: agent.inline_assistant_model, + inline_assistant_use_streaming_tools: agent + .inline_assistant_use_streaming_tools + .unwrap_or(true), commit_message_model: agent.commit_message_model, thread_summary_model: agent.thread_summary_model, inline_alternatives: agent.inline_alternatives.unwrap_or_default(), + favorite_models: agent.favorite_models, default_profile: AgentProfileId(agent.default_profile.unwrap()), default_view: agent.default_view.unwrap(), profiles: agent diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 0f52c07078f447c9d8a95312ccd96561516907a1..8a9633e578a85323f2a289bd83c169a1f5d7f272 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,7 +13,8 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = ["gpui/test-support", "language/test-support"] +test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"] +unit-eval = [] [dependencies] acp_thread.workspace = true @@ -39,6 +40,7 @@ component.workspace = true context_server.workspace = true db.workspace = true editor.workspace = true +eval_utils = { workspace = true, optional = true } extension.workspace = true extension_host.workspace = true feature_flags.workspace = true @@ -47,6 +49,7 @@ fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +gpui_tokio.workspace = true html_to_markdown.workspace = true http_client.workspace = true indoc.workspace = true @@ -69,6 +72,7 @@ postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true +rand.workspace = true release_channel.workspace = true rope.workspace = true rules_library.workspace = true @@ -82,7 +86,6 @@ smol.workspace = true streaming_diff.workspace = true task.workspace = true telemetry.workspace = true -telemetry_events.workspace = true terminal.workspace = true terminal_view.workspace = true text.workspace = true @@ -93,19 +96,23 @@ ui.workspace = true ui_input.workspace = true url.workspace = true util.workspace = true +uuid.workspace = true watch.workspace = true workspace.workspace = true zed_actions.workspace = true image.workspace = true async-fs.workspace = true +reqwest_client = { workspace = true, optional = true } [dev-dependencies] acp_thread = { workspace = true, features = ["test-support"] } agent = { workspace = true, features = ["test-support"] } assistant_text_thread = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } +clock.workspace = true db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +eval_utils.workspace = true gpui = { workspace = true, "features" = ["test-support"] } indoc.workspace = true language = { workspace = true, "features" = ["test-support"] } @@ -114,6 +121,6 @@ language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } semver.workspace = true -rand.workspace = true +reqwest_client.workspace = true tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 6fb94dfb6b84826d715e9b28163e9968fc2df3b9..feae74a86bc241c5d2e01f0941eafc60210f1bf6 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -22,7 +22,7 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; pub struct EntryViewState { workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, entries: Vec, @@ -34,7 +34,7 @@ pub struct EntryViewState { impl EntryViewState { pub fn new( workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, @@ -328,7 +328,7 @@ impl Entry { fn create_terminal( workspace: WeakEntity, - project: Entity, + project: WeakEntity, terminal: Entity, window: &mut Window, cx: &mut App, @@ -336,9 +336,9 @@ fn create_terminal( cx.new(|cx| { let mut view = TerminalView::new( terminal.read(cx).inner().clone(), - workspace.clone(), + workspace, None, - project.downgrade(), + project, window, cx, ); @@ -432,24 +432,11 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let tool_call = acp::ToolCall { - id: acp::ToolCallId("tool".into()), - title: "Tool call".into(), - kind: acp::ToolKind::Other, - status: acp::ToolCallStatus::InProgress, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/hello.txt".into(), - old_text: Some("hi world".into()), - new_text: "hello world".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; + let tool_call = acp::ToolCall::new("tool", "Tool call") + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/hello.txt", "hello world").old_text("hi world"), + )]); let connection = Rc::new(StubAgentConnection::new()); let thread = cx .update(|_, cx| { @@ -471,7 +458,7 @@ mod tests { let view_state = cx.new(|_cx| { EntryViewState::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store, None, Default::default(), diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 169220a3614bf2d74d24a9638f87b9613a556bd6..6bed82accf876aaaba0668d366216c3a965ad8cb 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -21,8 +21,8 @@ use editor::{ }; use futures::{FutureExt as _, future::join_all}; use gpui::{ - AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, KeyContext, - SharedString, Subscription, Task, TextStyle, WeakEntity, + AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, + KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, Language, language_settings::InlayHintKind}; use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; @@ -34,12 +34,11 @@ use theme::ThemeSettings; use ui::prelude::*; use util::{ResultExt, debug_panic}; use workspace::{CollaboratorId, Workspace}; -use zed_actions::agent::Chat; +use zed_actions::agent::{Chat, PasteRaw}; pub struct MessageEditor { mention_set: Entity, editor: Entity, - project: Entity, workspace: WeakEntity, prompt_capabilities: Rc>, available_commands: Rc>>, @@ -98,7 +97,7 @@ impl PromptCompletionProviderDelegate for Entity { impl MessageEditor { pub fn new( workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, @@ -124,6 +123,7 @@ impl MessageEditor { let mut editor = Editor::new(mode, buffer, None, window, cx); editor.set_placeholder_text(placeholder, window, cx); editor.set_show_indent_guides(false, cx); + editor.set_show_completions_on_input(Some(true)); editor.set_soft_wrap(); editor.set_use_modal_editing(true); editor.set_context_menu_options(ContextMenuOptions { @@ -134,13 +134,8 @@ impl MessageEditor { editor.register_addon(MessageEditorAddon::new()); editor }); - let mention_set = cx.new(|_cx| { - MentionSet::new( - project.downgrade(), - history_store.clone(), - prompt_store.clone(), - ) - }); + let mention_set = + cx.new(|_cx| MentionSet::new(project, history_store.clone(), prompt_store.clone())); let completion_provider = Rc::new(PromptCompletionProvider::new( cx.entity(), editor.downgrade(), @@ -198,7 +193,6 @@ impl MessageEditor { Self { editor, - project, mention_set, workspace, prompt_capabilities, @@ -225,8 +219,13 @@ impl MessageEditor { .iter() .find(|command| command.name == command_name)?; - let acp::AvailableCommandInput::Unstructured { mut hint } = - available_command.input.clone()?; + let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput { + mut hint, + .. + }) = available_command.input.clone()? + else { + return None; + }; let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize; if hint_pos > snapshot.len() { @@ -403,34 +402,27 @@ impl MessageEditor { } => { all_tracked_buffers.extend(tracked_buffers.iter().cloned()); if supports_embedded_context { - acp::ContentBlock::Resource(acp::EmbeddedResource { - annotations: None, - resource: - acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - mime_type: None, - text: content.clone(), - uri: uri.to_uri().to_string(), - meta: None, - }, + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new( + content.clone(), + uri.to_uri().to_string(), ), - meta: None, - }) + ), + )) } else { - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: uri.name(), - uri: uri.to_uri().to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }) + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )) } } - Mention::Image(mention_image) => { - let uri = match uri { + Mention::Image(mention_image) => acp::ContentBlock::Image( + acp::ImageContent::new( + mention_image.data.clone(), + mention_image.format.mime_type(), + ) + .uri(match uri { MentionUri::File { .. } => Some(uri.to_uri().to_string()), MentionUri::PastedImage => None, other => { @@ -440,25 +432,11 @@ impl MessageEditor { ); None } - }; - acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data: mention_image.data.to_string(), - mime_type: mention_image.format.mime_type().into(), - uri, - meta: None, - }) - } - Mention::Link => acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: uri.name(), - uri: uri.to_uri().to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), + }), + ), + Mention::Link => acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()), + ), }; chunks.push(chunk); ix = crease_range.end.0; @@ -565,6 +543,142 @@ impl MessageEditor { } fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let editor_clipboard_selections = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + ClipboardEntry::String(text) => { + text.metadata_json::>() + } + _ => None, + }); + + // Insert creases for pasted clipboard selections that: + // 1. Contain exactly one selection + // 2. Have an associated file path + // 3. Span multiple lines (not single-line selections) + // 4. Belong to a file that exists in the current project + let should_insert_creases = util::maybe!({ + let selections = editor_clipboard_selections.as_ref()?; + if selections.len() > 1 { + return Some(false); + } + let selection = selections.first()?; + let file_path = selection.file_path.as_ref()?; + let line_range = selection.line_range.as_ref()?; + + if line_range.start() == line_range.end() { + return Some(false); + } + + Some( + workspace + .read(cx) + .project() + .read(cx) + .project_path_for_absolute_path(file_path, cx) + .is_some(), + ) + }) + .unwrap_or(false); + + if should_insert_creases && let Some(selections) = editor_clipboard_selections { + cx.stop_propagation(); + let insertion_target = self + .editor + .read(cx) + .selections + .newest_anchor() + .start + .text_anchor; + + let project = workspace.read(cx).project().clone(); + for selection in selections { + if let (Some(file_path), Some(line_range)) = + (selection.file_path, selection.line_range) + { + let crease_text = + acp_thread::selection_name(Some(file_path.as_ref()), &line_range); + + let mention_uri = MentionUri::Selection { + abs_path: Some(file_path.clone()), + line_range: line_range.clone(), + }; + + let mention_text = mention_uri.as_link().to_string(); + let (excerpt_id, text_anchor, content_len) = + self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx); + let snapshot = buffer.snapshot(cx); + let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap(); + let text_anchor = insertion_target.bias_left(&buffer_snapshot); + + editor.insert(&mention_text, window, cx); + editor.insert(" ", window, cx); + + (*excerpt_id, text_anchor, mention_text.len()) + }); + + let Some((crease_id, tx)) = insert_crease_for_mention( + excerpt_id, + text_anchor, + content_len, + crease_text.into(), + mention_uri.icon_path(cx), + None, + self.editor.clone(), + window, + cx, + ) else { + continue; + }; + drop(tx); + + let mention_task = cx + .spawn({ + let project = project.clone(); + async move |_, cx| { + let project_path = project + .update(cx, |project, cx| { + project.project_path_for_absolute_path(&file_path, cx) + }) + .map_err(|e| e.to_string())? + .ok_or_else(|| "project path not found".to_string())?; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + + buffer + .update(cx, |buffer, cx| { + let start = Point::new(*line_range.start(), 0) + .min(buffer.max_point()); + let end = Point::new(*line_range.end() + 1, 0) + .min(buffer.max_point()); + let content = buffer.text_for_range(start..end).collect(); + Mention::Text { + content, + tracked_buffers: vec![cx.entity()], + } + }) + .map_err(|e| e.to_string()) + } + }) + .shared(); + + self.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task) + }); + } + } + return; + } + if self.prompt_capabilities.borrow().image && let Some(task) = paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) @@ -573,6 +687,13 @@ impl MessageEditor { } } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { + let editor = self.editor.clone(); + window.defer(cx, move |window, cx| { + editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx)); + }); + } + pub fn insert_dragged_files( &mut self, paths: Vec, @@ -583,17 +704,18 @@ impl MessageEditor { let Some(workspace) = self.workspace.upgrade() else { return; }; - let path_style = self.project.read(cx).path_style(cx); + let project = workspace.read(cx).project().clone(); + let path_style = project.read(cx).path_style(cx); let buffer = self.editor.read(cx).buffer().clone(); let Some(buffer) = buffer.read(cx).as_singleton() else { return; }; let mut tasks = Vec::new(); for path in paths { - let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else { + let Some(entry) = project.read(cx).entry_for_path(&path, cx) else { continue; }; - let Some(worktree) = self.project.read(cx).worktree_for_id(path.worktree_id, cx) else { + let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else { continue; }; let abs_path = worktree.read(cx).absolutize(&path.path); @@ -701,9 +823,13 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + self.clear(window, cx); - let path_style = self.project.read(cx).path_style(cx); + let path_style = workspace.read(cx).project().read(cx).path_style(cx); let mut text = String::new(); let mut mentions = Vec::new(); @@ -746,8 +872,7 @@ impl MessageEditor { uri, data, mime_type, - annotations: _, - meta: _, + .. }) => { let mention_uri = if let Some(uri) = uri { MentionUri::parse(&uri, path_style) @@ -773,7 +898,7 @@ impl MessageEditor { }), )); } - acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {} + _ => {} } } @@ -846,6 +971,7 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::chat)) .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() .child({ @@ -947,7 +1073,7 @@ mod tests { cx.new(|cx| { MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -1058,7 +1184,7 @@ mod tests { cx.new(|cx| { MessageEditor::new( workspace_handle.clone(), - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -1092,12 +1218,7 @@ mod tests { assert!(error_message.contains("Available commands: none")); // Now simulate Claude providing its list of available commands (which doesn't include file) - available_commands.replace(vec![acp::AvailableCommand { - name: "help".to_string(), - description: "Get help".to_string(), - input: None, - meta: None, - }]); + available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]); // Test that unsupported slash commands trigger an error when we have a list of available commands editor.update_in(cx, |editor, window, cx| { @@ -1211,20 +1332,12 @@ mod tests { let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ - acp::AvailableCommand { - name: "quick-math".to_string(), - description: "2 + 2 = 4 - 1 = 3".to_string(), - input: None, - meta: None, - }, - acp::AvailableCommand { - name: "say-hello".to_string(), - description: "Say hello to whoever you want".to_string(), - input: Some(acp::AvailableCommandInput::Unstructured { - hint: "".to_string(), - }), - meta: None, - }, + acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), + acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input( + acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new( + "", + )), + ), ])); let editor = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -1232,7 +1345,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -1257,7 +1370,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); message_editor.read(cx).editor().clone() }); @@ -1423,7 +1536,7 @@ mod tests { rel_path("b/eight.txt"), ]; - let slash = PathStyle::local().separator(); + let slash = PathStyle::local().primary_separator(); let mut opened_editors = Vec::new(); for path in paths { @@ -1454,7 +1567,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -1479,7 +1592,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); let editor = message_editor.read(cx).editor().clone(); (message_editor, editor) }); @@ -1504,12 +1617,12 @@ mod tests { editor.set_text("", window, cx); }); - prompt_capabilities.replace(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }); + prompt_capabilities.replace( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ); cx.simulate_input("Lorem "); @@ -1945,7 +2058,7 @@ mod tests { cx.new(|cx| { let editor = MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -1960,11 +2073,9 @@ mod tests { cx, ); // Enable embedded context so files are actually included - editor.prompt_capabilities.replace(acp::PromptCapabilities { - embedded_context: true, - meta: None, - ..Default::default() - }); + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)); editor }) }); @@ -2043,7 +2154,7 @@ mod tests { // Create a thread metadata to insert as summary let thread_metadata = agent::DbThreadMetadata { - id: acp::SessionId("thread-123".into()), + id: acp::SessionId::new("thread-123"), title: "Previous Conversation".into(), updated_at: chrono::Utc::now(), }; @@ -2052,7 +2163,7 @@ mod tests { cx.new(|cx| { let mut editor = MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -2121,7 +2232,7 @@ mod tests { cx.new(|cx| { MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -2150,14 +2261,7 @@ mod tests { .await .unwrap(); - assert_eq!( - content, - vec![acp::ContentBlock::Text(acp::TextContent { - text: "してhello world".into(), - annotations: None, - meta: None - })] - ); + assert_eq!(content, vec!["してhello world".into()]); } #[gpui::test] @@ -2191,7 +2295,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -2216,7 +2320,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); let editor = message_editor.read(cx).editor().clone(); (message_editor, editor) }); @@ -2236,38 +2340,24 @@ mod tests { .0; let main_rs_uri = if cfg!(windows) { - "file:///C:/project/src/main.rs".to_string() + "file:///C:/project/src/main.rs" } else { - "file:///project/src/main.rs".to_string() + "file:///project/src/main.rs" }; // When embedded context is `false` we should get a resource link pretty_assertions::assert_eq!( content, vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "What is in ".to_string(), - annotations: None, - meta: None - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: main_rs_uri.clone(), - name: "main.rs".to_string(), - annotations: None, - meta: None, - description: None, - mime_type: None, - size: None, - title: None, - }) + "What is in ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri)) ] ); message_editor.update(cx, |editor, _cx| { - editor.prompt_capabilities.replace(acp::PromptCapabilities { - embedded_context: true, - ..Default::default() - }) + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)) }); let content = message_editor @@ -2280,23 +2370,12 @@ mod tests { pretty_assertions::assert_eq!( content, vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "What is in ".to_string(), - annotations: None, - meta: None - }), - acp::ContentBlock::Resource(acp::EmbeddedResource { - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - text: file_content.to_string(), - uri: main_rs_uri, - mime_type: None, - meta: None - } - ), - annotations: None, - meta: None - }) + "What is in ".into(), + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(file_content, main_rs_uri) + ) + )) ] ); } @@ -2374,7 +2453,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs index 2db031cafeb8a66e43120be9766debe3c16eb2d0..22af75a6e96edc4f597819e04e2e84b80ba0417a 100644 --- a/crates/agent_ui/src/acp/mode_selector.rs +++ b/crates/agent_ui/src/acp/mode_selector.rs @@ -161,7 +161,7 @@ impl Render for ModeSelector { .map(|mode| mode.name.clone()) .unwrap_or_else(|| "Unknown".into()); - let this = cx.entity(); + let this = cx.weak_entity(); let icon = if self.menu_handle.is_deployed() { IconName::ChevronUp @@ -188,25 +188,25 @@ impl Render for ModeSelector { .gap_1() .child( h_flex() - .pb_1() .gap_2() .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child(Label::new("Cycle Through Modes")) + .child(Label::new("Toggle Mode Menu")) .child(KeyBinding::for_action_in( - &CycleModeSelector, + &ToggleProfileSelector, &focus_handle, cx, )), ) .child( h_flex() + .pb_1() .gap_2() .justify_between() - .child(Label::new("Toggle Mode Menu")) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Cycle Through Modes")) .child(KeyBinding::for_action_in( - &ToggleProfileSelector, + &CycleModeSelector, &focus_handle, cx, )), @@ -222,7 +222,8 @@ impl Render for ModeSelector { y: px(-2.0), }) .menu(move |window, cx| { - Some(this.update(cx, |this, cx| this.build_context_menu(window, cx))) + this.update(cx, |this, cx| this.build_context_menu(window, cx)) + .ok() }) } } diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 8a0c3c9df90e73d0ef00ecd7232115729dd35347..903d5fe425d99389aae0e2a8028d9a31b986fbb3 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -1,25 +1,26 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; -use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector}; +use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector}; +use agent_client_protocol::ModelId; use agent_servers::AgentServer; +use agent_settings::AgentSettings; use anyhow::Result; -use collections::IndexMap; +use collections::{HashSet, IndexMap}; use fs::Fs; use futures::FutureExt; use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{ Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity, }; +use itertools::Itertools; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use ui::{ - DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, KeyBinding, ListItem, - ListItemSpacing, prelude::*, -}; +use settings::Settings; +use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*}; use util::ResultExt; use zed_actions::agent::OpenSettings; -use crate::ui::HoldForDefault; +use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}; pub type AcpModelSelector = Picker; @@ -41,7 +42,7 @@ pub fn acp_model_selector( enum AcpModelPickerEntry { Separator(SharedString), - Model(AgentModelInfo), + Model(AgentModelInfo, bool), } pub struct AcpModelPickerDelegate { @@ -118,6 +119,67 @@ impl AcpModelPickerDelegate { pub fn active_model(&self) -> Option<&AgentModelInfo> { self.selected_model.as_ref() } + + pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context>) { + if !self.selector.supports_favorites() { + return; + } + + let favorites = AgentSettings::get_global(cx).favorite_model_ids(); + + if favorites.is_empty() { + return; + } + + let Some(models) = self.models.clone() else { + return; + }; + + let all_models: Vec = match models { + AgentModelList::Flat(list) => list, + AgentModelList::Grouped(index_map) => index_map + .into_values() + .flatten() + .collect::>(), + }; + + let favorite_models = all_models + .iter() + .filter(|model| favorites.contains(&model.id)) + .unique_by(|model| &model.id) + .cloned() + .collect::>(); + + let current_id = self.selected_model.as_ref().map(|m| m.id.clone()); + + let current_index_in_favorites = current_id + .as_ref() + .and_then(|id| favorite_models.iter().position(|m| &m.id == id)) + .unwrap_or(usize::MAX); + + let next_index = if current_index_in_favorites == usize::MAX { + 0 + } else { + (current_index_in_favorites + 1) % favorite_models.len() + }; + + let next_model = favorite_models[next_index].clone(); + + self.selector + .select_model(next_model.id.clone(), cx) + .detach_and_log_err(cx); + + self.selected_model = Some(next_model); + + // Keep the picker selection aligned with the newly-selected model + if let Some(new_index) = self.filtered_entries.iter().position(|entry| { + matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id)) + }) { + self.set_selected_index(new_index, window, cx); + } else { + cx.notify(); + } + } } impl PickerDelegate for AcpModelPickerDelegate { @@ -143,7 +205,7 @@ impl PickerDelegate for AcpModelPickerDelegate { _cx: &mut Context>, ) -> bool { match self.filtered_entries.get(ix) { - Some(AcpModelPickerEntry::Model(_)) => true, + Some(AcpModelPickerEntry::Model(_, _)) => true, Some(AcpModelPickerEntry::Separator(_)) | None => false, } } @@ -158,6 +220,12 @@ impl PickerDelegate for AcpModelPickerDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { + let favorites = if self.selector.supports_favorites() { + AgentSettings::get_global(cx).favorite_model_ids() + } else { + Default::default() + }; + cx.spawn_in(window, async move |this, cx| { let filtered_models = match this .read_with(cx, |this, cx| { @@ -174,7 +242,7 @@ impl PickerDelegate for AcpModelPickerDelegate { this.update_in(cx, |this, window, cx| { this.delegate.filtered_entries = - info_list_to_picker_entries(filtered_models).collect(); + info_list_to_picker_entries(filtered_models, &favorites); // Finds the currently selected model in the list let new_index = this .delegate @@ -182,7 +250,7 @@ impl PickerDelegate for AcpModelPickerDelegate { .as_ref() .and_then(|selected| { this.delegate.filtered_entries.iter().position(|entry| { - if let AcpModelPickerEntry::Model(model_info) = entry { + if let AcpModelPickerEntry::Model(model_info, _) = entry { model_info.id == selected.id } else { false @@ -198,7 +266,7 @@ impl PickerDelegate for AcpModelPickerDelegate { } fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - if let Some(AcpModelPickerEntry::Model(model_info)) = + if let Some(AcpModelPickerEntry::Model(model_info, _)) = self.filtered_entries.get(self.selected_index) { if window.modifiers().secondary() { @@ -241,75 +309,60 @@ impl PickerDelegate for AcpModelPickerDelegate { cx: &mut Context>, ) -> Option { match self.filtered_entries.get(ix)? { - AcpModelPickerEntry::Separator(title) => Some( - div() - .px_2() - .pb_1() - .when(ix > 1, |this| { - this.mt_1() - .pt_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - Label::new(title) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - ), - AcpModelPickerEntry::Model(model_info) => { + AcpModelPickerEntry::Separator(title) => { + Some(ModelSelectorHeader::new(title, ix > 1).into_any_element()) + } + AcpModelPickerEntry::Model(model_info, is_favorite) => { let is_selected = Some(model_info) == self.selected_model.as_ref(); let default_model = self.agent_server.default_model(cx); let is_default = default_model.as_ref() == Some(&model_info.id); - let model_icon_color = if is_selected { - Color::Accent - } else { - Color::Muted + let supports_favorites = self.selector.supports_favorites(); + + let is_favorite = *is_favorite; + let handle_action_click = { + let model_id = model_info.id.clone(); + let fs = self.fs.clone(); + + move |cx: &App| { + crate::favorite_models::toggle_model_id_in_settings( + model_id.clone(), + !is_favorite, + fs.clone(), + cx, + ); + } }; Some( div() .id(("model-picker-menu-child", ix)) .when_some(model_info.description.clone(), |this, description| { - this - .on_hover(cx.listener(move |menu, hovered, _, cx| { - if *hovered { - menu.delegate.selected_description = Some((ix, description.clone(), is_default)); - } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) { - menu.delegate.selected_description = None; - } - cx.notify(); - })) + this.on_hover(cx.listener(move |menu, hovered, _, cx| { + if *hovered { + menu.delegate.selected_description = + Some((ix, description.clone(), is_default)); + } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) { + menu.delegate.selected_description = None; + } + cx.notify(); + })) }) .child( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_1p5() - .when_some(model_info.icon, |this, icon| { - this.child( - Icon::new(icon) - .color(model_icon_color) - .size(IconSize::Small) - ) - }) - .child(Label::new(model_info.name.clone()).truncate()), - ) - .end_slot(div().pr_3().when(is_selected, |this| { - this.child( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - })), + ModelSelectorListItem::new(ix, model_info.name.clone()) + .map(|this| match &model_info.icon { + Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()), + Some(AgentModelIcon::Named(icon)) => this.icon(*icon), + None => this, + }) + .is_selected(is_selected) + .is_focused(selected) + .when(supports_favorites, |this| { + this.is_favorite(is_favorite) + .on_toggle_favorite(handle_action_click) + }), ) - .into_any_element() + .into_any_element(), ) } } @@ -343,7 +396,7 @@ impl PickerDelegate for AcpModelPickerDelegate { fn render_footer( &self, _window: &mut Window, - cx: &mut Context>, + _cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); @@ -351,43 +404,57 @@ impl PickerDelegate for AcpModelPickerDelegate { return None; } - Some( - h_flex() - .w_full() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenSettings.boxed_clone(), cx); - }), - ) - .into_any(), - ) + Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element()) } } fn info_list_to_picker_entries( model_list: AgentModelList, -) -> impl Iterator { + favorites: &HashSet, +) -> Vec { + let mut entries = Vec::new(); + + let all_models: Vec<_> = match &model_list { + AgentModelList::Flat(list) => list.iter().collect(), + AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(), + }; + + let favorite_models: Vec<_> = all_models + .iter() + .filter(|m| favorites.contains(&m.id)) + .unique_by(|m| &m.id) + .collect(); + + let has_favorites = !favorite_models.is_empty(); + if has_favorites { + entries.push(AcpModelPickerEntry::Separator("Favorite".into())); + for model in favorite_models { + entries.push(AcpModelPickerEntry::Model((*model).clone(), true)); + } + } + match model_list { AgentModelList::Flat(list) => { - itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model)) + if has_favorites { + entries.push(AcpModelPickerEntry::Separator("All".into())); + } + for model in list { + let is_favorite = favorites.contains(&model.id); + entries.push(AcpModelPickerEntry::Model(model, is_favorite)); + } } AgentModelList::Grouped(index_map) => { - itertools::Either::Right(index_map.into_iter().flat_map(|(group_name, models)| { - std::iter::once(AcpModelPickerEntry::Separator(group_name.0)) - .chain(models.into_iter().map(AcpModelPickerEntry::Model)) - })) + for (group_name, models) in index_map { + entries.push(AcpModelPickerEntry::Separator(group_name.0)); + for model in models { + let is_favorite = favorites.contains(&model.id); + entries.push(AcpModelPickerEntry::Model(model, is_favorite)); + } + } } } + + entries } async fn fuzzy_search( @@ -403,9 +470,7 @@ async fn fuzzy_search( let candidates = model_list .iter() .enumerate() - .map(|(ix, model)| { - StringMatchCandidate::new(ix, &format!("{}/{}", model.id, model.name)) - }) + .map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref())) .collect::>(); let mut matches = match_strings( &candidates, @@ -464,7 +529,7 @@ mod tests { models .into_iter() .map(|model| acp_thread::AgentModelInfo { - id: acp::ModelId(model.to_string().into()), + id: acp::ModelId::new(model.to_string()), name: model.to_string().into(), description: None, icon: None, @@ -511,6 +576,168 @@ mod tests { } } + fn create_favorites(models: Vec<&str>) -> HashSet { + models + .into_iter() + .map(|m| ModelId::new(m.to_string())) + .collect() + } + + fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> { + entries + .iter() + .filter_map(|entry| match entry { + AcpModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()), + _ => None, + }) + .collect() + } + + fn get_entry_labels(entries: &[AcpModelPickerEntry]) -> Vec<&str> { + entries + .iter() + .map(|entry| match entry { + AcpModelPickerEntry::Model(info, _) => info.id.0.as_ref(), + AcpModelPickerEntry::Separator(s) => &s, + }) + .collect() + } + + #[gpui::test] + fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ("zed", vec!["zed/claude", "zed/gemini"]), + ("openai", vec!["openai/gpt-5"]), + ]); + let favorites = create_favorites(vec!["zed/gemini"]); + + let entries = info_list_to_picker_entries(models, &favorites); + + assert!(matches!( + entries.first(), + Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite" + )); + + let model_ids = get_entry_model_ids(&entries); + assert_eq!(model_ids[0], "zed/gemini"); + } + + #[gpui::test] + fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) { + let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]); + let favorites = create_favorites(vec![]); + + let entries = info_list_to_picker_entries(models, &favorites); + + assert!(matches!( + entries.first(), + Some(AcpModelPickerEntry::Separator(s)) if s == "zed" + )); + } + + #[gpui::test] + fn test_models_have_correct_actions(_cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ("zed", vec!["zed/claude", "zed/gemini"]), + ("openai", vec!["openai/gpt-5"]), + ]); + let favorites = create_favorites(vec!["zed/claude"]); + + let entries = info_list_to_picker_entries(models, &favorites); + + for entry in &entries { + if let AcpModelPickerEntry::Model(info, is_favorite) = entry { + if info.id.0.as_ref() == "zed/claude" { + assert!(is_favorite, "zed/claude should be a favorite"); + } else { + assert!(!is_favorite, "{} should not be a favorite", info.id.0); + } + } + } + } + + #[gpui::test] + fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ("zed", vec!["zed/claude", "zed/gemini"]), + ("openai", vec!["openai/gpt-5", "openai/gpt-4"]), + ]); + let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]); + + let entries = info_list_to_picker_entries(models, &favorites); + let model_ids = get_entry_model_ids(&entries); + + assert_eq!(model_ids[0], "zed/gemini"); + assert_eq!(model_ids[1], "openai/gpt-5"); + + assert!(model_ids[2..].contains(&"zed/gemini")); + assert!(model_ids[2..].contains(&"openai/gpt-5")); + } + + #[gpui::test] + fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ("Recommended", vec!["zed/claude", "anthropic/claude"]), + ("Zed", vec!["zed/claude", "zed/gpt-5"]), + ("Antropic", vec!["anthropic/claude"]), + ("OpenAI", vec!["openai/gpt-5"]), + ]); + + let favorites = create_favorites(vec!["zed/claude"]); + + let entries = info_list_to_picker_entries(models, &favorites); + let labels = get_entry_labels(&entries); + + assert_eq!( + labels, + vec![ + "Favorite", + "zed/claude", + "Recommended", + "zed/claude", + "anthropic/claude", + "Zed", + "zed/claude", + "zed/gpt-5", + "Antropic", + "anthropic/claude", + "OpenAI", + "openai/gpt-5" + ] + ); + } + + #[gpui::test] + fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) { + let models = AgentModelList::Flat(vec![ + acp_thread::AgentModelInfo { + id: acp::ModelId::new("zed/claude".to_string()), + name: "Claude".into(), + description: None, + icon: None, + }, + acp_thread::AgentModelInfo { + id: acp::ModelId::new("zed/gemini".to_string()), + name: "Gemini".into(), + description: None, + icon: None, + }, + ]); + let favorites = create_favorites(vec!["zed/gemini"]); + + let entries = info_list_to_picker_entries(models, &favorites); + + assert!(matches!( + entries.first(), + Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite" + )); + + assert!(entries.iter().any(|e| matches!( + e, + AcpModelPickerEntry::Separator(s) if s == "All" + ))); + } + #[gpui::test] async fn test_fuzzy_match(cx: &mut TestAppContext) { let models = create_model_list(vec![ diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs index e2393c11bd6c23b79397abf274fb6539c0c7063f..a15c01445dd8e9845f6744e795ed90a1ede6c7fc 100644 --- a/crates/agent_ui/src/acp/model_selector_popover.rs +++ b/crates/agent_ui/src/acp/model_selector_popover.rs @@ -1,17 +1,17 @@ use std::rc::Rc; use std::sync::Arc; -use acp_thread::{AgentModelInfo, AgentModelSelector}; +use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector}; use agent_servers::AgentServer; +use agent_settings::AgentSettings; use fs::Fs; use gpui::{Entity, FocusHandle}; use picker::popover_menu::PickerPopoverMenu; -use ui::{ - ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window, - prelude::*, -}; +use settings::Settings as _; +use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; use zed_actions::agent::ToggleModelSelector; +use crate::CycleFavoriteModels; use crate::acp::{AcpModelSelector, model_selector::acp_model_selector}; pub struct AcpModelSelectorPopover { @@ -54,6 +54,12 @@ impl AcpModelSelectorPopover { pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> { self.selector.read(cx).delegate.active_model() } + + pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context) { + self.selector.update(cx, |selector, cx| { + selector.delegate.cycle_favorite_models(window, cx); + }); + } } impl Render for AcpModelSelectorPopover { @@ -64,7 +70,7 @@ impl Render for AcpModelSelectorPopover { .map(|model| model.name.clone()) .unwrap_or_else(|| SharedString::from("Select a Model")); - let model_icon = model.as_ref().and_then(|model| model.icon); + let model_icon = model.as_ref().and_then(|model| model.icon.clone()); let focus_handle = self.focus_handle.clone(); @@ -74,12 +80,59 @@ impl Render for AcpModelSelectorPopover { (Color::Muted, IconName::ChevronDown) }; + let tooltip = Tooltip::element({ + move |_, cx| { + let focus_handle = focus_handle.clone(); + let should_show_cycle_row = !AgentSettings::get_global(cx) + .favorite_model_ids() + .is_empty(); + + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Change Model")) + .child(KeyBinding::for_action_in( + &ToggleModelSelector, + &focus_handle, + cx, + )), + ) + .when(should_show_cycle_row, |this| { + this.child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new("Cycle Favorited Models")) + .child(KeyBinding::for_action_in( + &CycleFavoriteModels, + &focus_handle, + cx, + )), + ) + }) + .into_any() + } + }); + PickerPopoverMenu::new( self.selector.clone(), ButtonLike::new("active-model") .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when_some(model_icon, |this, icon| { - this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) + this.child( + match icon { + AgentModelIcon::Path(path) => Icon::from_external_svg(path), + AgentModelIcon::Named(icon_name) => Icon::new(icon_name), + } + .color(color) + .size(IconSize::XSmall), + ) }) .child( Label::new(model_name) @@ -88,9 +141,7 @@ impl Render for AcpModelSelectorPopover { .ml_0p5(), ) .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), - move |_window, cx| { - Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) - }, + tooltip, gpui::Corner::BottomRight, cx, ) diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index 11718c63475212fbe8b996b2f6edae8b4295c91a..a885e52a05e342dbcd81d28a970560b3047ef9c0 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -1,7 +1,7 @@ use crate::acp::AcpThreadView; -use crate::{AgentPanel, RemoveSelectedThread}; +use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; use agent::{HistoryEntry, HistoryStore}; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::{Editor, EditorEvent}; use fuzzy::StringMatchCandidate; use gpui::{ @@ -12,7 +12,7 @@ use std::{fmt::Display, ops::Range}; use text::Bias; use time::{OffsetDateTime, UtcOffset}; use ui::{ - HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tooltip, WithScrollbar, + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, prelude::*, }; @@ -25,6 +25,7 @@ pub struct AcpThreadHistory { search_query: SharedString, visible_items: Vec, local_timezone: UtcOffset, + confirming_delete_history: bool, _update_task: Task<()>, _subscriptions: Vec, } @@ -98,6 +99,7 @@ impl AcpThreadHistory { ) .unwrap(), search_query: SharedString::default(), + confirming_delete_history: false, _subscriptions: vec![search_editor_subscription, history_store_subscription], _update_task: Task::ready(()), }; @@ -331,6 +333,24 @@ impl AcpThreadHistory { task.detach_and_log_err(cx); } + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.history_store.update(cx, |store, cx| { + store.delete_threads(cx).detach_and_log_err(cx) + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + fn render_list_items( &mut self, range: Range, @@ -382,7 +402,22 @@ impl AcpThreadHistory { let selected = ix == self.selected_index; let hovered = Some(ix) == self.hovered_index; let timestamp = entry.updated_at().timestamp(); - let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone); + + let display_text = match format { + EntryTimeFormat::DateAndTime => { + let entry_time = entry.updated_at(); + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), + }; + + let title = entry.title().clone(); + let full_date = + EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); h_flex() .w_full() @@ -403,11 +438,14 @@ impl AcpThreadHistory { .truncate(), ) .child( - Label::new(thread_timestamp) + Label::new(display_text) .color(Color::Muted) .size(LabelSize::XSmall), ), ) + .tooltip(move |_, cx| { + Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) + }) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { if *is_hovered { this.hovered_index = Some(ix); @@ -426,9 +464,10 @@ impl AcpThreadHistory { .tooltip(move |_window, cx| { Tooltip::for_action("Delete", &RemoveSelectedThread, cx) }) - .on_click( - cx.listener(move |this, _, _, cx| this.remove_thread(ix, cx)), - ), + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), ) } else { None @@ -447,6 +486,8 @@ impl Focusable for AcpThreadHistory { impl Render for AcpThreadHistory { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history_store.read(cx).is_empty(cx); + v_flex() .key_context("ThreadHistory") .size_full() @@ -457,9 +498,12 @@ impl Render for AcpThreadHistory { .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) .child( h_flex() - .h(px(41.)) // Match the toolbar perfectly + .h(Tab::container_height(cx)) .w_full() .py_1() .px_2() @@ -481,7 +525,7 @@ impl Render for AcpThreadHistory { .overflow_hidden() .flex_grow(); - if self.history_store.read(cx).is_empty(cx) { + if has_no_history { view.justify_center().items_center().child( Label::new("You don't have any past threads yet.") .size(LabelSize::Small) @@ -502,16 +546,74 @@ impl Render for AcpThreadHistory { ) .p_1() .pr_4() - .track_scroll(self.scroll_handle.clone()) + .track_scroll(&self.scroll_handle) .flex_grow(), ) - .vertical_scrollbar_for( - self.scroll_handle.clone(), - window, - cx, - ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) } }) + .when(!has_no_history, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 92765140f5101034a30fc95db675ff335f2cb324..32b2de2c0d850676bf7a6a80ee88950d62aa24e0 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -34,7 +34,7 @@ use language::Buffer; use language_model::LanguageModelRegistry; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; -use project::{Project, ProjectEntryId}; +use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; @@ -63,14 +63,11 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::ui::{ - AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip, - UsageCallout, -}; +use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout}; use crate::{ AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode, - CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAgentDiff, OpenHistory, - RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector, + CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, + OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector, }; #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -100,7 +97,7 @@ impl ThreadError { { Self::ModelRequestLimitReached(error.plan) } else if let Some(acp_error) = error.downcast_ref::() - && acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code + && acp_error.code == acp::ErrorCode::AuthRequired { Self::AuthenticationRequired(acp_error.message.clone().into()) } else { @@ -170,7 +167,7 @@ impl ThreadFeedbackState { } } let session_id = thread.read(cx).session_id().clone(); - let agent = thread.read(cx).connection().telemetry_id(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); let rating = match feedback { ThreadFeedback::Positive => "positive", @@ -180,7 +177,7 @@ impl ThreadFeedbackState { let thread = task.await?; telemetry::event!( "Agent Thread Rated", - agent = agent, + agent = agent_telemetry_id, session_id = session_id, rating = rating, thread = thread @@ -207,13 +204,13 @@ impl ThreadFeedbackState { self.comments_editor.take(); let session_id = thread.read(cx).session_id().clone(); - let agent = thread.read(cx).connection().telemetry_id(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); cx.background_spawn(async move { let thread = task.await?; telemetry::event!( "Agent Thread Feedback Comments", - agent = agent, + agent = agent_telemetry_id, session_id = session_id, comments = comments, thread = thread @@ -256,13 +253,14 @@ impl ThreadFeedbackState { editor }); - editor.read(cx).focus_handle(cx).focus(window); + editor.read(cx).focus_handle(cx).focus(window, cx); editor } } pub struct AcpThreadView { agent: Rc, + agent_server_store: Entity, workspace: WeakEntity, project: Entity, thread_state: ThreadState, @@ -333,18 +331,25 @@ impl AcpThreadView { project: Entity, history_store: Entity, prompt_store: Option>, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> Self { let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); - let placeholder = placeholder_text(agent.name().as_ref(), false); + let agent_server_store = project.read(cx).agent_server_store().clone(); + let agent_display_name = agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(agent.name())) + .unwrap_or_else(|| agent.name()); + + let placeholder = placeholder_text(agent_display_name.as_ref(), false); let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( workspace.clone(), - project.clone(), + project.downgrade(), history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), @@ -369,7 +374,7 @@ impl AcpThreadView { let entry_view_state = cx.new(|_| { EntryViewState::new( workspace.clone(), - project.clone(), + project.downgrade(), history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), @@ -378,7 +383,6 @@ impl AcpThreadView { ) }); - let agent_server_store = project.read(cx).agent_server_store().clone(); let subscriptions = [ cx.observe_global_in::(window, Self::agent_ui_font_size_changed), cx.observe_global_in::(window, Self::agent_ui_font_size_changed), @@ -391,11 +395,24 @@ impl AcpThreadView { ), ]; - let show_codex_windows_warning = crate::ExternalAgent::parse_built_in(agent.as_ref()) - == Some(crate::ExternalAgent::Codex); + cx.on_release(|this, cx| { + for window in this.notifications.drain(..) { + window + .update(cx, |_, window, _| { + window.remove_window(); + }) + .ok(); + } + }) + .detach(); + + let show_codex_windows_warning = cfg!(windows) + && project.read(cx).is_local() + && agent.clone().downcast::().is_some(); Self { agent: agent.clone(), + agent_server_store, workspace: workspace.clone(), project: project.clone(), entry_view_state, @@ -404,6 +421,7 @@ impl AcpThreadView { resume_thread.clone(), workspace.clone(), project.clone(), + track_load_event, window, cx, ), @@ -448,6 +466,7 @@ impl AcpThreadView { self.resume_thread_metadata.clone(), self.workspace.clone(), self.project.clone(), + true, window, cx, ); @@ -461,6 +480,7 @@ impl AcpThreadView { resume_thread: Option, workspace: WeakEntity, project: Entity, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> ThreadState { @@ -519,6 +539,10 @@ impl AcpThreadView { } }; + if track_load_event { + telemetry::event!("Agent Thread Started", agent = connection.telemetry_id()); + } + let result = if let Some(native_agent) = connection .clone() .downcast::() @@ -653,7 +677,6 @@ impl AcpThreadView { mode_selector, _subscriptions: subscriptions, }; - this.message_editor.focus_handle(cx).focus(window); this.profile_selector = this.as_native_thread(cx).map(|thread| { cx.new(|cx| { @@ -666,6 +689,8 @@ impl AcpThreadView { }) }); + this.message_editor.focus_handle(cx).focus(window, cx); + cx.notify(); } Err(err) => { @@ -683,7 +708,7 @@ impl AcpThreadView { this.new_server_version_available = Some(new_version.into()); cx.notify(); }) - .log_err(); + .ok(); } } }) @@ -719,7 +744,7 @@ impl AcpThreadView { cx: &mut App, ) { let agent_name = agent.name(); - let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id { + let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id { let registry = LanguageModelRegistry::global(cx); let sub = window.subscribe(®istry, cx, { @@ -761,12 +786,11 @@ impl AcpThreadView { configuration_view, description: err .description - .clone() .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))), _subscription: subscription, }; if this.message_editor.focus_handle(cx).is_focused(window) { - this.focus_handle.focus(window) + this.focus_handle.focus(window, cx) } cx.notify(); }) @@ -786,7 +810,7 @@ impl AcpThreadView { ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into())) } if self.message_editor.focus_handle(cx).is_focused(window) { - self.focus_handle.focus(window) + self.focus_handle.focus(window, cx) } cx.notify(); } @@ -1000,6 +1024,10 @@ impl AcpThreadView { } } + pub fn is_loading(&self) -> bool { + matches!(self.thread_state, ThreadState::Loading { .. }) + } + fn resume_chat(&mut self, cx: &mut Context) { self.thread_error.take(); let Some(thread) = self.thread() else { @@ -1066,10 +1094,7 @@ impl AcpThreadView { window.defer(cx, |window, cx| { Self::handle_auth_required( this, - AuthRequired { - description: None, - provider_id: None, - }, + AuthRequired::new(), agent, connection, window, @@ -1128,8 +1153,8 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; - let agent_telemetry_id = self.agent.telemetry_id(); let session_id = thread.read(cx).session_id().clone(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let thread = thread.downgrade(); if self.should_be_following { self.workspace @@ -1248,7 +1273,7 @@ impl AcpThreadView { } }) }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -1300,11 +1325,11 @@ impl AcpThreadView { .await?; this.update_in(cx, |this, window, cx| { this.send_impl(message_editor, window, cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })?; anyhow::Ok(()) }) - .detach(); + .detach_and_log_err(cx); } fn open_edited_buffer( @@ -1443,7 +1468,7 @@ impl AcpThreadView { self.thread_retry_status.take(); self.thread_state = ThreadState::LoadError(error.clone()); if self.message_editor.focus_handle(cx).is_focused(window) { - self.focus_handle.focus(window) + self.focus_handle.focus(window, cx) } } AcpThreadEvent::TitleUpdated => { @@ -1471,24 +1496,20 @@ impl AcpThreadView { .iter() .any(|method| method.id.0.as_ref() == "claude-login") { - available_commands.push(acp::AvailableCommand { - name: "login".to_owned(), - description: "Authenticate".to_owned(), - input: None, - meta: None, - }); - available_commands.push(acp::AvailableCommand { - name: "logout".to_owned(), - description: "Authenticate".to_owned(), - input: None, - meta: None, - }); + available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); + available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); } let has_commands = !available_commands.is_empty(); self.available_commands.replace(available_commands); - let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands); + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(self.agent.name())) + .unwrap_or_else(|| self.agent.name()); + + let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands); self.message_editor.update(cx, |editor, cx| { editor.set_placeholder_text(&new_placeholder, window, cx); @@ -1517,6 +1538,7 @@ impl AcpThreadView { else { return; }; + let agent_telemetry_id = connection.telemetry_id(); // Check for the experimental "terminal-auth" _meta field let auth_method = connection.auth_methods().iter().find(|m| m.id == method); @@ -1584,19 +1606,18 @@ impl AcpThreadView { ); cx.notify(); self.auth_task = Some(cx.spawn_in(window, { - let agent = self.agent.clone(); async move |this, cx| { let result = authenticate.await; match &result { Ok(_) => telemetry::event!( "Authenticate Agent Succeeded", - agent = agent.telemetry_id() + agent = agent_telemetry_id ), Err(_) => { telemetry::event!( "Authenticate Agent Failed", - agent = agent.telemetry_id(), + agent = agent_telemetry_id, ) } } @@ -1651,43 +1672,6 @@ impl AcpThreadView { }); return; } - } else if method.0.as_ref() == "anthropic-api-key" { - let registry = LanguageModelRegistry::global(cx); - let provider = registry - .read(cx) - .provider(&language_model::ANTHROPIC_PROVIDER_ID) - .unwrap(); - let this = cx.weak_entity(); - let agent = self.agent.clone(); - let connection = connection.clone(); - window.defer(cx, move |window, cx| { - if !provider.is_authenticated(cx) { - Self::handle_auth_required( - this, - AuthRequired { - description: Some("ANTHROPIC_API_KEY must be set".to_owned()), - provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID), - }, - agent, - connection, - window, - cx, - ); - } else { - this.update(cx, |this, cx| { - this.thread_state = Self::initial_state( - agent, - None, - this.workspace.clone(), - this.project.clone(), - window, - cx, - ) - }) - .ok(); - } - }); - return; } else if method.0.as_ref() == "vertex-ai" && std::env::var("GOOGLE_API_KEY").is_err() && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err() @@ -1735,43 +1719,38 @@ impl AcpThreadView { connection.authenticate(method, cx) }; cx.notify(); - self.auth_task = - Some(cx.spawn_in(window, { - let agent = self.agent.clone(); - async move |this, cx| { - let result = authenticate.await; - - match &result { - Ok(_) => telemetry::event!( - "Authenticate Agent Succeeded", - agent = agent.telemetry_id() - ), - Err(_) => { - telemetry::event!( - "Authenticate Agent Failed", - agent = agent.telemetry_id(), - ) - } + self.auth_task = Some(cx.spawn_in(window, { + async move |this, cx| { + let result = authenticate.await; + + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent_telemetry_id + ), + Err(_) => { + telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,) } + } - this.update_in(cx, |this, window, cx| { - if let Err(err) = result { - if let ThreadState::Unauthenticated { - pending_auth_method, - .. - } = &mut this.thread_state - { - pending_auth_method.take(); - } - this.handle_thread_error(err, cx); - } else { - this.reset(window, cx); + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + if let ThreadState::Unauthenticated { + pending_auth_method, + .. + } = &mut this.thread_state + { + pending_auth_method.take(); } - this.auth_task.take() - }) - .ok(); - } - })); + this.handle_thread_error(err, cx); + } else { + this.reset(window, cx); + } + this.auth_task.take() + }) + .ok(); + } + })); } fn spawn_external_agent_login( @@ -1890,6 +1869,17 @@ impl AcpThreadView { }) } + pub fn has_user_submitted_prompt(&self, cx: &App) -> bool { + self.thread().is_some_and(|thread| { + thread.read(cx).entries().iter().any(|entry| { + matches!( + entry, + AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some() + ) + }) + }) + } + fn authorize_tool_call( &mut self, tool_call_id: acp::ToolCallId, @@ -1901,10 +1891,11 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); telemetry::event!( "Agent Tool Call Authorized", - agent = self.agent.telemetry_id(), + agent = agent_telemetry_id, session = thread.read(cx).session_id(), option = option_kind ); @@ -1942,6 +1933,16 @@ impl AcpThreadView { window: &mut Window, cx: &Context, ) -> AnyElement { + let is_indented = entry.is_indented(); + let is_first_indented = is_indented + && self.thread().is_some_and(|thread| { + thread + .read(cx) + .entries() + .get(entry_ix.saturating_sub(1)) + .is_none_or(|entry| !entry.is_indented()) + }); + let primary = match &entry { AgentThreadEntry::UserMessage(message) => { let Some(editor) = self @@ -1974,7 +1975,9 @@ impl AcpThreadView { v_flex() .id(("user_message", entry_ix)) .map(|this| { - if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { + if is_first_indented { + this.pt_0p5() + } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { this.pt(rems_from_px(18.)) } else if rules_item.is_some() { this.pt_3() @@ -2020,6 +2023,9 @@ impl AcpThreadView { .shadow_md() .bg(cx.theme().colors().editor_background) .border_1() + .when(is_indented, |this| { + this.py_2().px_2().shadow_sm() + }) .when(editing && !editor_focus, |this| this.border_dashed()) .border_color(cx.theme().colors().border) .map(|this|{ @@ -2090,10 +2096,23 @@ impl AcpThreadView { .icon_size(IconSize::Small) .icon_color(Color::Muted) .style(ButtonStyle::Transparent) - .tooltip(move |_window, cx| { - cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone())) - .into() - }) + .tooltip(Tooltip::element({ + move |_, _| { + v_flex() + .gap_1() + .child(Label::new("Unavailable Editing")).child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + agent_name.clone() + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + })) ) ) } @@ -2101,7 +2120,11 @@ impl AcpThreadView { ) .into_any() } - AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks, + indented: _, + }) => { + let mut is_blank = true; let is_last = entry_ix + 1 == total_entries; let style = default_markdown_style(false, false, window, cx); @@ -2111,52 +2134,101 @@ impl AcpThreadView { .children(chunks.iter().enumerate().filter_map( |(chunk_ix, chunk)| match chunk { AssistantMessageChunk::Message { block } => { - block.markdown().map(|md| { - self.render_markdown(md.clone(), style.clone()) - .into_any_element() + block.markdown().and_then(|md| { + let this_is_blank = md.read(cx).source().trim().is_empty(); + is_blank = is_blank && this_is_blank; + if this_is_blank { + return None; + } + + Some( + self.render_markdown(md.clone(), style.clone()) + .into_any_element(), + ) }) } AssistantMessageChunk::Thought { block } => { - block.markdown().map(|md| { - self.render_thinking_block( - entry_ix, - chunk_ix, - md.clone(), - window, - cx, + block.markdown().and_then(|md| { + let this_is_blank = md.read(cx).source().trim().is_empty(); + is_blank = is_blank && this_is_blank; + if this_is_blank { + return None; + } + + Some( + self.render_thinking_block( + entry_ix, + chunk_ix, + md.clone(), + window, + cx, + ) + .into_any_element(), ) - .into_any_element() }) } }, )) .into_any(); - v_flex() - .px_5() - .py_1p5() - .when(is_last, |this| this.pb_4()) - .w_full() - .text_ui(cx) - .child(message_body) - .into_any() + if is_blank { + Empty.into_any() + } else { + v_flex() + .px_5() + .py_1p5() + .when(is_last, |this| this.pb_4()) + .w_full() + .text_ui(cx) + .child(message_body) + .into_any() + } } AgentThreadEntry::ToolCall(tool_call) => { let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().map(|this| { - if has_terminals { - this.children(tool_call.terminals().map(|terminal| { - self.render_terminal_tool_call( - entry_ix, terminal, tool_call, window, cx, - ) - })) - } else { - this.child(self.render_tool_call(entry_ix, tool_call, window, cx)) - } - }) + div() + .w_full() + .map(|this| { + if has_terminals { + this.children(tool_call.terminals().map(|terminal| { + self.render_terminal_tool_call( + entry_ix, terminal, tool_call, window, cx, + ) + })) + } else { + this.child(self.render_tool_call(entry_ix, tool_call, window, cx)) + } + }) + .into_any() } - .into_any(), + }; + + let primary = if is_indented { + let line_top = if is_first_indented { + rems_from_px(-12.0) + } else { + rems_from_px(0.0) + }; + + div() + .relative() + .w_full() + .pl_5() + .bg(cx.theme().colors().panel_background.opacity(0.2)) + .child( + div() + .absolute() + .left(rems_from_px(18.0)) + .top(line_top) + .bottom_0() + .w_px() + .bg(cx.theme().colors().border.opacity(0.6)), + ) + .child(primary) + .into_any_element() + } else { + primary }; let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { @@ -2359,6 +2431,12 @@ impl AcpThreadView { let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); + let input_output_header = |label: SharedString| { + Label::new(label) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + }; let tool_output_display = if is_open { @@ -2400,7 +2478,25 @@ impl AcpThreadView { | ToolCallStatus::Completed | ToolCallStatus::Failed | ToolCallStatus::Canceled => v_flex() - .w_full() + .when(!is_edit && !is_terminal_tool, |this| { + this.mt_1p5().w_full().child( + v_flex() + .ml(rems(0.4)) + .px_3p5() + .pb_1() + .gap_1() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .child(input_output_header("Raw Input:".into())) + .children(tool_call.raw_input_markdown.clone().map(|input| { + self.render_markdown( + input, + default_markdown_style(false, false, window, cx), + ) + })) + .child(input_output_header("Output:".into())), + ) + }) .children(tool_call.content.iter().enumerate().map( |(content_ix, content)| { div().child(self.render_tool_call_content( @@ -2499,7 +2595,7 @@ impl AcpThreadView { .gap_px() .when(is_collapsible, |this| { this.child( - Disclosure::new(("expand", entry_ix), is_open) + Disclosure::new(("expand-output", entry_ix), is_open) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&card_header_id) @@ -2557,7 +2653,7 @@ impl AcpThreadView { acp::ToolKind::Think => IconName::ToolThink, acp::ToolKind::Fetch => IconName::ToolWeb, acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, - acp::ToolKind::Other => IconName::ToolHammer, + acp::ToolKind::Other | _ => IconName::ToolHammer, }) } .size(IconSize::Small) @@ -2622,7 +2718,7 @@ impl AcpThreadView { ..default_markdown_style(false, true, window, cx) }, )) - .tooltip(Tooltip::text("Jump to File")) + .tooltip(Tooltip::text("Go to File")) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })) @@ -2685,20 +2781,20 @@ impl AcpThreadView { let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); v_flex() - .mt_1p5() .gap_2() - .when(!card_layout, |this| { - this.ml(rems(0.4)) - .px_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - }) - .when(card_layout, |this| { - this.px_2().pb_2().when(context_ix > 0, |this| { - this.border_t_1() - .pt_2() + .map(|this| { + if card_layout { + this.when(context_ix > 0, |this| { + this.pt_2() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + }) + } else { + this.ml(rems(0.4)) + .px_3p5() + .border_l_1() .border_color(self.tool_card_border_color(cx)) - }) + } }) .text_xs() .text_color(cx.theme().colors().text_muted) @@ -2809,7 +2905,7 @@ impl AcpThreadView { }) .gap_0p5() .children(options.iter().map(move |option| { - let option_id = SharedString::from(option.id.0.clone()); + let option_id = SharedString::from(option.option_id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) .map(|this| { let (this, action) = match option.kind { @@ -2825,7 +2921,7 @@ impl AcpThreadView { this.icon(IconName::Close).icon_color(Color::Error), Some(&RejectOnce as &dyn Action), ), - acp::PermissionOptionKind::RejectAlways => { + acp::PermissionOptionKind::RejectAlways | _ => { (this.icon(IconName::Close).icon_color(Color::Error), None) } }; @@ -2850,7 +2946,7 @@ impl AcpThreadView { .label_size(LabelSize::Small) .on_click(cx.listener({ let tool_call_id = tool_call_id.clone(); - let option_id = option.id.clone(); + let option_id = option.option_id.clone(); let option_kind = option.kind; move |this, _, window, cx| { this.authorize_tool_call( @@ -3419,171 +3515,154 @@ impl AcpThreadView { pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, - ) -> Div { - let show_description = - configuration_view.is_none() && description.is_none() && pending_auth_method.is_none(); - + ) -> impl IntoElement { let auth_methods = connection.auth_methods(); - v_flex().flex_1().size_full().justify_end().child( - v_flex() - .p_2() - .pr_3() - .w_full() - .gap_1() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().status().warning.opacity(0.04)) - .child( - h_flex() - .gap_1p5() - .child( - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::Small), - ) - .child(Label::new("Authentication Required").size(LabelSize::Small)), - ) - .children(description.map(|desc| { - div().text_ui(cx).child(self.render_markdown( - desc.clone(), - default_markdown_style(false, false, window, cx), - )) - })) - .children( - configuration_view - .cloned() - .map(|view| div().w_full().child(view)), - ) - .when(show_description, |el| { - el.child( - Label::new(format!( - "You are not currently authenticated with {}.{}", - self.agent.name(), - if auth_methods.len() > 1 { - " Please choose one of the following options:" - } else { - "" - } - )) - .size(LabelSize::Small) - .color(Color::Muted) - .mb_1() - .ml_5(), - ) - }) - .when_some(pending_auth_method, |el, _| { - el.child( - h_flex() - .py_4() - .w_full() - .justify_center() - .gap_1() - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .color(Color::Muted) - .with_rotate_animation(2), - ) - .child(Label::new("Authenticating…").size(LabelSize::Small)), - ) - }) - .when(!auth_methods.is_empty(), |this| { - this.child( - h_flex() - .justify_end() - .flex_wrap() - .gap_1() - .when(!show_description, |this| { - this.border_t_1() - .mt_1() - .pt_2() - .border_color(cx.theme().colors().border.opacity(0.8)) - }) - .children(connection.auth_methods().iter().enumerate().rev().map( - |(ix, method)| { - let (method_id, name) = if self - .project - .read(cx) - .is_via_remote_server() - && method.id.0.as_ref() == "oauth-personal" - && method.name == "Log in with Google" - { - ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) - } else { - (method.id.0.clone(), method.name.clone()) - }; + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(self.agent.name())) + .unwrap_or_else(|| self.agent.name()); - Button::new(SharedString::from(method_id.clone()), name) - .label_size(LabelSize::Small) - .map(|this| { - if ix == 0 { - this.style(ButtonStyle::Tinted(TintColor::Warning)) - } else { - this.style(ButtonStyle::Outlined) - } - }) - .when_some( - method.description.clone(), - |this, description| { - this.tooltip(Tooltip::text(description)) - }, - ) - .on_click({ - cx.listener(move |this, _, window, cx| { - telemetry::event!( - "Authenticate Agent Started", - agent = this.agent.telemetry_id(), - method = method_id - ); - - this.authenticate( - acp::AuthMethodId(method_id.clone()), - window, - cx, - ) - }) - }) - }, - )), - ) - }), - ) - } + let show_fallback_description = auth_methods.len() > 1 + && configuration_view.is_none() + && description.is_none() + && pending_auth_method.is_none(); - fn render_load_error( - &self, - e: &LoadError, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let (title, message, action_slot): (_, SharedString, _) = match e { - LoadError::Unsupported { - command: path, - current_version, - minimum_version, - } => { - return self.render_unsupported(path, current_version, minimum_version, window, cx); - } - LoadError::FailedToInstall(msg) => ( - "Failed to Install", - msg.into(), - Some(self.create_copy_button(msg.to_string()).into_any_element()), - ), - LoadError::Exited { status } => ( - "Failed to Launch", - format!("Server exited with status {status}").into(), - None, - ), - LoadError::Other(msg) => ( - "Failed to Launch", - msg.into(), - Some(self.create_copy_button(msg.to_string()).into_any_element()), - ), - }; + let auth_buttons = || { + h_flex().justify_end().flex_wrap().gap_1().children( + connection + .auth_methods() + .iter() + .enumerate() + .rev() + .map(|(ix, method)| { + let (method_id, name) = if self.project.read(cx).is_via_remote_server() + && method.id.0.as_ref() == "oauth-personal" + && method.name == "Log in with Google" + { + ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) + } else { + (method.id.0.clone(), method.name.clone()) + }; - Callout::new() - .severity(Severity::Error) + let agent_telemetry_id = connection.telemetry_id(); + + Button::new(method_id.clone(), name) + .label_size(LabelSize::Small) + .map(|this| { + if ix == 0 { + this.style(ButtonStyle::Tinted(TintColor::Accent)) + } else { + this.style(ButtonStyle::Outlined) + } + }) + .when_some(method.description.clone(), |this, description| { + this.tooltip(Tooltip::text(description)) + }) + .on_click({ + cx.listener(move |this, _, window, cx| { + telemetry::event!( + "Authenticate Agent Started", + agent = agent_telemetry_id, + method = method_id + ); + + this.authenticate( + acp::AuthMethodId::new(method_id.clone()), + window, + cx, + ) + }) + }) + }), + ) + }; + + if pending_auth_method.is_some() { + return Callout::new() + .icon(IconName::Info) + .title(format!("Authenticating to {}…", agent_display_name)) + .actions_slot( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), + ) + .into_any_element(); + } + + Callout::new() + .icon(IconName::Info) + .title(format!("Authenticate to {}", agent_display_name)) + .when(auth_methods.len() == 1, |this| { + this.actions_slot(auth_buttons()) + }) + .description_slot( + v_flex() + .text_ui(cx) + .map(|this| { + if show_fallback_description { + this.child( + Label::new("Choose one of the following authentication options:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.children( + configuration_view + .cloned() + .map(|view| div().w_full().child(view)), + ) + .children(description.map(|desc| { + self.render_markdown( + desc.clone(), + default_markdown_style(false, false, window, cx), + ) + })) + } + }) + .when(auth_methods.len() > 1, |this| { + this.gap_1().child(auth_buttons()) + }), + ) + .into_any_element() + } + + fn render_load_error( + &self, + e: &LoadError, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (title, message, action_slot): (_, SharedString, _) = match e { + LoadError::Unsupported { + command: path, + current_version, + minimum_version, + } => { + return self.render_unsupported(path, current_version, minimum_version, window, cx); + } + LoadError::FailedToInstall(msg) => ( + "Failed to Install", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + LoadError::Exited { status } => ( + "Failed to Launch", + format!("Server exited with status {status}").into(), + None, + ), + LoadError::Other(msg) => ( + "Failed to Launch", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + }; + + Callout::new() + .severity(Severity::Error) .icon(IconName::XCircleFilled) .title(title) .description(message) @@ -3803,48 +3882,64 @@ impl AcpThreadView { })) } - fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context) -> Div { - v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| { - let element = h_flex() - .py_1() - .px_2() - .gap_2() - .justify_between() - .bg(cx.theme().colors().editor_background) - .when(index < plan.entries.len() - 1, |parent| { - parent.border_color(cx.theme().colors().border).border_b_1() - }) - .child( - h_flex() - .id(("plan_entry", index)) - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .text_xs() - .text_color(cx.theme().colors().text_muted) - .child(match entry.status { - acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element(), - acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress) - .size(IconSize::Small) - .color(Color::Accent) - .with_rotate_animation(2) - .into_any_element(), - acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete) - .size(IconSize::Small) - .color(Color::Success) - .into_any_element(), - }) - .child(MarkdownElement::new( - entry.content.clone(), - plan_label_markdown_style(&entry.status, window, cx), - )), - ); + fn render_plan_entries( + &self, + plan: &Plan, + window: &mut Window, + cx: &Context, + ) -> impl IntoElement { + v_flex() + .id("plan_items_list") + .max_h_40() + .overflow_y_scroll() + .children(plan.entries.iter().enumerate().flat_map(|(index, entry)| { + let element = h_flex() + .py_1() + .px_2() + .gap_2() + .justify_between() + .bg(cx.theme().colors().editor_background) + .when(index < plan.entries.len() - 1, |parent| { + parent.border_color(cx.theme().colors().border).border_b_1() + }) + .child( + h_flex() + .id(("plan_entry", index)) + .gap_1p5() + .max_w_full() + .overflow_x_scroll() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(match entry.status { + acp::PlanEntryStatus::InProgress => { + Icon::new(IconName::TodoProgress) + .size(IconSize::Small) + .color(Color::Accent) + .with_rotate_animation(2) + .into_any_element() + } + acp::PlanEntryStatus::Completed => { + Icon::new(IconName::TodoComplete) + .size(IconSize::Small) + .color(Color::Success) + .into_any_element() + } + acp::PlanEntryStatus::Pending | _ => { + Icon::new(IconName::TodoPending) + .size(IconSize::Small) + .color(Color::Muted) + .into_any_element() + } + }) + .child(MarkdownElement::new( + entry.content.clone(), + plan_label_markdown_style(&entry.status, window, cx), + )), + ); - Some(element) - })) + Some(element) + })) + .into_any_element() } fn render_edits_summary( @@ -3982,162 +4077,185 @@ impl AcpThreadView { changed_buffers: &BTreeMap, Entity>, pending_edits: bool, cx: &Context, - ) -> Div { + ) -> impl IntoElement { let editor_bg_color = cx.theme().colors().editor_background; - v_flex().children(changed_buffers.iter().enumerate().flat_map( - |(index, (buffer, _diff))| { - let file = buffer.read(cx).file()?; - let path = file.path(); - let path_style = file.path_style(cx); - let separator = file.path_style(cx).separator(); + v_flex() + .id("edited_files_list") + .max_h_40() + .overflow_y_scroll() + .children( + changed_buffers + .iter() + .enumerate() + .flat_map(|(index, (buffer, _diff))| { + let file = buffer.read(cx).file()?; + let path = file.path(); + let path_style = file.path_style(cx); + let separator = file.path_style(cx).primary_separator(); + + let file_path = path.parent().and_then(|parent| { + if parent.is_empty() { + None + } else { + Some( + Label::new(format!( + "{}{separator}", + parent.display(path_style) + )) + .color(Color::Muted) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + } + }); - let file_path = path.parent().and_then(|parent| { - if parent.is_empty() { - None - } else { - Some( - Label::new(format!("{}{separator}", parent.display(path_style))) - .color(Color::Muted) + let file_name = path.file_name().map(|name| { + Label::new(name.to_string()) .size(LabelSize::XSmall) - .buffer_font(cx), - ) - } - }); + .buffer_font(cx) + .ml_1p5() + }); - let file_name = path.file_name().map(|name| { - Label::new(name.to_string()) - .size(LabelSize::XSmall) - .buffer_font(cx) - .ml_1p5() - }); + let full_path = path.display(path_style).to_string(); - let file_icon = FileIcons::get_icon(path.as_std_path(), cx) - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) - .unwrap_or_else(|| { - Icon::new(IconName::File) - .color(Color::Muted) - .size(IconSize::Small) - }); + let file_icon = FileIcons::get_icon(path.as_std_path(), cx) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) + .unwrap_or_else(|| { + Icon::new(IconName::File) + .color(Color::Muted) + .size(IconSize::Small) + }); - let overlay_gradient = linear_gradient( - 90., - linear_color_stop(editor_bg_color, 1.), - linear_color_stop(editor_bg_color.opacity(0.2), 0.), - ); + let overlay_gradient = linear_gradient( + 90., + linear_color_stop(editor_bg_color, 1.), + linear_color_stop(editor_bg_color.opacity(0.2), 0.), + ); - let element = h_flex() - .group("edited-code") - .id(("file-container", index)) - .py_1() - .pl_2() - .pr_1() - .gap_2() - .justify_between() - .bg(editor_bg_color) - .when(index < changed_buffers.len() - 1, |parent| { - parent.border_color(cx.theme().colors().border).border_b_1() - }) - .child( - h_flex() - .id(("file-name-row", index)) - .relative() - .pr_8() - .w_full() - .overflow_x_scroll() + let element = h_flex() + .group("edited-code") + .id(("file-container", index)) + .py_1() + .pl_2() + .pr_1() + .gap_2() + .justify_between() + .bg(editor_bg_color) + .when(index < changed_buffers.len() - 1, |parent| { + parent.border_color(cx.theme().colors().border).border_b_1() + }) .child( h_flex() - .id(("file-name-path", index)) - .cursor_pointer() - .pr_0p5() - .gap_0p5() - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .rounded_xs() - .child(file_icon) - .children(file_name) - .children(file_path) - .tooltip(Tooltip::text("Go to File")) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.open_edited_buffer(&buffer, window, cx); - }) - }), - ) - .child( - div() - .absolute() - .h_full() - .w_12() - .top_0() - .bottom_0() - .right_0() - .bg(overlay_gradient), - ), - ) - .child( - h_flex() - .gap_1() - .visible_on_hover("edited-code") - .child( - Button::new("review", "Review") - .label_size(LabelSize::Small) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.open_edited_buffer(&buffer, window, cx); - }) - }), + .id(("file-name-row", index)) + .relative() + .pr_8() + .w_full() + .child( + h_flex() + .id(("file-name-path", index)) + .cursor_pointer() + .pr_0p5() + .gap_0p5() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .rounded_xs() + .child(file_icon) + .children(file_name) + .children(file_path) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Go to File", + None, + full_path.clone(), + cx, + ) + }) + .on_click({ + let buffer = buffer.clone(); + cx.listener(move |this, _, window, cx| { + this.open_edited_buffer(&buffer, window, cx); + }) + }), + ) + .child( + div() + .absolute() + .h_full() + .w_12() + .top_0() + .bottom_0() + .right_0() + .bg(overlay_gradient), + ), ) - .child(Divider::vertical().color(DividerColor::BorderVariant)) .child( - Button::new("reject-file", "Reject") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .on_click({ - let buffer = buffer.clone(); - let action_log = action_log.clone(); - let telemetry = telemetry.clone(); - move |_, _, cx| { - action_log.update(cx, |action_log, cx| { - action_log + h_flex() + .gap_1() + .visible_on_hover("edited-code") + .child( + Button::new("review", "Review") + .label_size(LabelSize::Small) + .on_click({ + let buffer = buffer.clone(); + cx.listener(move |this, _, window, cx| { + this.open_edited_buffer(&buffer, window, cx); + }) + }), + ) + .child(Divider::vertical().color(DividerColor::BorderVariant)) + .child( + Button::new("reject-file", "Reject") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .on_click({ + let buffer = buffer.clone(); + let action_log = action_log.clone(); + let telemetry = telemetry.clone(); + move |_, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log .reject_edits_in_ranges( buffer.clone(), - vec![Anchor::MIN..Anchor::MAX], + vec![Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + )], Some(telemetry.clone()), cx, ) .detach_and_log_err(cx); - }) - } - }), - ) - .child( - Button::new("keep-file", "Keep") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .on_click({ - let buffer = buffer.clone(); - let action_log = action_log.clone(); - let telemetry = telemetry.clone(); - move |_, _, cx| { - action_log.update(cx, |action_log, cx| { - action_log.keep_edits_in_range( - buffer.clone(), - Anchor::MIN..Anchor::MAX, - Some(telemetry.clone()), - cx, - ); - }) - } - }), - ), - ); + }) + } + }), + ) + .child( + Button::new("keep-file", "Keep") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .on_click({ + let buffer = buffer.clone(); + let action_log = action_log.clone(); + let telemetry = telemetry.clone(); + move |_, _, cx| { + action_log.update(cx, |action_log, cx| { + action_log.keep_edits_in_range( + buffer.clone(), + Anchor::min_max_range_for_buffer( + buffer.read(cx).remote_id(), + ), + Some(telemetry.clone()), + cx, + ); + }) + } + }), + ), + ); - Some(element) - }, - )) + Some(element) + }), + ) + .into_any_element() } fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { @@ -4158,8 +4276,10 @@ impl AcpThreadView { .block_mouse_except_scroll(); let enable_editor = match self.thread_state { - ThreadState::Loading { .. } | ThreadState::Ready { .. } => true, - ThreadState::Unauthenticated { .. } | ThreadState::LoadError(..) => false, + ThreadState::Ready { .. } => true, + ThreadState::Loading { .. } + | ThreadState::Unauthenticated { .. } + | ThreadState::LoadError(..) => false, }; v_flex() @@ -4172,7 +4292,11 @@ impl AcpThreadView { } })) .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { - if let Some(mode_selector) = this.mode_selector() { + if let Some(profile_selector) = this.profile_selector.as_ref() { + profile_selector.update(cx, |profile_selector, cx| { + profile_selector.cycle_profile(cx); + }); + } else if let Some(mode_selector) = this.mode_selector() { mode_selector.update(cx, |mode_selector, cx| { mode_selector.cycle_mode(window, cx); }); @@ -4184,6 +4308,13 @@ impl AcpThreadView { .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); } })) + .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { + if let Some(model_selector) = this.model_selector.as_ref() { + model_selector.update(cx, |model_selector, cx| { + model_selector.cycle_favorite_models(window, cx); + }); + } + })) .p_2() .gap_2() .border_t_1() @@ -4391,7 +4522,7 @@ impl AcpThreadView { self.authorize_tool_call( tool_call.id.clone(), - option.id.clone(), + option.option_id.clone(), option.kind, window, cx, @@ -4744,11 +4875,8 @@ impl AcpThreadView { let buffer = multibuffer.as_singleton(); if agent_location.buffer.upgrade() == buffer { let excerpt_id = multibuffer.excerpt_ids().first().cloned(); - let anchor = editor::Anchor::in_buffer( - excerpt_id.unwrap(), - buffer.unwrap().read(cx).remote_id(), - agent_location.position, - ); + let anchor = + editor::Anchor::in_buffer(excerpt_id.unwrap(), agent_location.position); editor.change_selections(Default::default(), window, cx, |selections| { selections.select_anchor_ranges([anchor..anchor]); }) @@ -4826,6 +4954,32 @@ impl AcpThreadView { cx.notify(); } + fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + + let entries = thread.read(cx).entries(); + if entries.is_empty() { + return; + } + + // Find the most recent user message and scroll it to the top of the viewport. + // (Fallback: if no user message exists, scroll to the bottom.) + if let Some(ix) = entries + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) + { + self.list_state.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.0), + }); + cx.notify(); + } else { + self.scroll_to_bottom(cx); + } + } + pub fn scroll_to_bottom(&mut self, cx: &mut Context) { if let Some(thread) = self.thread() { let entry_count = thread.read(cx).entries().len(); @@ -4921,8 +5075,8 @@ impl AcpThreadView { }); if let Some(screen_window) = cx - .open_window(options, |_, cx| { - cx.new(|_| { + .open_window(options, |_window, cx| { + cx.new(|_cx| { AgentNotification::new(title.clone(), caption.clone(), icon, project_name) }) }) @@ -5044,6 +5198,16 @@ impl AcpThreadView { } })); + let scroll_to_recent_user_prompt = + IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_most_recent_user_prompt(cx); + })); + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -5120,6 +5284,7 @@ impl AcpThreadView { container .child(open_as_markdown) + .child(scroll_to_recent_user_prompt) .child(scroll_to_top) .into_any_element() } @@ -5351,47 +5516,39 @@ impl AcpThreadView { ) } - fn render_codex_windows_warning(&self, cx: &mut Context) -> Option { - if self.show_codex_windows_warning { - Some( - Callout::new() - .icon(IconName::Warning) - .severity(Severity::Warning) - .title("Codex on Windows") - .description( - "For best performance, run Codex in Windows Subsystem for Linux (WSL2)", - ) - .actions_slot( - Button::new("open-wsl-modal", "Open in WSL") - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |_, _, _window, cx| { - #[cfg(windows)] - _window.dispatch_action( - zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), - cx, - ); - cx.notify(); - } - })), - ) - .dismiss_action( - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Dismiss Warning")) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.show_codex_windows_warning = false; - cx.notify(); - } - })), - ), + fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title("Codex on Windows") + .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") + .actions_slot( + Button::new("open-wsl-modal", "Open in WSL") + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |_, _, _window, cx| { + #[cfg(windows)] + _window.dispatch_action( + zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), + cx, + ); + cx.notify(); + } + })), + ) + .dismiss_action( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Warning")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.show_codex_windows_warning = false; + cx.notify(); + } + })), ) - } else { - None - } } fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context) -> Option
{ @@ -5719,10 +5876,6 @@ impl AcpThreadView { }; let connection = thread.read(cx).connection().clone(); - let err = AuthRequired { - description: None, - provider_id: None, - }; this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { this.message_editor.update(cx, |editor, cx| { @@ -5731,7 +5884,14 @@ impl AcpThreadView { } let this = cx.weak_entity(); window.defer(cx, |window, cx| { - Self::handle_auth_required(this, err, agent, connection, window, cx); + Self::handle_auth_required( + this, + AuthRequired::new(), + agent, + connection, + window, + cx, + ); }) } })) @@ -5744,14 +5904,10 @@ impl AcpThreadView { }; let connection = thread.read(cx).connection().clone(); - let err = AuthRequired { - description: None, - provider_id: None, - }; self.clear_thread_error(cx); let this = cx.weak_entity(); window.defer(cx, |window, cx| { - Self::handle_auth_required(this, err, agent, connection, window, cx); + Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx); }) } @@ -5823,12 +5979,10 @@ fn placeholder_text(agent_name: &str, has_commands: bool) -> String { impl Focusable for AcpThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { match self.thread_state { - ThreadState::Loading { .. } | ThreadState::Ready { .. } => { - self.active_editor(cx).focus_handle(cx) - } - ThreadState::LoadError(_) | ThreadState::Unauthenticated { .. } => { - self.focus_handle.clone() - } + ThreadState::Ready { .. } => self.active_editor(cx).focus_handle(cx), + ThreadState::Loading { .. } + | ThreadState::LoadError(_) + | ThreadState::Unauthenticated { .. } => self.focus_handle.clone(), } } } @@ -5856,16 +6010,19 @@ impl Render for AcpThreadView { configuration_view, pending_auth_method, .. - } => self - .render_auth_required_state( + } => v_flex() + .flex_1() + .size_full() + .justify_end() + .child(self.render_auth_required_state( connection, description.as_ref(), configuration_view.as_ref(), pending_auth_method.as_ref(), window, cx, - ) - .into_any(), + )) + .into_any_element(), ThreadState::Loading { .. } => v_flex() .flex_1() .child(self.render_recent_history(cx)) @@ -5896,7 +6053,7 @@ impl Render for AcpThreadView { .flex_grow() .into_any(), ) - .vertical_scrollbar_for(self.list_state.clone(), window, cx) + .vertical_scrollbar_for(&self.list_state, window, cx) .into_any() } else { this.child(self.render_recent_history(cx)).into_any() @@ -5913,12 +6070,8 @@ impl Render for AcpThreadView { _ => this, }) .children(self.render_thread_retry_status_callout(window, cx)) - .children({ - if cfg!(windows) && self.project.read(cx).is_local() { - self.render_codex_windows_warning(cx) - } else { - None - } + .when(self.show_codex_windows_warning, |this| { + this.child(self.render_codex_windows_warning(cx)) }) .children(self.render_thread_error(window, cx)) .when_some( @@ -6034,13 +6187,13 @@ fn default_markdown_style( }, border_color: Some(colors.border_variant), background: Some(colors.editor_background.into()), - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some(theme_settings.buffer_font.family.clone()), font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), font_features: Some(theme_settings.buffer_font.features.clone()), font_size: Some(buffer_font_size.into()), ..Default::default() - }), + }, ..Default::default() }, inline_code: TextStyleRefinement { @@ -6212,27 +6365,18 @@ pub(crate) mod tests { async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) { init_test(cx); - let tool_call_id = acp::ToolCallId("1".into()); - let tool_call = acp::ToolCall { - id: tool_call_id.clone(), - title: "Label".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Pending, - content: vec!["hi".into()], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; + let tool_call_id = acp::ToolCallId::new("1"); + let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label") + .kind(acp::ToolKind::Edit) + .content(vec!["hi".into()]); let connection = StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( tool_call_id, - vec![acp::PermissionOption { - id: acp::PermissionOptionId("1".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }], + vec![acp::PermissionOption::new( + "1", + "Allow", + acp::PermissionOptionKind::AllowOnce, + )], )])); connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); @@ -6360,6 +6504,57 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + let weak_view = thread_view.downgrade(); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + + cx.deactivate_window(); + + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.run_until_parked(); + + // Verify notification is shown + assert!( + cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Expected notification to be shown" + ); + + // Drop the thread view (simulating navigation to a new thread) + drop(thread_view); + drop(message_editor); + // Trigger an update to flush effects, which will call release_dropped_entities + cx.update(|_window, _cx| {}); + cx.run_until_parked(); + + // Verify the entity was actually released + assert!( + !weak_view.is_upgradable(), + "Thread view entity should be released after dropping" + ); + + // The notification should be automatically closed via on_release + assert!( + !cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Notification should be closed when thread view is dropped" + ); + } + async fn setup_thread_view( agent: impl AgentServer + 'static, cx: &mut TestAppContext, @@ -6384,6 +6579,7 @@ pub(crate) mod tests { project, history_store, None, + false, window, cx, ) @@ -6451,10 +6647,7 @@ pub(crate) mod tests { fn default_response() -> Self { let conn = StubAgentConnection::new(); conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: "Default response".into(), - meta: None, - }, + acp::ContentChunk::new("Default response".into()), )]); Self::new(conn) } @@ -6464,10 +6657,6 @@ pub(crate) mod tests { where C: 'static + AgentConnection + Send + Clone, { - fn telemetry_id(&self) -> &'static str { - "test" - } - fn logo(&self) -> ui::IconName { ui::IconName::Ai } @@ -6494,8 +6683,8 @@ pub(crate) mod tests { struct SaboteurAgentConnection; impl AgentConnection for SaboteurAgentConnection { - fn telemetry_id(&self) -> &'static str { - "saboteur" + fn telemetry_id(&self) -> SharedString { + "saboteur".into() } fn new_thread( @@ -6511,13 +6700,13 @@ pub(crate) mod tests { self, project, action_log, - SessionId("test".into()), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + SessionId::new("test"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }))) @@ -6558,8 +6747,8 @@ pub(crate) mod tests { struct RefusalAgentConnection; impl AgentConnection for RefusalAgentConnection { - fn telemetry_id(&self) -> &'static str { - "refusal" + fn telemetry_id(&self) -> SharedString { + "refusal".into() } fn new_thread( @@ -6575,13 +6764,13 @@ pub(crate) mod tests { self, project, action_log, - SessionId("test".into()), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + SessionId::new("test"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }))) @@ -6605,10 +6794,7 @@ pub(crate) mod tests { _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { - Task::ready(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - })) + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal))) } fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { @@ -6663,6 +6849,7 @@ pub(crate) mod tests { project.clone(), history_store.clone(), None, + false, window, cx, ) @@ -6676,24 +6863,14 @@ pub(crate) mod tests { .unwrap(); // First user message - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool1".into()), - title: "Edit file 1".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/test1.txt".into(), - old_text: Some("old content 1".into()), - new_text: "new content 1".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - })]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool1", "Edit file 1") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"), + )]), + )]); thread .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx)) @@ -6719,24 +6896,14 @@ pub(crate) mod tests { }); // Second user message - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool2".into()), - title: "Edit file 2".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/test2.txt".into(), - old_text: Some("old content 2".into()), - new_text: "new content 2".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - })]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool2", "Edit file 2") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"), + )]), + )]); thread .update(cx, |thread, cx| thread.send_raw("Another one", cx)) @@ -6803,6 +6970,70 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + // Each user prompt will result in a user message entry plus an agent message entry. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response 1".into()), + )]); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + + let thread = thread_view + .read_with(cx, |view, _| view.thread().cloned()) + .unwrap(); + + thread + .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response 2".into()), + )]); + + thread + .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // Move somewhere else first so we're not trivially already on the last user prompt. + thread_view.update(cx, |view, cx| { + view.scroll_to_top(cx); + }); + cx.run_until_parked(); + + thread_view.update(cx, |view, cx| { + view.scroll_to_most_recent_user_prompt(cx); + let scroll_top = view.list_state.logical_scroll_top(); + // Entries layout is: [User1, Assistant1, User2, Assistant2] + assert_eq!(scroll_top.item_ix, 2); + }); + } + + #[gpui::test] + async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + // With no entries, scrolling should be a no-op and must not panic. + thread_view.update(cx, |view, cx| { + view.scroll_to_most_recent_user_prompt(cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 0); + }); + } + #[gpui::test] async fn test_message_editing_cancel(cx: &mut TestAppContext) { init_test(cx); @@ -6810,14 +7041,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; @@ -6903,14 +7127,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = @@ -6950,14 +7167,7 @@ pub(crate) mod tests { // Send connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "New Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("New Response".into()), )]); user_message_editor.update_in(cx, |_editor, window, cx| { @@ -7045,14 +7255,7 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())), cx, ); connection.end_turn(session_id, acp::StopReason::EndTurn); @@ -7104,10 +7307,9 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "Message 1 resp".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Message 1 resp".into(), + )), cx, ); }); @@ -7141,10 +7343,7 @@ pub(crate) mod tests { // Simulate a response sent after beginning to cancel connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "onse".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())), cx, ); }); @@ -7175,10 +7374,9 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "Message 2 response".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Message 2 response".into(), + )), cx, ); connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); @@ -7217,14 +7415,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; @@ -7303,14 +7494,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index ef6b90ad89e2e038e96d8864d4c2ce0ecf333d6e..562976453d963db65f9033536e528000de2b510f 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -22,7 +22,8 @@ use gpui::{ }; use language::LanguageRegistry; use language_model::{ - LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID, + IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, + ZED_CLOUD_PROVIDER_ID, }; use language_models::AllLanguageModelSettings; use notifications::status_toast::{StatusToast, ToastIcon}; @@ -34,9 +35,9 @@ use project::{ }; use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ - Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, - Divider, DividerColor, ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize, - PopoverMenu, Switch, SwitchColor, Tooltip, WithScrollbar, prelude::*, + ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, + DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use workspace::{Workspace, create_and_open_local_file}; @@ -117,7 +118,7 @@ impl AgentConfiguration { } fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context) { - let providers = LanguageModelRegistry::read_global(cx).providers(); + let providers = LanguageModelRegistry::read_global(cx).visible_providers(); for provider in providers { self.add_provider_configuration_view(&provider, window, cx); } @@ -261,9 +262,12 @@ impl AgentConfiguration { .w_full() .gap_1p5() .child( - Icon::new(provider.icon()) - .size(IconSize::Small) - .color(Color::Muted), + match provider.icon() { + IconOrSvg::Svg(path) => Icon::from_external_svg(path), + IconOrSvg::Icon(name) => Icon::new(name), + } + .size(IconSize::Small) + .color(Color::Muted), ) .child( h_flex() @@ -416,7 +420,7 @@ impl AgentConfiguration { &mut self, cx: &mut Context, ) -> impl IntoElement { - let providers = LanguageModelRegistry::read_global(cx).providers(); + let providers = LanguageModelRegistry::read_global(cx).visible_providers(); let popover_menu = PopoverMenu::new("add-provider-popover") .trigger( @@ -838,7 +842,7 @@ impl AgentConfiguration { .min_w_0() .child( h_flex() - .id(SharedString::from(format!("tooltip-{}", item_id))) + .id(format!("tooltip-{}", item_id)) .h_full() .w_3() .mr_2() @@ -879,7 +883,6 @@ impl AgentConfiguration { .child(context_server_configuration_menu) .child( Switch::new("context-server-switch", is_running.into()) - .color(SwitchColor::Accent) .on_click({ let context_server_manager = self.context_server_store.clone(); let fs = self.fs.clone(); @@ -976,9 +979,12 @@ impl AgentConfiguration { let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { AgentIcon::Path(icon_path) } else { - AgentIcon::Name(IconName::Ai) + AgentIcon::Name(IconName::Sparkle) }; - (name, icon) + let display_name = agent_server_store + .agent_display_name(&name) + .unwrap_or_else(|| name.0.clone()); + (name, icon, display_name) }) .collect(); @@ -1085,6 +1091,7 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiClaude), "Claude Code", + "Claude Code", false, cx, )) @@ -1092,6 +1099,7 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiOpenAi), "Codex CLI", + "Codex CLI", false, cx, )) @@ -1099,16 +1107,23 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiGemini), "Gemini CLI", + "Gemini CLI", false, cx, )) .map(|mut parent| { - for (name, icon) in user_defined_agents { + for (name, icon, display_name) in user_defined_agents { parent = parent .child( Divider::horizontal().color(DividerColor::BorderFaded), ) - .child(self.render_agent_server(icon, name, true, cx)); + .child(self.render_agent_server( + icon, + name, + display_name, + true, + cx, + )); } parent }), @@ -1119,11 +1134,14 @@ impl AgentConfiguration { fn render_agent_server( &self, icon: AgentIcon, - name: impl Into, + id: impl Into, + display_name: impl Into, external: bool, cx: &mut Context, ) -> impl IntoElement { - let name = name.into(); + let id = id.into(); + let display_name = display_name.into(); + let icon = match icon { AgentIcon::Name(icon_name) => Icon::new(icon_name) .size(IconSize::Small) @@ -1133,12 +1151,15 @@ impl AgentConfiguration { .color(Color::Muted), }; - let tooltip_id = SharedString::new(format!("agent-source-{}", name)); - let tooltip_message = format!("The {} agent was installed from an extension.", name); + let tooltip_id = SharedString::new(format!("agent-source-{}", id)); + let tooltip_message = format!( + "The {} agent was installed from an extension.", + display_name + ); - let agent_server_name = ExternalAgentServerName(name.clone()); + let agent_server_name = ExternalAgentServerName(id.clone()); - let uninstall_btn_id = SharedString::from(format!("uninstall-{}", name)); + let uninstall_btn_id = SharedString::from(format!("uninstall-{}", id)); let uninstall_button = IconButton::new(uninstall_btn_id, IconName::Trash) .icon_color(Color::Muted) .icon_size(IconSize::Small) @@ -1162,7 +1183,7 @@ impl AgentConfiguration { h_flex() .gap_1p5() .child(icon) - .child(Label::new(name)) + .child(Label::new(display_name)) .when(external, |this| { this.child( div() @@ -1209,7 +1230,7 @@ impl Render for AgentConfiguration { .child(self.render_context_servers_section(window, cx)) .child(self.render_provider_configuration_section(cx)), ) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx), + .vertical_scrollbar_for(&self.scroll_handle, window, cx), ) } } diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 3427dab0d22c6900a3078f1dcb4cc7e892cce7db..e443df33b4ddcaeba32b9b2623c0fdca85fac51c 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -446,17 +446,17 @@ impl AddLlmProviderModal { }) } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); } fn on_tab_prev( &mut self, _: &menu::SelectPrevious, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) { - window.focus_prev(); + window.focus_prev(cx); } } @@ -493,7 +493,7 @@ impl Render for AddLlmProviderModal { .on_action(cx.listener(Self::on_tab)) .on_action(cx.listener(Self::on_tab_prev)) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .child( Modal::new("configure-context-server", None) @@ -516,7 +516,7 @@ impl Render for AddLlmProviderModal { .child( div() .size_full() - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) .child( v_flex() .id("modal_content") diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index ebea8c25fb68a8a5055d4ccaa8b9068583c4b91c..b30f1494f0d4dcbf3ef63cc7f549d16374f4899b 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -1,7 +1,4 @@ -use std::{ - path::PathBuf, - sync::{Arc, Mutex}, -}; +use std::sync::{Arc, Mutex}; use anyhow::{Context as _, Result}; use collections::HashMap; @@ -182,7 +179,7 @@ impl ConfigurationSource { parse_input(&editor.read(cx).text(cx)).map(|(id, command)| { ( id, - ContextServerSettings::Custom { + ContextServerSettings::Stdio { enabled: true, command, }, @@ -224,11 +221,12 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) Some((id, cmd)) => { let args = serde_json::to_string(&cmd.args).unwrap(); let env = serde_json::to_string(&cmd.env.unwrap_or_default()).unwrap(); - (id.0.to_string(), cmd.path, args, env) + let cmd_path = serde_json::to_string(&cmd.path).unwrap(); + (id.0.to_string(), cmd_path, args, env) } None => ( "some-mcp-server".to_string(), - PathBuf::new(), + "".to_string(), "[]".to_string(), "{}".to_string(), ), @@ -239,14 +237,13 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) /// The name of your MCP server "{name}": {{ /// The command which runs the MCP server - "command": "{}", + "command": {command}, /// The arguments to pass to the MCP server "args": {args}, /// The environment variables to set "env": {env} }} -}}"#, - command.display() +}}"# ) } @@ -403,7 +400,7 @@ impl ConfigureContextServerModal { window.spawn(cx, async move |cx| { let target = match settings { - ContextServerSettings::Custom { + ContextServerSettings::Stdio { enabled: _, command, } => Some(ConfigurationTarget::Existing { @@ -635,7 +632,6 @@ impl ConfigureContextServerModal { } fn render_modal_content(&self, cx: &App) -> AnyElement { - // All variants now use single editor approach let editor = match &self.source { ConfigurationSource::New { editor, .. } => editor, ConfigurationSource::Existing { editor, .. } => editor, @@ -712,12 +708,12 @@ impl ConfigureContextServerModal { ) } else if let ConfigurationSource::New { is_http, .. } = &self.source { let label = if *is_http { - "Run command" + "Configure Local" } else { - "Connect via HTTP" + "Configure Remote" }; let tooltip = if *is_http { - "Configure an MCP serevr that runs on stdin/stdout." + "Configure an MCP server that runs on stdin/stdout." } else { "Configure an MCP server that you connect to over HTTP" }; @@ -822,7 +818,6 @@ impl ConfigureContextServerModal { impl Render for ConfigureContextServerModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let scroll_handle = self.scroll_handle.clone(); div() .elevation_3(cx) .w(rems(34.)) @@ -836,7 +831,7 @@ impl Render for ConfigureContextServerModal { }), ) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .child( Modal::new("configure-context-server", None) @@ -850,7 +845,7 @@ impl Render for ConfigureContextServerModal { .id("modal-content") .max_h(vh(0.7, window)) .overflow_y_scroll() - .track_scroll(&scroll_handle) + .track_scroll(&self.scroll_handle) .child(self.render_modal_description(window, cx)) .child(self.render_modal_content(cx)) .child(match &self.state { @@ -863,7 +858,7 @@ impl Render for ConfigureContextServerModal { } }), ) - .vertical_scrollbar_for(scroll_handle, window, cx), + .vertical_scrollbar_for(&self.scroll_handle, window, cx), ), ) .footer(self.render_modal_footer(cx)), diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs index 3fe0b8d1b1400b4362192261995ed5b6bd1cb662..5115e2f70c0ae87cdd3ca3901a64aed09de68b0f 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs @@ -87,7 +87,7 @@ impl ConfigureContextServerToolsModal { v_flex() .child( h_flex() - .id(SharedString::from(format!("tool-header-{}", index))) + .id(format!("tool-header-{}", index)) .py_1() .pl_1() .pr_2() @@ -138,7 +138,7 @@ impl ConfigureContextServerToolsModal { items })), ) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) .into_any_element() } } diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 7e03dc46b704c22b4665bbce0f3b818134b56634..c7f395ebbd813cfd7c28f33a7e69ec32f6d90fca 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -8,6 +8,7 @@ use editor::Editor; use fs::Fs; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*}; use language_model::{LanguageModel, LanguageModelRegistry}; +use settings::SettingsStore; use settings::{ LanguageModelProviderSetting, LanguageModelSelection, Settings as _, update_settings_file, }; @@ -94,6 +95,7 @@ pub struct ViewProfileMode { configure_default_model: NavigableEntry, configure_tools: NavigableEntry, configure_mcps: NavigableEntry, + delete_profile: NavigableEntry, cancel_item: NavigableEntry, } @@ -109,6 +111,7 @@ pub struct ManageProfilesModal { active_model: Option>, focus_handle: FocusHandle, mode: Mode, + _settings_subscription: Subscription, } impl ManageProfilesModal { @@ -148,18 +151,29 @@ impl ManageProfilesModal { ) -> Self { let focus_handle = cx.focus_handle(); + // Keep this modal in sync with settings changes (including profile deletion). + let settings_subscription = + cx.observe_global_in::(window, |this, window, cx| { + if matches!(this.mode, Mode::ChooseProfile(_)) { + this.mode = Mode::choose_profile(window, cx); + this.focus_handle(cx).focus(window, cx); + cx.notify(); + } + }); + Self { fs, active_model, context_server_registry, focus_handle, mode: Mode::choose_profile(window, cx), + _settings_subscription: settings_subscription, } } fn choose_profile(&mut self, window: &mut Window, cx: &mut Context) { self.mode = Mode::choose_profile(window, cx); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn new_profile( @@ -177,7 +191,7 @@ impl ManageProfilesModal { name_editor, base_profile_id, }); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } pub fn view_profile( @@ -192,9 +206,10 @@ impl ManageProfilesModal { configure_default_model: NavigableEntry::focusable(cx), configure_tools: NavigableEntry::focusable(cx), configure_mcps: NavigableEntry::focusable(cx), + delete_profile: NavigableEntry::focusable(cx), cancel_item: NavigableEntry::focusable(cx), }); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_default_model( @@ -207,7 +222,6 @@ impl ManageProfilesModal { let profile_id_for_closure = profile_id.clone(); let model_picker = cx.new(|cx| { - let fs = fs.clone(); let profile_id = profile_id_for_closure.clone(); language_model_selector( @@ -235,22 +249,36 @@ impl ManageProfilesModal { }) } }, - move |model, cx| { - let provider = model.provider_id().0.to_string(); - let model_id = model.id().0.to_string(); - let profile_id = profile_id.clone(); + { + let fs = fs.clone(); + move |model, cx| { + let provider = model.provider_id().0.to_string(); + let model_id = model.id().0.to_string(); + let profile_id = profile_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _cx| { - let agent_settings = settings.agent.get_or_insert_default(); - if let Some(profiles) = agent_settings.profiles.as_mut() { - if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) { - profile.default_model = Some(LanguageModelSelection { - provider: LanguageModelProviderSetting(provider.clone()), - model: model_id.clone(), - }); + update_settings_file(fs.clone(), cx, move |settings, _cx| { + let agent_settings = settings.agent.get_or_insert_default(); + if let Some(profiles) = agent_settings.profiles.as_mut() { + if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) { + profile.default_model = Some(LanguageModelSelection { + provider: LanguageModelProviderSetting(provider.clone()), + model: model_id.clone(), + }); + } } - } - }); + }); + } + }, + { + let fs = fs.clone(); + move |model, should_be_favorite, cx| { + crate::favorite_models::toggle_in_settings( + model, + should_be_favorite, + fs.clone(), + cx, + ); + } }, false, // Do not use popover styles for the model picker self.focus_handle.clone(), @@ -272,7 +300,7 @@ impl ManageProfilesModal { model_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_mcp_tools( @@ -308,7 +336,7 @@ impl ManageProfilesModal { tool_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_builtin_tools( @@ -349,7 +377,7 @@ impl ManageProfilesModal { tool_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn confirm(&mut self, window: &mut Window, cx: &mut Context) { @@ -369,6 +397,42 @@ impl ManageProfilesModal { } } + fn delete_profile( + &mut self, + profile_id: AgentProfileId, + window: &mut Window, + cx: &mut Context, + ) { + if builtin_profiles::is_builtin(&profile_id) { + self.view_profile(profile_id, window, cx); + return; + } + + let fs = self.fs.clone(); + + update_settings_file(fs, cx, move |settings, _cx| { + let Some(agent_settings) = settings.agent.as_mut() else { + return; + }; + + let Some(profiles) = agent_settings.profiles.as_mut() else { + return; + }; + + profiles.shift_remove(profile_id.0.as_ref()); + + if agent_settings + .default_profile + .as_deref() + .is_some_and(|default_profile| default_profile == profile_id.0.as_ref()) + { + agent_settings.default_profile = Some(AgentProfileId::default().0); + } + }); + + self.choose_profile(window, cx); + } + fn cancel(&mut self, window: &mut Window, cx: &mut Context) { match &self.mode { Mode::ChooseProfile { .. } => { @@ -422,7 +486,7 @@ impl ManageProfilesModal { let is_focused = profile.navigation.focus_handle.contains_focused(window, cx); div() - .id(SharedString::from(format!("profile-{}", profile.id))) + .id(format!("profile-{}", profile.id)) .track_focus(&profile.navigation.focus_handle) .on_action({ let profile_id = profile.id.clone(); @@ -431,7 +495,7 @@ impl ManageProfilesModal { }) }) .child( - ListItem::new(SharedString::from(format!("profile-{}", profile.id))) + ListItem::new(format!("profile-{}", profile.id)) .toggle_state(is_focused) .inset(true) .spacing(ListItemSpacing::Sparse) @@ -756,6 +820,40 @@ impl ManageProfilesModal { }), ), ) + .child( + div() + .id("delete-profile") + .track_focus(&mode.delete_profile.focus_handle) + .on_action({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _: &menu::Confirm, window, cx| { + this.delete_profile(profile_id.clone(), window, cx); + }) + }) + .child( + ListItem::new("delete-profile") + .toggle_state( + mode.delete_profile + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Trash) + .size(IconSize::Small) + .color(Color::Error), + ) + .child(Label::new("Delete Profile").color(Color::Error)) + .disabled(builtin_profiles::is_builtin(&mode.profile_id)) + .on_click({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _, window, cx| { + this.delete_profile(profile_id.clone(), window, cx); + }) + }), + ), + ) .child(ListSeparator) .child( div() @@ -805,6 +903,7 @@ impl ManageProfilesModal { .entry(mode.configure_default_model) .entry(mode.configure_tools) .entry(mode.configure_mcps) + .entry(mode.delete_profile) .entry(mode.cancel_item) } } @@ -852,7 +951,7 @@ impl Render for ManageProfilesModal { .on_action(cx.listener(|this, _: &menu::Cancel, window, cx| this.cancel(window, cx))) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| this.confirm(window, cx))) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent))) .child(match &self.mode { diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 53e7a2f46d37e4cd2f0688d5af2a7d4a01174801..91d345b7ebb9dae5225626d7a054d0de1882dfe0 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -130,7 +130,12 @@ impl AgentDiffPane { .action_log() .read(cx) .changed_buffers(cx); - let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::>(); + let mut paths_to_delete = self + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); for (buffer, diff_handle) in changed_buffers { if buffer.read(cx).file().is_none() { @@ -145,7 +150,7 @@ impl AgentDiffPane { let diff_hunk_ranges = diff .hunks_intersecting_range( - language::Anchor::MIN..language::Anchor::MAX, + language::Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, cx, ) @@ -207,10 +212,10 @@ impl AgentDiffPane { .focus_handle(cx) .contains_focused(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { self.editor.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }); } } @@ -493,7 +498,7 @@ impl Item for AgentDiffPane { Some("Assistant Diff Opened") } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } @@ -869,12 +874,12 @@ impl AgentDiffToolbar { match active_item { AgentDiffToolbarItem::Pane(agent_diff) => { if let Some(agent_diff) = agent_diff.upgrade() { - agent_diff.focus_handle(cx).focus(window); + agent_diff.focus_handle(cx).focus(window, cx); } } AgentDiffToolbarItem::Editor { editor, .. } => { if let Some(editor) = editor.upgrade() { - editor.read(cx).focus_handle(cx).focus(window); + editor.read(cx).focus_handle(cx).focus(window, cx); } } } diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index 43982cdda7bd887b8fd9970e836090a0e549ae11..45cefbf2b9f8d4b1639a9849f2ee2e4468e530b1 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -4,6 +4,7 @@ use crate::{ }; use fs::Fs; use gpui::{Entity, FocusHandle, SharedString}; +use language_model::IconOrSvg; use picker::popover_menu::PickerPopoverMenu; use settings::update_settings_file; use std::sync::Arc; @@ -29,26 +30,39 @@ impl AgentModelSelector { Self { selector: cx.new(move |cx| { - let fs = fs.clone(); language_model_selector( { let model_context = model_usage_context.clone(); move |cx| model_context.configured_model(cx) }, - move |model, cx| { - let provider = model.provider_id().0.to_string(); - let model_id = model.id().0.to_string(); - match &model_usage_context { - ModelUsageContext::InlineAssistant => { - update_settings_file(fs.clone(), cx, move |settings, _cx| { - settings - .agent - .get_or_insert_default() - .set_inline_assistant_model(provider.clone(), model_id); - }); + { + let fs = fs.clone(); + move |model, cx| { + let provider = model.provider_id().0.to_string(); + let model_id = model.id().0.to_string(); + match &model_usage_context { + ModelUsageContext::InlineAssistant => { + update_settings_file(fs.clone(), cx, move |settings, _cx| { + settings + .agent + .get_or_insert_default() + .set_inline_assistant_model(provider.clone(), model_id); + }); + } } } }, + { + let fs = fs.clone(); + move |model, should_be_favorite, cx| { + crate::favorite_models::toggle_in_settings( + model, + should_be_favorite, + fs.clone(), + cx, + ); + } + }, true, // Use popover styles for picker focus_handle_clone, window, @@ -63,6 +77,10 @@ impl AgentModelSelector { pub fn toggle(&self, window: &mut Window, cx: &mut Context) { self.menu_handle.toggle(window, cx); } + + pub fn active_model(&self, cx: &App) -> Option { + self.selector.read(cx).delegate.active_model(cx) + } } impl Render for AgentModelSelector { @@ -86,7 +104,14 @@ impl Render for AgentModelSelector { self.selector.clone(), ButtonLike::new("active-model") .when_some(provider_icon, |this, icon| { - this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) + this.child( + match icon { + IconOrSvg::Svg(path) => Icon::from_external_svg(path), + IconOrSvg::Icon(name) => Icon::new(name), + } + .color(color) + .size(IconSize::XSmall), + ) }) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child( diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 6ff909389986ec27b998c4554fe2d86115ef1785..a050f75120cd73949251c09c8424314e3616c705 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,10 +1,8 @@ -use std::ops::Range; -use std::path::Path; -use std::rc::Rc; -use std::sync::Arc; +use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; use acp_thread::AcpThread; use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore}; +use agent_servers::AgentServer; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use project::{ ExternalAgentServerName, @@ -20,10 +18,9 @@ use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent}; use crate::ManageProfiles; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ - AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, Follow, InlineAssistant, - NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, - ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu, - ToggleOptionsMenu, + AddContextServer, AgentDiffPane, Follow, InlineAssistant, NewTextThread, NewThread, + OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, + ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, acp::AcpThreadView, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, slash_command::SlashCommandCompletionProvider, @@ -47,9 +44,9 @@ use extension::ExtensionEvents; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, Entity, EventEmitter, - ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, Task, UpdateGlobal, - WeakEntity, prelude::*, + Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, + Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, + Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::{ConfigurationError, LanguageModelRegistry}; @@ -59,10 +56,9 @@ use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; -use ui::utils::WithRemSize; use ui::{ Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, - ProgressBar, Tab, Tooltip, prelude::*, + ProgressBar, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ @@ -264,7 +260,7 @@ impl AgentType { Self::Gemini => Some(IconName::AiGemini), Self::ClaudeCode => Some(IconName::AiClaude), Self::Codex => Some(IconName::AiOpenAi), - Self::Custom { .. } => Some(IconName::Terminal), + Self::Custom { .. } => Some(IconName::Sparkle), } } } @@ -292,7 +288,7 @@ impl ActiveView { } } - pub fn native_agent( + fn native_agent( fs: Arc, prompt_store: Option>, history_store: Entity, @@ -310,6 +306,7 @@ impl ActiveView { project, history_store, prompt_store, + false, window, cx, ) @@ -446,6 +443,7 @@ pub struct AgentPanel { pending_serialization: Option>>, onboarding: Entity, selected_agent: AgentType, + show_trust_workspace_message: bool, } impl AgentPanel { @@ -614,11 +612,14 @@ impl AgentPanel { if let Some(panel) = panel.upgrade() { menu = Self::populate_recently_opened_menu_section(menu, panel, cx); } - menu.action("View All", Box::new(OpenHistory)) - .end_slot_action(DeleteRecentlyOpenThread.boxed_clone()) + + menu = menu + .action("View All", Box::new(OpenHistory)) .fixed_width(px(320.).into()) .keep_open_on_confirm(false) - .key_context("NavigationMenu") + .key_context("NavigationMenu"); + + menu }); weak_panel .update(cx, |panel, cx| { @@ -693,6 +694,7 @@ impl AgentPanel { history_store, selected_agent: AgentType::default(), loading: false, + show_trust_workspace_message: false, }; // Initial sync of agent servers from extensions @@ -816,10 +818,11 @@ impl AgentPanel { window, cx, ), + true, window, cx, ); - text_thread_editor.focus_handle(cx).focus(window); + text_thread_editor.focus_handle(cx).focus(window, cx); } fn external_thread( @@ -885,34 +888,21 @@ impl AgentPanel { }; let server = ext_agent.server(fs, history); + this.update_in(cx, |agent_panel, window, cx| { + agent_panel._external_thread( + server, + resume_thread, + summarize_thread, + workspace, + project, + loading, + ext_agent, + window, + cx, + ); + })?; - if !loading { - telemetry::event!("Agent Thread Started", agent = server.telemetry_id()); - } - - this.update_in(cx, |this, window, cx| { - let selected_agent = ext_agent.into(); - if this.selected_agent != selected_agent { - this.selected_agent = selected_agent; - this.serialize(cx); - } - - let thread_view = cx.new(|cx| { - crate::acp::AcpThreadView::new( - server, - resume_thread, - summarize_thread, - workspace.clone(), - project, - this.history_store.clone(), - this.prompt_store.clone(), - window, - cx, - ) - }); - - this.set_active_view(ActiveView::ExternalAgentThread { thread_view }, window, cx); - }) + anyhow::Ok(()) }) .detach_and_log_err(cx); } @@ -945,7 +935,7 @@ impl AgentPanel { if let Some(thread_view) = self.active_thread_view() { thread_view.update(cx, |view, cx| { view.expand_message_editor(&ExpandMessageEditor, window, cx); - view.focus_handle(cx).focus(window); + view.focus_handle(cx).focus(window, cx); }); } } @@ -953,10 +943,10 @@ impl AgentPanel { fn open_history(&mut self, window: &mut Window, cx: &mut Context) { if matches!(self.active_view, ActiveView::History) { if let Some(previous_view) = self.previous_view.take() { - self.set_active_view(previous_view, window, cx); + self.set_active_view(previous_view, true, window, cx); } } else { - self.set_active_view(ActiveView::History, window, cx); + self.set_active_view(ActiveView::History, true, window, cx); } cx.notify(); } @@ -1012,6 +1002,7 @@ impl AgentPanel { window, cx, ), + true, window, cx, ); @@ -1025,12 +1016,12 @@ impl AgentPanel { match &self.active_view { ActiveView::ExternalAgentThread { thread_view } => { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } ActiveView::TextThread { text_thread_editor, .. } => { - text_thread_editor.focus_handle(cx).focus(window); + text_thread_editor.focus_handle(cx).focus(window, cx); } ActiveView::History | ActiveView::Configuration => {} } @@ -1157,7 +1148,7 @@ impl AgentPanel { let context_server_store = self.project.read(cx).context_server_store(); let fs = self.fs.clone(); - self.set_active_view(ActiveView::Configuration, window, cx); + self.set_active_view(ActiveView::Configuration, true, window, cx); self.configuration = Some(cx.new(|cx| { AgentConfiguration::new( fs, @@ -1178,7 +1169,7 @@ impl AgentPanel { Self::handle_agent_configuration_event, )); - configuration.focus_handle(cx).focus(window); + configuration.focus_handle(cx).focus(window, cx); } } @@ -1274,6 +1265,7 @@ impl AgentPanel { fn set_active_view( &mut self, new_view: ActiveView, + focus: bool, window: &mut Window, cx: &mut Context, ) { @@ -1312,7 +1304,9 @@ impl AgentPanel { self.active_view = new_view; } - self.focus_handle(cx).focus(window); + if focus { + self.focus_handle(cx).focus(window, cx); + } } fn populate_recently_opened_menu_section( @@ -1471,6 +1465,47 @@ impl AgentPanel { cx, ); } + + fn _external_thread( + &mut self, + server: Rc, + resume_thread: Option, + summarize_thread: Option, + workspace: WeakEntity, + project: Entity, + loading: bool, + ext_agent: ExternalAgent, + window: &mut Window, + cx: &mut Context, + ) { + let selected_agent = AgentType::from(ext_agent); + if self.selected_agent != selected_agent { + self.selected_agent = selected_agent; + self.serialize(cx); + } + + let thread_view = cx.new(|cx| { + crate::acp::AcpThreadView::new( + server, + resume_thread, + summarize_thread, + workspace.clone(), + project, + self.history_store.clone(), + self.prompt_store.clone(), + !loading, + window, + cx, + ) + }); + + self.set_active_view( + ActiveView::ExternalAgentThread { thread_view }, + !loading, + window, + cx, + ); + } } impl Focusable for AgentPanel { @@ -1585,14 +1620,19 @@ impl AgentPanel { let content = match &self.active_view { ActiveView::ExternalAgentThread { thread_view } => { + let is_generating_title = thread_view + .read(cx) + .as_native_thread(cx) + .map_or(false, |t| t.read(cx).is_generating_title()); + if let Some(title_editor) = thread_view.read(cx).title_editor() { - div() + let container = div() .w_full() .on_action({ let thread_view = thread_view.downgrade(); move |_: &menu::Confirm, window, cx| { if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } } }) @@ -1600,12 +1640,25 @@ impl AgentPanel { let thread_view = thread_view.downgrade(); move |_: &editor::actions::Cancel, window, cx| { if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } } }) - .child(title_editor) - .into_any_element() + .child(title_editor); + + if is_generating_title { + container + .with_animation( + "generating_title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |div, delta| div.opacity(delta), + ) + .into_any_element() + } else { + container.into_any_element() + } } else { Label::new(thread_view.read(cx).title(cx)) .color(Color::Muted) @@ -1635,6 +1688,13 @@ impl AgentPanel { Label::new(LOADING_SUMMARY_PLACEHOLDER) .truncate() .color(Color::Muted) + .with_animation( + "generating_title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) .into_any_element() } } @@ -1678,6 +1738,25 @@ impl AgentPanel { .into_any() } + fn handle_regenerate_thread_title(thread_view: Entity, cx: &mut App) { + thread_view.update(cx, |thread_view, cx| { + if let Some(thread) = thread_view.as_native_thread(cx) { + thread.update(cx, |thread, cx| { + thread.generate_title(cx); + }); + } + }); + } + + fn handle_regenerate_text_thread_title( + text_thread_editor: Entity, + cx: &mut App, + ) { + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor.regenerate_summary(cx); + }); + } + fn render_panel_options_menu( &self, window: &mut Window, @@ -1697,6 +1776,35 @@ impl AgentPanel { let selected_agent = self.selected_agent.clone(); + let text_thread_view = match &self.active_view { + ActiveView::TextThread { + text_thread_editor, .. + } => Some(text_thread_editor.clone()), + _ => None, + }; + let text_thread_with_messages = match &self.active_view { + ActiveView::TextThread { + text_thread_editor, .. + } => text_thread_editor + .read(cx) + .text_thread() + .read(cx) + .messages(cx) + .any(|message| message.role == language_model::Role::Assistant), + _ => false, + }; + + let thread_view = match &self.active_view { + ActiveView::ExternalAgentThread { thread_view } => Some(thread_view.clone()), + _ => None, + }; + let thread_with_messages = match &self.active_view { + ActiveView::ExternalAgentThread { thread_view } => { + thread_view.read(cx).has_user_submitted_prompt(cx) + } + _ => false, + }; + PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( IconButton::new("agent-options-menu", IconName::Ellipsis) @@ -1719,6 +1827,7 @@ impl AgentPanel { move |window, cx| { Some(ContextMenu::build(window, cx, |mut menu, _window, _| { menu = menu.context(focus_handle.clone()); + if let Some(usage) = usage { menu = menu .header_with_link("Prompt Usage", "Manage", account_url.clone()) @@ -1756,6 +1865,38 @@ impl AgentPanel { .separator() } + if thread_with_messages | text_thread_with_messages { + menu = menu.header("Current Thread"); + + if let Some(text_thread_view) = text_thread_view.as_ref() { + menu = menu + .entry("Regenerate Thread Title", None, { + let text_thread_view = text_thread_view.clone(); + move |_, cx| { + Self::handle_regenerate_text_thread_title( + text_thread_view.clone(), + cx, + ); + } + }) + .separator(); + } + + if let Some(thread_view) = thread_view.as_ref() { + menu = menu + .entry("Regenerate Thread Title", None, { + let thread_view = thread_view.clone(); + move |_, cx| { + Self::handle_regenerate_thread_title( + thread_view.clone(), + cx, + ); + } + }) + .separator(); + } + } + menu = menu .header("MCP Servers") .action( @@ -1845,14 +1986,17 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); - // Get custom icon path for selected agent before building menu (to avoid borrow issues) - let selected_agent_custom_icon = + let (selected_agent_custom_icon, selected_agent_label) = if let AgentType::Custom { name, .. } = &self.selected_agent { - agent_server_store - .read(cx) - .agent_icon(&ExternalAgentServerName(name.clone())) + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + + let label = store + .agent_display_name(&ExternalAgentServerName(name.clone())) + .unwrap_or_else(|| self.selected_agent.label()); + (icon, label) } else { - None + (None, self.selected_agent.label()) }; let active_thread = match &self.active_view { @@ -2075,13 +2219,16 @@ impl AgentPanel { for agent_name in agent_names { let icon_path = agent_server_store.agent_icon(&agent_name); + let display_name = agent_server_store + .agent_display_name(&agent_name) + .unwrap_or_else(|| agent_name.0.clone()); - let mut entry = ContextMenuEntry::new(agent_name.clone()); + let mut entry = ContextMenuEntry::new(display_name); if let Some(icon_path) = icon_path { entry = entry.custom_icon_svg(icon_path); } else { - entry = entry.icon(IconName::Terminal); + entry = entry.icon(IconName::Sparkle); } entry = entry .when( @@ -2145,30 +2292,41 @@ impl AgentPanel { } }); - let selected_agent_label = self.selected_agent.label(); + let is_thread_loading = self + .active_thread_view() + .map(|thread| thread.read(cx).is_loading()) + .unwrap_or(false); let has_custom_icon = selected_agent_custom_icon.is_some(); + let selected_agent = div() .id("selected_agent_icon") .when_some(selected_agent_custom_icon, |this, icon_path| { - let label = selected_agent_label.clone(); this.px_1() .child(Icon::from_external_svg(icon_path).color(Color::Muted)) - .tooltip(move |_window, cx| { - Tooltip::with_meta(label.clone(), None, "Selected Agent", cx) - }) }) .when(!has_custom_icon, |this| { this.when_some(self.selected_agent.icon(), |this, icon| { - let label = selected_agent_label.clone(); - this.px_1() - .child(Icon::new(icon).color(Color::Muted)) - .tooltip(move |_window, cx| { - Tooltip::with_meta(label.clone(), None, "Selected Agent", cx) - }) + this.px_1().child(Icon::new(icon).color(Color::Muted)) }) }) - .into_any_element(); + .tooltip(move |_, cx| { + Tooltip::with_meta(selected_agent_label.clone(), None, "Selected Agent", cx) + }); + + let selected_agent = if is_thread_loading { + selected_agent + .with_animation( + "pulsating-icon", + Animation::new(Duration::from_secs(1)) + .repeat() + .with_easing(pulsating_between(0.2, 0.6)), + |icon, delta| icon.opacity(delta), + ) + .into_any_element() + } else { + selected_agent.into_any_element() + }; h_flex() .id("agent-panel-toolbar") @@ -2270,7 +2428,7 @@ impl AgentPanel { let history_is_empty = self.history_store.read(cx).is_empty(cx); let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) - .providers() + .visible_providers() .iter() .any(|provider| { provider.is_authenticated(cx) @@ -2534,6 +2692,38 @@ impl AgentPanel { } } + fn render_workspace_trust_message(&self, cx: &Context) -> Option { + if !self.show_trust_workspace_message { + return None; + } + + let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe."; + + Some( + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .border_position(ui::BorderPosition::Bottom) + .title("You're in Restricted Mode") + .description(description) + .actions_slot( + Button::new("open-trust-modal", "Configure Project Trust") + .label_size(LabelSize::Small) + .style(ButtonStyle::Outlined) + .on_click({ + cx.listener(move |this, _, window, cx| { + this.workspace + .update(cx, |workspace, cx| { + workspace + .show_worktree_trust_security_modal(true, window, cx) + }) + .log_err(); + }) + }), + ), + ) + } + fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); @@ -2586,6 +2776,7 @@ impl Render for AgentPanel { } })) .child(self.render_toolbar(window, cx)) + .children(self.render_workspace_trust_message(cx)) .children(self.render_onboarding(window, cx)) .map(|parent| match &self.active_view { ActiveView::ExternalAgentThread { thread_view, .. } => parent @@ -2664,16 +2855,17 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { return; }; let project = workspace.read(cx).project().downgrade(); + let thread_store = panel.read(cx).thread_store().clone(); assistant.assist( prompt_editor, self.workspace.clone(), project, - panel.read(cx).thread_store().clone(), + thread_store, None, initial_prompt, window, cx, - ) + ); }) } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index ae4cb70d4af419184519afb53ab62849b8a0eab8..401b506b302d9c2a86a36ddce0fc72df075f4c18 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,4 +1,4 @@ -mod acp; +pub mod acp; mod agent_configuration; mod agent_diff; mod agent_model_selector; @@ -7,6 +7,7 @@ mod buffer_codegen; mod completion_provider; mod context; mod context_server_configuration; +mod favorite_models; mod inline_assistant; mod inline_prompt_editor; mod language_model_selector; @@ -26,7 +27,7 @@ use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; use command_palette_hooks::CommandPaletteFilter; -use feature_flags::FeatureFlagAppExt as _; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{Action, App, Entity, SharedString, actions}; use language::{ @@ -67,8 +68,12 @@ actions!( ToggleProfileSelector, /// Cycles through available session modes. CycleModeSelector, + /// Cycles through favorited models in the ACP model selector. + CycleFavoriteModels, /// Expands the message editor to full size. ExpandMessageEditor, + /// Removes all thread history. + RemoveHistory, /// Opens the conversation history view. OpenHistory, /// Adds a context server to the configuration. @@ -156,16 +161,6 @@ pub enum ExternalAgent { } impl ExternalAgent { - pub fn parse_built_in(server: &dyn agent_servers::AgentServer) -> Option { - match server.telemetry_id() { - "gemini-cli" => Some(Self::Gemini), - "claude-code" => Some(Self::ClaudeCode), - "codex" => Some(Self::Codex), - "zed" => Some(Self::NativeAgent), - _ => None, - } - } - pub fn server( &self, fs: Arc, @@ -222,7 +217,7 @@ pub fn init( is_eval: bool, cx: &mut App, ) { - assistant_text_thread::init(client.clone(), cx); + assistant_text_thread::init(client, cx); rules_library::init(cx); if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when @@ -235,13 +230,8 @@ pub fn init( TextThreadEditor::init(cx); register_slash_commands(cx); - inline_assistant::init( - fs.clone(), - prompt_builder.clone(), - client.telemetry().clone(), - cx, - ); - terminal_inline_assistant::init(fs.clone(), prompt_builder, client.telemetry().clone(), cx); + inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); + terminal_inline_assistant::init(fs.clone(), prompt_builder, cx); cx.observe_new(move |workspace, window, cx| { ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) }) @@ -257,23 +247,31 @@ pub fn init( update_command_palette_filter(app_cx); }) .detach(); + + cx.on_flags_ready(|_, cx| { + update_command_palette_filter(cx); + }) + .detach(); } fn update_command_palette_filter(cx: &mut App) { let disable_ai = DisableAiSettings::get_global(cx).disable_ai; let agent_enabled = AgentSettings::get_global(cx).enabled; + let agent_v2_enabled = cx.has_flag::(); let edit_prediction_provider = AllLanguageSettings::get_global(cx) .edit_predictions .provider; CommandPaletteFilter::update_global(cx, |filter, _| { use editor::actions::{ - AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction, - PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, + AcceptEditPrediction, AcceptNextLineEditPrediction, AcceptNextWordEditPrediction, + NextEditPrediction, PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, }; let edit_prediction_actions = [ TypeId::of::(), - TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), TypeId::of::(), TypeId::of::(), TypeId::of::(), @@ -282,6 +280,7 @@ fn update_command_palette_filter(cx: &mut App) { if disable_ai { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); filter.hide_namespace("assistant"); filter.hide_namespace("copilot"); filter.hide_namespace("supermaven"); @@ -293,8 +292,10 @@ fn update_command_palette_filter(cx: &mut App) { } else { if agent_enabled { filter.show_namespace("agent"); + filter.show_namespace("agents"); } else { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); } filter.show_namespace("assistant"); @@ -330,6 +331,9 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("zed_predict_onboarding"); filter.show_action_types(&[TypeId::of::()]); + if !agent_v2_enabled { + filter.hide_action_types(&[TypeId::of::()]); + } } }); } @@ -344,7 +348,8 @@ fn init_language_model_settings(cx: &mut App) { |_, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) - | language_model::Event::RemovedProvider(_) => { + | language_model::Event::RemovedProvider(_) + | language_model::Event::ProvidersChanged => { update_active_language_model_from_settings(cx); } _ => {} @@ -428,7 +433,7 @@ mod tests { use gpui::{BorrowAppContext, TestAppContext, px}; use project::DisableAiSettings; use settings::{ - DefaultAgentView, DockPosition, NotifyWhenAgentWaiting, Settings, SettingsStore, + DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting, Settings, SettingsStore, }; #[gpui::test] @@ -447,13 +452,16 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, + agents_panel_dock: DockSide::Left, default_width: px(300.), default_height: px(600.), default_model: None, inline_assistant_model: None, + inline_assistant_use_streaming_tools: false, commit_message_model: None, thread_summary_model: None, inline_alternatives: vec![], + favorite_models: vec![], default_profile: AgentProfileId::default(), default_view: DefaultAgentView::Thread, profiles: Default::default(), diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 647437770604b766ab054cb66fc6c8e154402ab7..a296d4d20918fba6eb32bfcf7fcc657f9db2b3ac 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,26 +1,34 @@ use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus}; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; +use uuid::Uuid; + use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; +use feature_flags::{FeatureFlagAppExt as _, InlineAssistantUseToolFeatureFlag}; use futures::{ SinkExt, Stream, StreamExt, TryStreamExt as _, channel::mpsc, future::{LocalBoxFuture, Shared}, join, + stream::BoxStream, }; -use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Subscription, Task}; -use language::{Buffer, IndentKind, Point, TransactionId, line_diff}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; +use language::{Buffer, IndentKind, LanguageName, Point, TransactionId, line_diff}; use language_model::{ - LanguageModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelTextStream, Role, report_assistant_event, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelRequestTool, LanguageModelTextStream, LanguageModelToolChoice, + LanguageModelToolUse, Role, TokenUsage, }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use prompt_store::PromptBuilder; use rope::Rope; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings as _; use smol::future::FutureExt; use std::{ cmp, @@ -33,7 +41,26 @@ use std::{ time::Instant, }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + +/// Use this tool when you cannot or should not make a rewrite. This includes: +/// - The user's request is unclear, ambiguous, or nonsensical +/// - The requested change cannot be made by only editing the section +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct FailureMessageInput { + /// A brief message to the user explaining why you're unable to fulfill the request or to ask a question about the request. + #[serde(default)] + pub message: String, +} + +/// Replaces text in tags with your replacement_text. +/// Only use this tool when you are confident you understand the user's request and can fulfill it +/// by editing the marked section. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct RewriteSectionInput { + /// The text to replace the section with. + #[serde(default)] + pub replacement_text: String, +} pub struct BufferCodegen { alternatives: Vec>, @@ -43,17 +70,20 @@ pub struct BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - telemetry: Arc, builder: Arc, pub is_insertion: bool, + session_id: Uuid, } +pub const REWRITE_SECTION_TOOL_NAME: &str = "rewrite_section"; +pub const FAILURE_MESSAGE_TOOL_NAME: &str = "failure_message"; + impl BufferCodegen { pub fn new( buffer: Entity, range: Range, initial_transaction_id: Option, - telemetry: Arc, + session_id: Uuid, builder: Arc, cx: &mut Context, ) -> Self { @@ -62,8 +92,8 @@ impl BufferCodegen { buffer.clone(), range.clone(), false, - Some(telemetry.clone()), builder.clone(), + session_id, cx, ) }); @@ -76,8 +106,8 @@ impl BufferCodegen { buffer, range, initial_transaction_id, - telemetry, builder, + session_id, }; this.activate(0, cx); this @@ -92,10 +122,18 @@ impl BufferCodegen { .push(cx.subscribe(&codegen, |_, _, event, cx| cx.emit(*event))); } + pub fn active_completion(&self, cx: &App) -> Option { + self.active_alternative().read(cx).current_completion() + } + pub fn active_alternative(&self) -> &Entity { &self.alternatives[self.active_alternative] } + pub fn language_name(&self, cx: &App) -> Option { + self.active_alternative().read(cx).language_name(cx) + } + pub fn status<'a>(&self, cx: &'a App) -> &'a CodegenStatus { &self.active_alternative().read(cx).status } @@ -154,8 +192,8 @@ impl BufferCodegen { self.buffer.clone(), self.range.clone(), false, - Some(self.telemetry.clone()), self.builder.clone(), + self.session_id, cx, ) })); @@ -214,6 +252,14 @@ impl BufferCodegen { pub fn last_equal_ranges<'a>(&self, cx: &'a App) -> &'a [Range] { self.active_alternative().read(cx).last_equal_ranges() } + + pub fn selected_text<'a>(&self, cx: &'a App) -> Option<&'a str> { + self.active_alternative().read(cx).selected_text() + } + + pub fn session_id(&self) -> Uuid { + self.session_id + } } impl EventEmitter for BufferCodegen {} @@ -229,7 +275,6 @@ pub struct CodegenAlternative { status: CodegenStatus, generation: Task<()>, diff: Diff, - telemetry: Option>, _subscription: gpui::Subscription, builder: Arc, active: bool, @@ -237,7 +282,11 @@ pub struct CodegenAlternative { line_operations: Vec, elapsed_time: Option, completion: Option, + selected_text: Option, pub message_id: Option, + session_id: Uuid, + pub description: Option, + pub failure: Option, } impl EventEmitter for CodegenAlternative {} @@ -247,8 +296,8 @@ impl CodegenAlternative { buffer: Entity, range: Range, active: bool, - telemetry: Option>, builder: Arc, + session_id: Uuid, cx: &mut Context, ) -> Self { let snapshot = buffer.read(cx).snapshot(cx); @@ -271,7 +320,7 @@ impl CodegenAlternative { let mut buffer = Buffer::local_normalized(text, line_ending, cx); buffer.set_language(language, cx); if let Some(language_registry) = language_registry { - buffer.set_language_registry(language_registry) + buffer.set_language_registry(language_registry); } buffer }); @@ -287,18 +336,28 @@ impl CodegenAlternative { status: CodegenStatus::Idle, generation: Task::ready(()), diff: Diff::default(), - telemetry, - _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), builder, - active, + active: active, edits: Vec::new(), line_operations: Vec::new(), range, elapsed_time: None, completion: None, + selected_text: None, + session_id, + description: None, + failure: None, + _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), } } + pub fn language_name(&self, cx: &App) -> Option { + self.old_buffer + .read(cx) + .language() + .map(|language| language.name()) + } + pub fn set_active(&mut self, active: bool, cx: &mut Context) { if active != self.active { self.active = active; @@ -340,6 +399,12 @@ impl CodegenAlternative { &self.last_equal_ranges } + pub fn use_streaming_tools(model: &dyn LanguageModel, cx: &App) -> bool { + model.supports_streaming_tools() + && cx.has_flag::() + && AgentSettings::get_global(cx).inline_assistant_use_streaming_tools + } + pub fn start( &mut self, user_prompt: String, @@ -347,6 +412,9 @@ impl CodegenAlternative { model: Arc, cx: &mut Context, ) -> Result<()> { + // Clear the model explanation since the user has started a new generation. + self.description = None; + if let Some(transformation_transaction_id) = self.transformation_transaction_id.take() { self.buffer.update(cx, |buffer, cx| { buffer.undo_transaction(transformation_transaction_id, cx); @@ -355,21 +423,132 @@ impl CodegenAlternative { self.edit_position = Some(self.range.start.bias_right(&self.snapshot)); - let api_key = model.api_key(cx); - let telemetry_id = model.telemetry_id(); - let provider_id = model.provider_id(); - let stream: LocalBoxFuture> = - if user_prompt.trim().to_lowercase() == "delete" { - async { Ok(LanguageModelTextStream::default()) }.boxed_local() + if Self::use_streaming_tools(model.as_ref(), cx) { + let request = self.build_request(&model, user_prompt, context_task, cx)?; + let completion_events = cx.spawn({ + let model = model.clone(); + async move |_, cx| model.stream_completion(request.await, cx).await + }); + self.generation = self.handle_completion(model, completion_events, cx); + } else { + let stream: LocalBoxFuture> = + if user_prompt.trim().to_lowercase() == "delete" { + async { Ok(LanguageModelTextStream::default()) }.boxed_local() + } else { + let request = self.build_request(&model, user_prompt, context_task, cx)?; + cx.spawn({ + let model = model.clone(); + async move |_, cx| { + Ok(model.stream_completion_text(request.await, cx).await?) + } + }) + .boxed_local() + }; + self.generation = + self.handle_stream(model, /* strip_invalid_spans: */ true, stream, cx); + } + + Ok(()) + } + + fn build_request_tools( + &self, + model: &Arc, + user_prompt: String, + context_task: Shared>>, + cx: &mut App, + ) -> Result> { + let buffer = self.buffer.read(cx).snapshot(cx); + let language = buffer.language_at(self.range.start); + let language_name = if let Some(language) = language.as_ref() { + if Arc::ptr_eq(language, &language::PLAIN_TEXT) { + None } else { - let request = self.build_request(&model, user_prompt, context_task, cx)?; - cx.spawn(async move |_, cx| { - Ok(model.stream_completion_text(request.await, cx).await?) - }) - .boxed_local() + Some(language.name()) + } + } else { + None + }; + + let language_name = language_name.as_ref(); + let start = buffer.point_to_buffer_offset(self.range.start); + let end = buffer.point_to_buffer_offset(self.range.end); + let (buffer, range) = if let Some((start, end)) = start.zip(end) { + let (start_buffer, start_buffer_offset) = start; + let (end_buffer, end_buffer_offset) = end; + if start_buffer.remote_id() == end_buffer.remote_id() { + (start_buffer.clone(), start_buffer_offset..end_buffer_offset) + } else { + anyhow::bail!("invalid transformation range"); + } + } else { + anyhow::bail!("invalid transformation range"); + }; + + let system_prompt = self + .builder + .generate_inline_transformation_prompt_tools( + language_name, + buffer, + range.start.0..range.end.0, + ) + .context("generating content prompt")?; + + let temperature = AgentSettings::temperature_for_model(model, cx); + + let tool_input_format = model.tool_input_format(); + let tool_choice = model + .supports_tool_choice(LanguageModelToolChoice::Any) + .then_some(LanguageModelToolChoice::Any); + + Ok(cx.spawn(async move |_cx| { + let mut messages = vec![LanguageModelRequestMessage { + role: Role::System, + content: vec![system_prompt.into()], + cache: false, + reasoning_details: None, + }]; + + let mut user_message = LanguageModelRequestMessage { + role: Role::User, + content: Vec::new(), + cache: false, + reasoning_details: None, }; - self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx); - Ok(()) + + if let Some(context) = context_task.await { + context.add_to_request_message(&mut user_message); + } + + user_message.content.push(user_prompt.into()); + messages.push(user_message); + + let tools = vec![ + LanguageModelRequestTool { + name: REWRITE_SECTION_TOOL_NAME.to_string(), + description: "Replaces text in tags with your replacement_text.".to_string(), + input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + }, + LanguageModelRequestTool { + name: FAILURE_MESSAGE_TOOL_NAME.to_string(), + description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), + input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + }, + ]; + + LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: Some(CompletionIntent::InlineAssist), + mode: None, + tools, + tool_choice, + stop: Vec::new(), + temperature, + messages, + thinking_allowed: false, + } + })) } fn build_request( @@ -379,6 +558,10 @@ impl CodegenAlternative { context_task: Shared>>, cx: &mut App, ) -> Result> { + if Self::use_streaming_tools(model.as_ref(), cx) { + return self.build_request_tools(model, user_prompt, context_task, cx); + } + let buffer = self.buffer.read(cx).snapshot(cx); let language = buffer.language_at(self.range.start); let language_name = if let Some(language) = language.as_ref() { @@ -449,12 +632,15 @@ impl CodegenAlternative { pub fn handle_stream( &mut self, - model_telemetry_id: String, - model_provider_id: String, - model_api_key: Option, + model: Arc, + strip_invalid_spans: bool, stream: impl 'static + Future>, cx: &mut Context, - ) { + ) -> Task<()> { + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); let start_time = Instant::now(); // Make a new snapshot and re-resolve anchor in case the document was modified. @@ -469,6 +655,8 @@ impl CodegenAlternative { .text_for_range(self.range.start..self.range.end) .collect::(); + self.selected_text = Some(selected_text.to_string()); + let selection_start = self.range.start.to_point(&snapshot); // Start with the indentation of the first line in the selection @@ -490,8 +678,6 @@ impl CodegenAlternative { } } - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); let language_name = { let multibuffer = self.buffer.read(cx); let snapshot = multibuffer.snapshot(cx); @@ -508,8 +694,10 @@ impl CodegenAlternative { let completion = Arc::new(Mutex::new(String::new())); let completion_clone = completion.clone(); - self.generation = cx.spawn(async move |codegen, cx| { + cx.notify(); + cx.spawn(async move |codegen, cx| { let stream = stream.await; + let token_usage = stream .as_ref() .ok() @@ -522,17 +710,25 @@ impl CodegenAlternative { let model_telemetry_id = model_telemetry_id.clone(); let model_provider_id = model_provider_id.clone(); let (mut diff_tx, mut diff_rx) = mpsc::channel(1); - let executor = cx.background_executor().clone(); let message_id = message_id.clone(); - let line_based_stream_diff: Task> = - cx.background_spawn(async move { + let line_based_stream_diff: Task> = cx.background_spawn({ + let anthropic_reporter = anthropic_reporter.clone(); + let language_name = language_name.clone(); + async move { let mut response_latency = None; let request_start = Instant::now(); let diff = async { - let chunks = StripInvalidSpans::new( - stream?.stream.map_err(|error| error.into()), - ); - futures::pin_mut!(chunks); + let raw_stream = stream?.stream.map_err(|error| error.into()); + + let stripped; + let mut chunks: Pin> + Send>> = + if strip_invalid_spans { + stripped = StripInvalidSpans::new(raw_stream); + Box::pin(stripped) + } else { + Box::pin(raw_stream) + }; + let mut diff = StreamingDiff::new(selected_text.to_string()); let mut line_diff = LineDiff::default(); @@ -621,27 +817,30 @@ impl CodegenAlternative { let result = diff.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - message_id, - kind: AssistantKind::Inline, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id, - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - telemetry, - http_client, - model_api_key, - &executor, + telemetry::event!( + "Assistant Responded", + kind = "inline", + phase = "response", + session_id = session_id.to_string(), + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name.as_ref().map(|n| n.to_string()), + message_id = message_id.as_deref(), + response_latency = response_latency, + error_message = error_message.as_deref(), ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Response, + language_name: language_name.map(|n| n.to_string()), + message_id, + }); + result?; Ok(()) - }); + } + }); while let Some((char_ops, line_ops)) = diff_rx.next().await { codegen.update(cx, |codegen, cx| { @@ -719,12 +918,30 @@ impl CodegenAlternative { output_tokens = usage.output_tokens, ) } + cx.emit(CodegenEvent::Finished); cx.notify(); }) .ok(); - }); - cx.notify(); + }) + } + + pub fn current_completion(&self) -> Option { + self.completion.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_description(&self) -> Option { + self.description.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_failure(&self) -> Option { + self.failure.clone() + } + + pub fn selected_text(&self) -> Option<&str> { + self.selected_text.as_deref() } pub fn stop(&mut self, cx: &mut Context) { @@ -898,6 +1115,224 @@ impl CodegenAlternative { .ok(); }) } + + fn handle_completion( + &mut self, + model: Arc, + completion_stream: Task< + Result< + BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, + >, + cx: &mut Context, + ) -> Task<()> { + self.diff = Diff::default(); + self.status = CodegenStatus::Pending; + + cx.notify(); + // Leaving this in generation so that STOP equivalent events are respected even + // while we're still pre-processing the completion event + cx.spawn(async move |codegen, cx| { + let finish_with_status = |status: CodegenStatus, cx: &mut AsyncApp| { + let _ = codegen.update(cx, |this, cx| { + this.status = status; + cx.emit(CodegenEvent::Finished); + cx.notify(); + }); + }; + + let mut completion_events = match completion_stream.await { + Ok(events) => events, + Err(err) => { + finish_with_status(CodegenStatus::Error(err.into()), cx); + return; + } + }; + + enum ToolUseOutput { + Rewrite { + text: String, + description: Option, + }, + Failure(String), + } + + enum ModelUpdate { + Description(String), + Failure(String), + } + + let chars_read_so_far = Arc::new(Mutex::new(0usize)); + let process_tool_use = move |tool_use: LanguageModelToolUse| -> Option { + let mut chars_read_so_far = chars_read_so_far.lock(); + match tool_use.name.as_ref() { + REWRITE_SECTION_TOOL_NAME => { + let Ok(input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + let text = input.replacement_text[*chars_read_so_far..].to_string(); + *chars_read_so_far = input.replacement_text.len(); + Some(ToolUseOutput::Rewrite { + text, + description: None, + }) + } + FAILURE_MESSAGE_TOOL_NAME => { + let Ok(mut input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + Some(ToolUseOutput::Failure(std::mem::take(&mut input.message))) + } + _ => None, + } + }; + + let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded::(); + + cx.spawn({ + let codegen = codegen.clone(); + async move |cx| { + while let Some(update) = message_rx.next().await { + let _ = codegen.update(cx, |this, _cx| match update { + ModelUpdate::Description(d) => this.description = Some(d), + ModelUpdate::Failure(f) => this.failure = Some(f), + }); + } + } + }) + .detach(); + + let mut message_id = None; + let mut first_text = None; + let last_token_usage = Arc::new(Mutex::new(TokenUsage::default())); + let total_text = Arc::new(Mutex::new(String::new())); + + loop { + if let Some(first_event) = completion_events.next().await { + match first_event { + Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => { + message_id = Some(id); + } + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + if let Some(output) = process_tool_use(tool_use) { + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.unbounded_send(update); + } + first_text = text; + if first_text.is_some() { + break; + } + } + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + } + Ok(e) => { + log::warn!("Unexpected event: {:?}", e); + break; + } + Err(e) => { + finish_with_status(CodegenStatus::Error(e.into()), cx); + break; + } + } + } + } + + let Some(first_text) = first_text else { + finish_with_status(CodegenStatus::Done, cx); + return; + }; + + let move_last_token_usage = last_token_usage.clone(); + + let text_stream = Box::pin(futures::stream::once(async { Ok(first_text) }).chain( + completion_events.filter_map(move |e| { + let process_tool_use = process_tool_use.clone(); + let last_token_usage = move_last_token_usage.clone(); + let total_text = total_text.clone(); + let mut message_tx = message_tx.clone(); + async move { + match e { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + let Some(output) = process_tool_use(tool_use) else { + return None; + }; + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.send(update).await; + } + text.map(Ok) + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + None + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + None + } + Ok(LanguageModelCompletionEvent::Stop(_reason)) => None, + e => { + log::error!("UNEXPECTED EVENT {:?}", e); + None + } + } + } + }), + )); + + let language_model_text_stream = LanguageModelTextStream { + message_id: message_id, + stream: text_stream, + last_token_usage, + }; + + let Some(task) = codegen + .update(cx, move |codegen, cx| { + codegen.handle_stream( + model, + /* strip_invalid_spans: */ false, + async { Ok(language_model_text_stream) }, + cx, + ) + }) + .ok() + else { + return; + }; + + task.await; + }) + } } #[derive(Copy, Clone, Debug)] @@ -1059,8 +1494,13 @@ mod tests { }; use gpui::TestAppContext; use indoc::indoc; - use language::{Buffer, Language, LanguageConfig, LanguageMatcher, Point, tree_sitter_rust}; - use language_model::{LanguageModelRegistry, TokenUsage}; + use language::{Buffer, Point}; + use language_model::fake_provider::FakeLanguageModel; + use language_model::{ + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry, + LanguageModelToolUse, StopReason, TokenUsage, + }; + use languages::rust_lang; use rand::prelude::*; use settings::SettingsStore; use std::{future, sync::Arc}; @@ -1077,7 +1517,7 @@ mod tests { } } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); @@ -1089,8 +1529,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1139,7 +1579,7 @@ mod tests { le } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); @@ -1151,8 +1591,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1203,7 +1643,7 @@ mod tests { " \n", "}\n" // ); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); @@ -1215,8 +1655,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1279,8 +1719,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1319,7 +1759,7 @@ mod tests { let x = 0; } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); @@ -1331,8 +1771,8 @@ mod tests { buffer.clone(), range.clone(), false, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1371,6 +1811,51 @@ mod tests { ); } + // When not streaming tool calls, we strip backticks as part of parsing the model's + // plain text response. This is a regression test for a bug where we stripped + // backticks incorrectly. + #[gpui::test] + async fn test_allows_model_to_output_backticks(cx: &mut TestAppContext) { + init_test(cx); + let text = "- Improved; `cmd+click` behavior. Now requires `cmd` to be pressed before the click starts or it doesn't run. ([#44579](https://github.com/zed-industries/zed/pull/44579); thanks [Zachiah](https://github.com/Zachiah))"; + let buffer = cx.new(|cx| Buffer::local("", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let range = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(0, 0))..snapshot.anchor_after(Point::new(0, 0)) + }); + let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); + let codegen = cx.new(|cx| { + CodegenAlternative::new( + buffer.clone(), + range.clone(), + true, + prompt_builder, + Uuid::new_v4(), + cx, + ) + }); + + let events_tx = simulate_tool_based_completion(&codegen, cx); + let chunk_len = text.find('`').unwrap(); + events_tx + .unbounded_send(rewrite_tool_use("tool_1", &text[..chunk_len], false)) + .unwrap(); + events_tx + .unbounded_send(rewrite_tool_use("tool_2", &text, true)) + .unwrap(); + events_tx + .unbounded_send(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)) + .unwrap(); + drop(events_tx); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + text + ); + } + #[gpui::test] async fn test_strip_invalid_spans_from_codeblock() { assert_chunks("Lorem ipsum dolor", "Lorem ipsum dolor").await; @@ -1421,11 +1906,11 @@ mod tests { cx: &mut TestAppContext, ) -> mpsc::UnboundedSender { let (chunks_tx, chunks_rx) = mpsc::unbounded(); + let model = Arc::new(FakeLanguageModel::default()); codegen.update(cx, |codegen, cx| { - codegen.handle_stream( - String::new(), - String::new(), - None, + codegen.generation = codegen.handle_stream( + model, + /* strip_invalid_spans: */ false, future::ready(Ok(LanguageModelTextStream { message_id: None, stream: chunks_rx.map(Ok).boxed(), @@ -1437,26 +1922,38 @@ mod tests { chunks_tx } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_indents_query( - r#" - (call_expression) @indent - (field_expression) @indent - (_ "(" ")" @end) @indent - (_ "{" "}" @end) @indent - "#, - ) - .unwrap() + fn simulate_tool_based_completion( + codegen: &Entity, + cx: &mut TestAppContext, + ) -> mpsc::UnboundedSender { + let (events_tx, events_rx) = mpsc::unbounded(); + let model = Arc::new(FakeLanguageModel::default()); + codegen.update(cx, |codegen, cx| { + let completion_stream = Task::ready(Ok(events_rx.map(Ok).boxed() + as BoxStream< + 'static, + Result, + >)); + codegen.generation = codegen.handle_completion(model, completion_stream, cx); + }); + events_tx + } + + fn rewrite_tool_use( + id: &str, + replacement_text: &str, + is_complete: bool, + ) -> LanguageModelCompletionEvent { + let input = RewriteSectionInput { + replacement_text: replacement_text.into(), + }; + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: id.into(), + name: REWRITE_SECTION_TOOL_NAME.into(), + raw_input: serde_json::to_string(&input).unwrap(), + input: serde_json::to_value(&input).unwrap(), + is_input_complete: is_complete, + thought_signature: None, + }) } } diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 61ce313cb0c0c6ed91a08aa07544e766de5c581a..a7b955b81ef3a7edccca98f15fa73bb40787a2c9 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -7,7 +7,9 @@ use std::sync::atomic::AtomicBool; use acp_thread::MentionUri; use agent::{HistoryEntry, HistoryStore}; use anyhow::Result; -use editor::{CompletionProvider, Editor, ExcerptId}; +use editor::{ + CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH, +}; use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; @@ -18,13 +20,14 @@ use project::{ Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId, }; -use prompt_store::{PromptId, PromptStore, UserPromptId}; +use prompt_store::{PromptStore, UserPromptId}; use rope::Point; use text::{Anchor, ToPoint as _}; use ui::prelude::*; use util::ResultExt as _; use util::paths::PathStyle; use util::rel_path::RelPath; +use util::truncate_and_remove_front; use workspace::Workspace; use crate::AgentPanel; @@ -336,14 +339,20 @@ impl PromptCompletionProvider { mention_set: WeakEntity, workspace: Entity, project: Entity, + label_max_chars: usize, cx: &mut App, ) -> Option { let path_style = project.read(cx).path_style(cx); let (file_name, directory) = extract_file_name_and_directory(&project_path.path, path_prefix, path_style); - let label = - build_code_label_for_path(&file_name, directory.as_ref().map(|s| s.as_ref()), None, cx); + let label = build_code_label_for_path( + &file_name, + directory.as_ref().map(|s| s.as_ref()), + None, + label_max_chars, + cx, + ); let abs_path = project.read(cx).absolute_path(&project_path, cx)?; @@ -392,6 +401,7 @@ impl PromptCompletionProvider { editor: WeakEntity, mention_set: WeakEntity, workspace: Entity, + label_max_chars: usize, cx: &mut App, ) -> Option { let project = workspace.read(cx).project().clone(); @@ -414,6 +424,7 @@ impl PromptCompletionProvider { &symbol.name, Some(&file_name), Some(symbol.range.start.0.row + 1), + label_max_chars, cx, ); @@ -852,7 +863,7 @@ impl CompletionProvider for PromptCompletio buffer: &Entity, buffer_position: Anchor, _trigger: CompletionContext, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Task>> { let state = buffer.update(cx, |buffer, cx| { @@ -861,7 +872,7 @@ impl CompletionProvider for PromptCompletio let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); let line = lines.next()?; - ContextCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx)) + PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx)) }); let Some(state) = state else { return Task::ready(Ok(Vec::new())); @@ -880,7 +891,7 @@ impl CompletionProvider for PromptCompletio let editor = self.editor.clone(); let mention_set = self.mention_set.downgrade(); match state { - ContextCompletion::SlashCommand(SlashCommandCompletion { + PromptCompletion::SlashCommand(SlashCommandCompletion { command, argument, .. }) => { let search_task = self.search_slash_commands(command.unwrap_or_default(), cx); @@ -943,11 +954,36 @@ impl CompletionProvider for PromptCompletio }]) }) } - ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => { + PromptCompletion::Mention(MentionCompletion { mode, argument, .. }) => { let query = argument.unwrap_or_default(); let search_task = self.search_mentions(mode, query, Arc::::default(), cx); + // Calculate maximum characters available for the full label (file_name + space + directory) + // based on maximum menu width after accounting for padding, spacing, and icon width + let label_max_chars = { + // Base06 left padding + Base06 gap + Base06 right padding + icon width + let used_pixels = DynamicSpacing::Base06.px(cx) * 3.0 + + IconSize::XSmall.rems() * window.rem_size(); + + let style = window.text_style(); + let font_id = window.text_system().resolve_font(&style.font()); + let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size()); + + // Fallback em_width of 10px matches file_finder.rs fallback for TextSize::Small + let em_width = cx + .text_system() + .em_width(font_id, font_size) + .unwrap_or(px(10.0)); + + // Calculate available pixels for text (file_name + directory) + // Using max width since dynamic_width allows the menu to expand up to this + let available_pixels = COMPLETION_MENU_MAX_WIDTH - used_pixels; + + // Convert to character count (total available for file_name + directory) + (f32::from(available_pixels) / f32::from(em_width)) as usize + }; + cx.spawn(async move |_, cx| { let matches = search_task.await; @@ -984,6 +1020,7 @@ impl CompletionProvider for PromptCompletio mention_set.clone(), workspace.clone(), project.clone(), + label_max_chars, cx, ) } @@ -996,6 +1033,7 @@ impl CompletionProvider for PromptCompletio editor.clone(), mention_set.clone(), workspace.clone(), + label_max_chars, cx, ) } @@ -1076,7 +1114,6 @@ impl CompletionProvider for PromptCompletio position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); @@ -1085,12 +1122,12 @@ impl CompletionProvider for PromptCompletio let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); if let Some(line) = lines.next() { - ContextCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx)) + PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx)) .filter(|completion| { // Right now we don't support completing arguments of slash commands let is_slash_command_with_argument = matches!( completion, - ContextCompletion::SlashCommand(SlashCommandCompletion { + PromptCompletion::SlashCommand(SlashCommandCompletion { argument: Some(_), .. }) @@ -1160,12 +1197,13 @@ fn confirm_completion_callback( }) } -enum ContextCompletion { +#[derive(Debug, PartialEq)] +enum PromptCompletion { SlashCommand(SlashCommandCompletion), Mention(MentionCompletion), } -impl ContextCompletion { +impl PromptCompletion { fn source_range(&self) -> Range { match self { Self::SlashCommand(completion) => completion.source_range.clone(), @@ -1178,15 +1216,14 @@ impl ContextCompletion { offset_to_line: usize, supported_modes: &[PromptContextType], ) -> Option { - if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) { - Some(Self::SlashCommand(command)) - } else if let Some(mention) = - MentionCompletion::try_parse(line, offset_to_line, supported_modes) - { - Some(Self::Mention(mention)) - } else { - None + if line.contains('@') { + if let Some(mention) = + MentionCompletion::try_parse(line, offset_to_line, supported_modes) + { + return Some(Self::Mention(mention)); + } } + SlashCommandCompletion::try_parse(line, offset_to_line).map(Self::SlashCommand) } } @@ -1548,13 +1585,10 @@ pub(crate) fn search_rules( if metadata.default { None } else { - match metadata.id { - PromptId::EditWorkflow => None, - PromptId::User { uuid } => Some(RulesContextEntry { - prompt_id: uuid, - title: metadata.title?, - }), - } + Some(RulesContextEntry { + prompt_id: metadata.id.as_user()?, + title: metadata.title?, + }) } }) .collect::>() @@ -1595,6 +1629,7 @@ fn build_code_label_for_path( file: &str, directory: Option<&str>, line_number: Option, + label_max_chars: usize, cx: &App, ) -> CodeLabel { let variable_highlight_id = cx @@ -1608,7 +1643,13 @@ fn build_code_label_for_path( label.push_str(" ", None); if let Some(directory) = directory { - label.push_str(directory, variable_highlight_id); + let file_name_chars = file.chars().count(); + // Account for: file_name + space (ellipsis is handled by truncate_and_remove_front) + let directory_max_chars = label_max_chars + .saturating_sub(file_name_chars) + .saturating_sub(1); + let truncated_directory = truncate_and_remove_front(directory, directory_max_chars.max(5)); + label.push_str(&truncated_directory, variable_highlight_id); } if let Some(line_number) = line_number { label.push_str(&format!(" L{}", line_number), variable_highlight_id); @@ -1653,6 +1694,38 @@ fn selection_ranges( mod tests { use super::*; + #[test] + fn test_prompt_completion_parse() { + let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol]; + + assert_eq!( + PromptCompletion::try_parse("/", 0, &supported_modes), + Some(PromptCompletion::SlashCommand(SlashCommandCompletion { + source_range: 0..1, + command: None, + argument: None, + })) + ); + + assert_eq!( + PromptCompletion::try_parse("@", 0, &supported_modes), + Some(PromptCompletion::Mention(MentionCompletion { + source_range: 0..1, + mode: None, + argument: None, + })) + ); + + assert_eq!( + PromptCompletion::try_parse("/test @file", 0, &supported_modes), + Some(PromptCompletion::Mention(MentionCompletion { + source_range: 6..11, + mode: Some(PromptContextType::File), + argument: None, + })) + ); + } + #[test] fn test_slash_command_completion_parse() { assert_eq!( diff --git a/crates/agent_ui/src/favorite_models.rs b/crates/agent_ui/src/favorite_models.rs new file mode 100644 index 0000000000000000000000000000000000000000..d8d4db976fc9916973eedd9174925fba75a06b2b --- /dev/null +++ b/crates/agent_ui/src/favorite_models.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use agent_client_protocol::ModelId; +use fs::Fs; +use language_model::LanguageModel; +use settings::{LanguageModelSelection, update_settings_file}; +use ui::App; + +fn language_model_to_selection(model: &Arc) -> LanguageModelSelection { + LanguageModelSelection { + provider: model.provider_id().to_string().into(), + model: model.id().0.to_string(), + } +} + +fn model_id_to_selection(model_id: &ModelId) -> LanguageModelSelection { + let id = model_id.0.as_ref(); + let (provider, model) = id.split_once('/').unwrap_or(("", id)); + LanguageModelSelection { + provider: provider.to_owned().into(), + model: model.to_owned(), + } +} + +pub fn toggle_in_settings( + model: Arc, + should_be_favorite: bool, + fs: Arc, + cx: &App, +) { + let selection = language_model_to_selection(&model); + update_settings_file(fs, cx, move |settings, _| { + let agent = settings.agent.get_or_insert_default(); + if should_be_favorite { + agent.add_favorite_model(selection.clone()); + } else { + agent.remove_favorite_model(&selection); + } + }); +} + +pub fn toggle_model_id_in_settings( + model_id: ModelId, + should_be_favorite: bool, + fs: Arc, + cx: &App, +) { + let selection = model_id_to_selection(&model_id); + update_settings_file(fs, cx, move |settings, _| { + let agent = settings.agent.get_or_insert_default(); + if should_be_favorite { + agent.add_favorite_model(selection.clone()); + } else { + agent.remove_favorite_model(&selection); + } + }); +} diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index f822c79f2589c757173bcd2699ef6abf2ac51027..671579f9ef018b495b7993279a852595c78d3e02 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -1,8 +1,11 @@ +use language_model::AnthropicEventData; +use language_model::report_anthropic_event; use std::cmp; use std::mem; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use uuid::Uuid; use crate::context::load_context; use crate::mention_set::MentionSet; @@ -15,7 +18,6 @@ use crate::{ use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; use collections::{HashMap, HashSet, VecDeque, hash_map}; use editor::EditorSnapshot; use editor::MultiBufferOffset; @@ -32,21 +34,19 @@ use editor::{ }, }; use fs::Fs; -use futures::FutureExt; +use futures::{FutureExt, channel::mpsc}; use gpui::{ App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal, WeakEntity, Window, point, }; use language::{Buffer, Point, Selection, TransactionId}; -use language_model::{ - ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event, -}; +use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use project::{CodeAction, DisableAiSettings, LspAction, Project, ProjectTransaction}; use prompt_store::{PromptBuilder, PromptStore}; use settings::{Settings, SettingsStore}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::{OffsetRangeExt, ToPoint as _}; use ui::prelude::*; @@ -54,13 +54,8 @@ use util::{RangeExt, ResultExt, maybe}; use workspace::{ItemHandle, Toast, Workspace, dock::Panel, notifications::NotificationId}; use zed_actions::agent::OpenSettings; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(InlineAssistant::new(fs, prompt_builder)); cx.observe_global::(|cx| { if DisableAiSettings::get_global(cx).disable_ai { @@ -100,18 +95,14 @@ pub struct InlineAssistant { confirmed_assists: HashMap>, prompt_history: VecDeque, prompt_builder: Arc, - telemetry: Arc, fs: Arc, + _inline_assistant_completions: Option>>, } impl Global for InlineAssistant {} impl InlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: InlineAssistId::default(), next_assist_group_id: InlineAssistGroupId::default(), @@ -121,8 +112,8 @@ impl InlineAssistant { confirmed_assists: HashMap::default(), prompt_history: VecDeque::default(), prompt_builder, - telemetry, fs, + _inline_assistant_completions: None, } } @@ -287,7 +278,7 @@ impl InlineAssistant { action.prompt.clone(), window, cx, - ) + ); }) } InlineAssistTarget::Terminal(active_terminal) => { @@ -301,8 +292,8 @@ impl InlineAssistant { action.prompt.clone(), window, cx, - ) - }) + ); + }); } }; @@ -377,17 +368,9 @@ impl InlineAssistant { let mut selections = Vec::>::new(); let mut newest_selection = None; for mut selection in initial_selections { - if selection.end > selection.start { - selection.start.column = 0; - // If the selection ends at the start of the line, we don't want to include it. - if selection.end.column == 0 { - selection.end.row -= 1; - } - selection.end.column = snapshot - .buffer_snapshot() - .line_len(MultiBufferRow(selection.end.row)); - } else if let Some(fold) = - snapshot.crease_for_buffer_row(MultiBufferRow(selection.end.row)) + if selection.end == selection.start + && let Some(fold) = + snapshot.crease_for_buffer_row(MultiBufferRow(selection.end.row)) { selection.start = fold.range().start; selection.end = fold.range().end; @@ -414,6 +397,15 @@ impl InlineAssistant { } } } + } else { + selection.start.column = 0; + // If the selection ends at the start of the line, we don't want to include it. + if selection.end.column == 0 && selection.start.row != selection.end.row { + selection.end.row -= 1; + } + selection.end.column = snapshot + .buffer_snapshot() + .line_len(MultiBufferRow(selection.end.row)); } if let Some(prev_selection) = selections.last_mut() @@ -440,24 +432,31 @@ impl InlineAssistant { { let anchor_range = Anchor::range_in_buffer( excerpt_id, - buffer.remote_id(), buffer.anchor_before(buffer_range.start)..buffer.anchor_after(buffer_range.end), ); codegen_ranges.push(anchor_range); if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { - self.telemetry.report_assistant_event(AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, - phase: AssistantPhase::Invoked, - message_id: None, - model: model.model.telemetry_id(), - model_provider: model.provider.id().to_string(), - response_latency: None, - error_message: None, - language_name: buffer.language().map(|language| language.name().to_proto()), - }); + telemetry::event!( + "Assistant Invoked", + kind = "inline", + phase = "invoked", + model = model.model.telemetry_id(), + model_provider = model.provider.id().to_string(), + language_name = buffer.language().map(|language| language.name().to_proto()) + ); + + report_anthropic_event( + &model.model, + AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Invoked, + language_name: buffer.language().map(|language| language.name().to_proto()), + message_id: None, + }, + cx, + ); } } @@ -481,6 +480,7 @@ impl InlineAssistant { let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let assist_group_id = self.next_assist_group_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), @@ -498,7 +498,7 @@ impl InlineAssistant { editor.read(cx).buffer().clone(), range.clone(), initial_transaction_id, - self.telemetry.clone(), + session_id, self.prompt_builder.clone(), cx, ) @@ -512,6 +512,7 @@ impl InlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen.clone(), + session_id, self.fs.clone(), thread_store.clone(), prompt_store.clone(), @@ -535,14 +536,15 @@ impl InlineAssistant { } } - let [prompt_block_id, end_block_id] = - self.insert_assist_blocks(editor, &range, &prompt_editor, cx); + let [prompt_block_id, tool_description_block_id, end_block_id] = + self.insert_assist_blocks(&editor, &range, &prompt_editor, cx); assists.push(( assist_id, range.clone(), prompt_editor, prompt_block_id, + tool_description_block_id, end_block_id, )); } @@ -561,7 +563,15 @@ impl InlineAssistant { }; let mut assist_group = InlineAssistGroup::new(); - for (assist_id, range, prompt_editor, prompt_block_id, end_block_id) in assists { + for ( + assist_id, + range, + prompt_editor, + prompt_block_id, + tool_description_block_id, + end_block_id, + ) in assists + { let codegen = prompt_editor.read(cx).codegen().clone(); self.assists.insert( @@ -572,6 +582,7 @@ impl InlineAssistant { editor, &prompt_editor, prompt_block_id, + tool_description_block_id, end_block_id, range, codegen, @@ -599,13 +610,13 @@ impl InlineAssistant { initial_prompt: Option, window: &mut Window, cx: &mut App, - ) { + ) -> Option { let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let Some((codegen_ranges, newest_selection)) = self.codegen_ranges(editor, &snapshot, window, cx) else { - return; + return None; }; let assist_to_focus = self.batch_assist( @@ -625,6 +636,8 @@ impl InlineAssistant { if let Some(assist_id) = assist_to_focus { self.focus_assist(assist_id, window, cx); } + + assist_to_focus } pub fn suggest_assist( @@ -678,7 +691,7 @@ impl InlineAssistant { range: &Range, prompt_editor: &Entity>, cx: &mut App, - ) -> [CustomBlockId; 2] { + ) -> [CustomBlockId; 3] { let prompt_editor_height = prompt_editor.update(cx, |prompt_editor, cx| { prompt_editor .editor @@ -692,6 +705,14 @@ impl InlineAssistant { render: build_assist_editor_renderer(prompt_editor), priority: 0, }, + // Placeholder for tool description - will be updated dynamically + BlockProperties { + style: BlockStyle::Flex, + placement: BlockPlacement::Below(range.end), + height: Some(0), + render: Arc::new(|_cx| div().into_any_element()), + priority: 0, + }, BlockProperties { style: BlockStyle::Sticky, placement: BlockPlacement::Below(range.end), @@ -710,7 +731,7 @@ impl InlineAssistant { editor.update(cx, |editor, cx| { let block_ids = editor.insert_blocks(assist_blocks, None, cx); - [block_ids[0], block_ids[1]] + [block_ids[0], block_ids[1], block_ids[2]] }) } @@ -1039,8 +1060,6 @@ impl InlineAssistant { } let active_alternative = assist.codegen.read(cx).active_alternative().clone(); - let message_id = active_alternative.read(cx).message_id.clone(); - if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { let language_name = assist.editor.upgrade().and_then(|editor| { let multibuffer = editor.read(cx).buffer().read(cx); @@ -1049,28 +1068,49 @@ impl InlineAssistant { ranges .first() .and_then(|(buffer, _, _)| buffer.language()) - .map(|language| language.name()) + .map(|language| language.name().0.to_string()) }); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, + + let codegen = assist.codegen.read(cx); + let session_id = codegen.session_id(); + let message_id = active_alternative.read(cx).message_id.clone(); + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + telemetry::event!( + event_type, + phase, + session_id = session_id.to_string(), + kind = "inline", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + message_id = message_id.as_deref(), + ); + + report_anthropic_event( + &model.model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: anthropic_event_type, + language_name, message_id, - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.model.telemetry_id(), - model_provider: model.model.provider_id().to_string(), - response_latency: None, - error_message: None, - language_name: language_name.map(|name| name.to_proto()), }, - Some(self.telemetry.clone()), - cx.http_client(), - model.model.api_key(cx), - cx.background_executor(), + cx, ); } @@ -1102,6 +1142,9 @@ impl InlineAssistant { let mut to_remove = decorations.removed_line_block_ids; to_remove.insert(decorations.prompt_block_id); to_remove.insert(decorations.end_block_id); + if let Some(tool_description_block_id) = decorations.model_explanation { + to_remove.insert(tool_description_block_id); + } editor.remove_blocks(to_remove, None, cx); }); @@ -1154,7 +1197,7 @@ impl InlineAssistant { assist .editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) .ok(); } @@ -1166,7 +1209,7 @@ impl InlineAssistant { if let Some(decorations) = assist.decorations.as_ref() { decorations.prompt_editor.update(cx, |prompt_editor, cx| { prompt_editor.editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.select_all(&SelectAll, window, cx); }) }); @@ -1445,6 +1488,7 @@ impl InlineAssistant { multi_buffer.update(cx, |multi_buffer, cx| { multi_buffer.push_excerpts( old_buffer.clone(), + // todo(lw): buffer_start and buffer_end might come from different snapshots! Some(ExcerptRange::new(buffer_start..buffer_end)), cx, ); @@ -1456,6 +1500,7 @@ impl InlineAssistant { editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); editor.set_show_wrap_guides(false, cx); editor.set_show_gutter(false, cx); + editor.set_offset_content(false, cx); editor.scroll_manager.set_forbid_vertical_scroll(true); editor.set_read_only(true); editor.set_show_edit_predictions(Some(false), window, cx); @@ -1540,6 +1585,27 @@ impl InlineAssistant { .map(InlineAssistTarget::Terminal) } } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_completion_receiver( + &mut self, + sender: mpsc::UnboundedSender>, + ) { + self._inline_assistant_completions = Some(sender); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_codegen( + &mut self, + assist_id: InlineAssistId, + cx: &mut App, + ) -> Option> { + self.assists.get(&assist_id).map(|inline_assist| { + inline_assist + .codegen + .update(cx, |codegen, _cx| codegen.active_alternative().clone()) + }) + } } struct EditorInlineAssists { @@ -1673,6 +1739,7 @@ impl InlineAssist { editor: &Entity, prompt_editor: &Entity>, prompt_block_id: CustomBlockId, + tool_description_block_id: CustomBlockId, end_block_id: CustomBlockId, range: Range, codegen: Entity, @@ -1687,7 +1754,8 @@ impl InlineAssist { decorations: Some(InlineAssistDecorations { prompt_block_id, prompt_editor: prompt_editor.clone(), - removed_line_block_ids: HashSet::default(), + removed_line_block_ids: Default::default(), + model_explanation: Some(tool_description_block_id), end_block_id, }), range, @@ -1739,6 +1807,16 @@ impl InlineAssist { && assist.decorations.is_none() && let Some(workspace) = assist.workspace.upgrade() { + #[cfg(any(test, feature = "test-support"))] + if let Some(sender) = &mut this._inline_assistant_completions { + sender + .unbounded_send(Err(anyhow::anyhow!( + "Inline assistant error: {}", + error + ))) + .ok(); + } + let error = format!("Inline assistant error: {}", error); workspace.update(cx, |workspace, cx| { struct InlineAssistantError; @@ -1749,6 +1827,11 @@ impl InlineAssist { workspace.show_toast(Toast::new(id, error), cx); }) + } else { + #[cfg(any(test, feature = "test-support"))] + if let Some(sender) = &mut this._inline_assistant_completions { + sender.unbounded_send(Ok(assist_id)).ok(); + } } if assist.decorations.is_none() { @@ -1776,6 +1859,7 @@ struct InlineAssistDecorations { prompt_block_id: CustomBlockId, prompt_editor: Entity>, removed_line_block_ids: HashSet, + model_explanation: Option, end_block_id: CustomBlockId, } @@ -1942,3 +2026,387 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } } + +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] +pub mod test { + + use std::sync::Arc; + + use agent::HistoryStore; + use assistant_text_thread::TextThreadStore; + use client::{Client, UserStore}; + use editor::{Editor, MultiBuffer, MultiBufferOffset}; + use fs::FakeFs; + use futures::channel::mpsc; + use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; + use language::Buffer; + use project::Project; + use prompt_store::PromptBuilder; + use smol::stream::StreamExt as _; + use util::test::marked_text_ranges; + use workspace::Workspace; + + use crate::InlineAssistant; + + #[derive(Debug)] + pub enum InlineAssistantOutput { + Success { + completion: Option, + description: Option, + full_buffer_text: String, + }, + Failure { + failure: String, + }, + // These fields are used for logging + #[allow(unused)] + Malformed { + completion: Option, + description: Option, + failure: Option, + }, + } + + pub fn run_inline_assistant_test( + base_buffer: String, + prompt: String, + setup: SetupF, + test: TestF, + cx: &mut TestAppContext, + ) -> InlineAssistantOutput + where + SetupF: FnOnce(&mut gpui::VisualTestContext), + TestF: FnOnce(&mut gpui::VisualTestContext), + { + let fs = FakeFs::new(cx.executor()); + let app_state = cx.update(|cx| workspace::AppState::test(cx)); + let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); + let http = Arc::new(reqwest_client::ReqwestClient::user_agent("agent tests").unwrap()); + let client = cx.update(|cx| { + cx.set_http_client(http); + Client::production(cx) + }); + let mut inline_assistant = InlineAssistant::new(fs.clone(), prompt_builder); + + let (tx, mut completion_rx) = mpsc::unbounded(); + inline_assistant.set_completion_receiver(tx); + + // Initialize settings and client + cx.update(|cx| { + gpui_tokio::init(cx); + settings::init(cx); + client::init(&client, cx); + workspace::init(app_state.clone(), cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store, client.clone(), cx); + + cx.set_global(inline_assistant); + }); + + let project = cx + .executor() + .block_test(async { Project::test(fs.clone(), [], cx).await }); + + // Create workspace with window + let (workspace, cx) = cx.add_window_view(|window, cx| { + window.activate_window(); + Workspace::new(None, project.clone(), app_state.clone(), window, cx) + }); + + setup(cx); + + let (_editor, buffer) = cx.update(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.new(|cx| Editor::for_multibuffer(multibuffer, None, window, cx)); + editor.update(cx, |editor, cx| { + let (unmarked_text, selection_ranges) = marked_text_ranges(&base_buffer, true); + editor.set_text(unmarked_text, window, cx); + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges( + selection_ranges.into_iter().map(|range| { + MultiBufferOffset(range.start)..MultiBufferOffset(range.end) + }), + ) + }) + }); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Add editor to workspace + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + + // Call assist method + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let assist_id = inline_assistant + .assist( + &editor, + workspace.downgrade(), + project.downgrade(), + history_store, // thread_store + None, // prompt_store + Some(prompt), + window, + cx, + ) + .unwrap(); + + inline_assistant.start_assist(assist_id, window, cx); + }); + + (editor, buffer) + }); + + cx.run_until_parked(); + + test(cx); + + let assist_id = cx + .executor() + .block_test(async { completion_rx.next().await }) + .unwrap() + .unwrap(); + + let (completion, description, failure) = cx.update(|_, cx| { + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let codegen = inline_assistant.get_codegen(assist_id, cx).unwrap(); + + let completion = codegen.read(cx).current_completion(); + let description = codegen.read(cx).current_description(); + let failure = codegen.read(cx).current_failure(); + + (completion, description, failure) + }) + }); + + if failure.is_some() && (completion.is_some() || description.is_some()) { + InlineAssistantOutput::Malformed { + completion, + description, + failure, + } + } else if let Some(failure) = failure { + InlineAssistantOutput::Failure { failure } + } else { + InlineAssistantOutput::Success { + completion, + description, + full_buffer_text: buffer.read_with(cx, |buffer, _| buffer.text()), + } + } + } +} + +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] +pub mod evals { + use std::str::FromStr; + + use eval_utils::{EvalOutput, NoProcessor}; + use gpui::TestAppContext; + use language_model::{LanguageModelRegistry, SelectedModel}; + use rand::{SeedableRng as _, rngs::StdRng}; + + use crate::inline_assistant::test::{InlineAssistantOutput, run_inline_assistant_test}; + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_single_cursor_edit() { + run_eval( + 20, + 1.0, + "Rename this variable to buffer_text".to_string(), + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "} + .to_string(), + exact_buffer_match(indoc::indoc! {" + struct EvalExampleStruct { + buffer_text: String, + prompt: String, + } + "}), + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_cant_do() { + run_eval( + 20, + 0.95, + "Rename the struct to EvalExampleStructNope", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_unclear() { + run_eval( + 20, + 0.95, + "Make exactly the change I want you to make", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_empty_buffer() { + run_eval( + 20, + 1.0, + "Write a Python hello, world program".to_string(), + "ˇ".to_string(), + |output| match output { + InlineAssistantOutput::Success { + full_buffer_text, .. + } => { + if full_buffer_text.is_empty() { + EvalOutput::failed("expected some output".to_string()) + } else { + EvalOutput::passed(format!("Produced {full_buffer_text}")) + } + } + o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + }, + ); + } + + fn run_eval( + iterations: usize, + expected_pass_ratio: f32, + prompt: impl Into, + buffer: impl Into, + judge: impl Fn(InlineAssistantOutput) -> eval_utils::EvalOutput<()> + Send + Sync + 'static, + ) { + let buffer = buffer.into(); + let prompt = prompt.into(); + + eval_utils::eval(iterations, expected_pass_ratio, NoProcessor, move || { + let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); + let mut cx = TestAppContext::build(dispatcher, None); + cx.skip_drawing(); + + let output = run_inline_assistant_test( + buffer.clone(), + prompt.clone(), + |cx| { + // Reconfigure to use a real model instead of the fake one + let model_name = std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-latest".into()); + + let selected_model = SelectedModel::from_str(&model_name) + .expect("Invalid model format. Use 'provider/model-id'"); + + log::info!("Selected model: {selected_model:?}"); + + cx.update(|_, cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.select_inline_assistant_model(Some(&selected_model), cx); + }); + }); + }, + |_cx| { + log::info!("Waiting for actual response from the LLM..."); + }, + &mut cx, + ); + + cx.quit(); + + judge(output) + }); + } + + fn uncertain_output(output: InlineAssistantOutput) -> EvalOutput<()> { + match &output { + o @ InlineAssistantOutput::Success { + completion, + description, + .. + } => { + if description.is_some() && completion.is_none() { + EvalOutput::passed(format!( + "Assistant produced no completion, but a description:\n{}", + description.as_ref().unwrap() + )) + } else { + EvalOutput::failed(format!("Assistant produced a completion:\n{:?}", o)) + } + } + InlineAssistantOutput::Failure { + failure: error_message, + } => EvalOutput::passed(format!( + "Assistant produced a failure message: {}", + error_message + )), + o @ InlineAssistantOutput::Malformed { .. } => { + EvalOutput::failed(format!("Assistant produced a malformed response:\n{:?}", o)) + } + } + } + + fn exact_buffer_match( + correct_output: impl Into, + ) -> impl Fn(InlineAssistantOutput) -> EvalOutput<()> { + let correct_output = correct_output.into(); + move |output| match output { + InlineAssistantOutput::Success { + description, + full_buffer_text, + .. + } => { + if full_buffer_text == correct_output && description.is_none() { + EvalOutput::passed("Assistant output matches") + } else if full_buffer_text == correct_output { + EvalOutput::failed(format!( + "Assistant output produced an unescessary description description:\n{:?}", + description + )) + } else { + EvalOutput::failed(format!( + "Assistant output does not match expected output:\n{:?}\ndescription:\n{:?}", + full_buffer_text, description + )) + } + } + o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + } + } +} diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index b9e8d9ada230ba497ffcd4e577d3312dd440e604..8d96d56ea67cc9366df420b23e2221636d3450fb 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -8,12 +8,14 @@ use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, actions::{MoveDown, MoveUp}, }; +use feature_flags::{FeatureFlagAppExt, InlineAssistantUseToolFeatureFlag}; use fs::Fs; use gpui::{ - AnyElement, App, Context, CursorStyle, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, TextStyle, WeakEntity, Window, + AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, + Subscription, TextStyle, TextStyleRefinement, WeakEntity, Window, actions, }; use language_model::{LanguageModel, LanguageModelRegistry}; +use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::Project; use prompt_store::PromptStore; @@ -25,11 +27,13 @@ use std::sync::Arc; use theme::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; -use workspace::Workspace; +use uuid::Uuid; +use workspace::notifications::NotificationId; +use workspace::{Toast, Workspace}; use zed_actions::agent::ToggleModelSelector; use crate::agent_model_selector::AgentModelSelector; -use crate::buffer_codegen::BufferCodegen; +use crate::buffer_codegen::{BufferCodegen, CodegenAlternative}; use crate::completion_provider::{ PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextType, }; @@ -38,6 +42,19 @@ use crate::mention_set::{MentionSet, crease_for_mention}; use crate::terminal_codegen::TerminalCodegen; use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; +actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]); + +enum CompletionState { + Pending, + Generated { completion_text: Option }, + Rated, +} + +struct SessionState { + session_id: Uuid, + completion: CompletionState, +} + pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, @@ -53,6 +70,7 @@ pub struct PromptEditor { _codegen_subscription: Subscription, editor_subscriptions: Vec, show_rate_limit_notice: bool, + session_state: SessionState, _phantom: std::marker::PhantomData, } @@ -65,7 +83,7 @@ impl Render for PromptEditor { const RIGHT_PADDING: Pixels = px(9.); - let (left_gutter_width, right_padding) = match &self.mode { + let (left_gutter_width, right_padding, explanation) = match &self.mode { PromptEditorMode::Buffer { id: _, codegen, @@ -83,17 +101,23 @@ impl Render for PromptEditor { let left_gutter_width = gutter.full_width() + (gutter.margin / 2.0); let right_padding = editor_margins.right + RIGHT_PADDING; - (left_gutter_width, right_padding) + let active_alternative = codegen.active_alternative().read(cx); + let explanation = active_alternative + .description + .clone() + .or_else(|| active_alternative.failure.clone()); + + (left_gutter_width, right_padding, explanation) } PromptEditorMode::Terminal { .. } => { // Give the equivalent of the same left-padding that we're using on the right - (Pixels::from(40.0), Pixels::from(24.)) + (Pixels::from(40.0), Pixels::from(24.), None) } }; let bottom_padding = match &self.mode { PromptEditorMode::Buffer { .. } => rems_from_px(2.0), - PromptEditorMode::Terminal { .. } => rems_from_px(8.0), + PromptEditorMode::Terminal { .. } => rems_from_px(4.0), }; buttons.extend(self.render_buttons(window, cx)); @@ -111,22 +135,33 @@ impl Render for PromptEditor { this.trigger_completion_menu(window, cx); })); + let markdown = window.use_state(cx, |_, cx| Markdown::new("".into(), None, None, cx)); + + if let Some(explanation) = &explanation { + markdown.update(cx, |markdown, cx| { + markdown.reset(SharedString::from(explanation), cx); + }); + } + + let explanation_label = self + .render_markdown(markdown, markdown_style(window, cx)) + .into_any_element(); + v_flex() .key_context("PromptEditor") .capture_action(cx.listener(Self::paste)) - .bg(cx.theme().colors().editor_background) .block_mouse_except_scroll() - .gap_0p5() - .border_y_1() - .border_color(cx.theme().status().info_border) .size_full() .pt_0p5() .pb(bottom_padding) .pr(right_padding) + .gap_0p5() + .justify_center() + .border_y_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) .child( h_flex() - .items_start() - .cursor(CursorStyle::Arrow) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { this.model_selector .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); @@ -135,18 +170,20 @@ impl Render for PromptEditor { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_down)) + .on_action(cx.listener(Self::thumbs_up)) + .on_action(cx.listener(Self::thumbs_down)) .capture_action(cx.listener(Self::cycle_prev)) .capture_action(cx.listener(Self::cycle_next)) .child( WithRemSize::new(ui_font_size) + .h_full() + .w(left_gutter_width) .flex() .flex_row() .flex_shrink_0() .items_center() - .h_full() - .w(left_gutter_width) .justify_center() - .gap_2() + .gap_1() .child(self.render_close_button(cx)) .map(|el| { let CodegenStatus::Error(error) = self.codegen_status(cx) else { @@ -177,26 +214,83 @@ impl Render for PromptEditor { .flex_row() .items_center() .gap_1() + .child(add_context_button) + .child(self.model_selector.clone()) .children(buttons), ), ), ) - .child( - WithRemSize::new(ui_font_size) - .flex() - .flex_row() - .items_center() - .child(h_flex().flex_shrink_0().w(left_gutter_width)) - .child( - h_flex() - .w_full() - .pl_1() - .items_start() - .justify_between() - .child(add_context_button) - .child(self.model_selector.clone()), - ), - ) + .when_some(explanation, |this, _| { + this.child( + h_flex() + .size_full() + .justify_center() + .child(div().w(left_gutter_width + px(6.))) + .child( + div() + .size_full() + .min_w_0() + .pt(rems_from_px(3.)) + .pl_0p5() + .flex_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(explanation_label), + ), + ) + }) + } +} + +fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let theme_settings = ThemeSettings::get_global(cx); + let colors = cx.theme().colors(); + let mut text_style = window.text_style(); + + text_style.refine(&TextStyleRefinement { + font_family: Some(theme_settings.ui_font.family.clone()), + color: Some(colors.text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style: text_style.clone(), + syntax: cx.theme().syntax().clone(), + selection_background_color: colors.element_selection_background, + heading_level_styles: Some(HeadingLevelStyles { + h1: Some(TextStyleRefinement { + font_size: Some(rems(1.15).into()), + ..Default::default() + }), + h2: Some(TextStyleRefinement { + font_size: Some(rems(1.1).into()), + ..Default::default() + }), + h3: Some(TextStyleRefinement { + font_size: Some(rems(1.05).into()), + ..Default::default() + }), + h4: Some(TextStyleRefinement { + font_size: Some(rems(1.).into()), + ..Default::default() + }), + h5: Some(TextStyleRefinement { + font_size: Some(rems(0.95).into()), + ..Default::default() + }), + h6: Some(TextStyleRefinement { + font_size: Some(rems(0.875).into()), + ..Default::default() + }), + }), + inline_code: TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), + font_features: Some(theme_settings.buffer_font.features.clone()), + background_color: Some(colors.editor_foreground.opacity(0.08)), + ..Default::default() + }, + ..Default::default() } } @@ -263,7 +357,7 @@ impl PromptEditor { creases = insert_message_creases(&mut editor, &existing_creases, window, cx); if focus { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } editor }); @@ -354,6 +448,7 @@ impl PromptEditor { } self.edited_since_done = true; + self.session_state.completion = CompletionState::Pending; cx.notify(); } EditorEvent::Blurred => { @@ -425,22 +520,207 @@ impl PromptEditor { fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { match self.codegen_status(cx) { CodegenStatus::Idle => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } CodegenStatus::Pending => {} CodegenStatus::Done => { if self.edited_since_done { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } else { cx.emit(PromptEditorEvent::ConfirmRequested { execute: false }); } } CodegenStatus::Error(_) => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } } } + fn fire_started_telemetry(&self, cx: &Context) { + let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() else { + return; + }; + + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.provider.id().to_string(); + + let (kind, language_name) = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + let codegen = codegen.read(cx); + ( + "inline", + codegen.language_name(cx).map(|name| name.to_string()), + ) + } + PromptEditorMode::Terminal { .. } => ("inline_terminal", None), + }; + + telemetry::event!( + "Assistant Started", + session_id = self.session_state.session_id.to_string(), + kind = kind, + phase = "started", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + ); + } + + fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let (model_id, use_streaming_tools) = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) + }; + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let prompt = self.editor.read(cx).text(cx); + + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "positive", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + use_streaming_tools + ); + + self.session_state.completion = CompletionState::Rated; + + cx.notify(); + } + } + } + + fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let (model_telemetry_id, use_streaming_tools) = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) + }; + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let prompt = self.editor.read(cx).text(cx); + + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "negative", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_telemetry_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + use_streaming_tools + ); + + self.session_state.completion = CompletionState::Rated; + + cx.notify(); + } + } + } + + fn toast(&mut self, msg: &str, uuid: Option, cx: &mut Context<'_, PromptEditor>) { + self.workspace + .update(cx, |workspace, cx| { + enum InlinePromptRating {} + workspace.show_toast( + { + let mut toast = Toast::new( + NotificationId::unique::(), + msg.to_string(), + ) + .autohide(); + + if let Some(uuid) = uuid { + toast = toast.on_click("Click to copy rating ID", move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(uuid.to_string())); + }); + }; + + toast + }, + cx, + ); + }) + .ok(); + } + fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { if let Some(ix) = self.prompt_history_ix { if ix > 0 { @@ -546,6 +826,9 @@ impl PromptEditor { .into_any_element(), ] } else { + let show_rating_buttons = cx.has_flag::(); + let rated = matches!(self.session_state.completion, CompletionState::Rated); + let accept = IconButton::new("accept", IconName::Check) .icon_color(Color::Info) .shape(IconButtonShape::Square) @@ -557,25 +840,92 @@ impl PromptEditor { })) .into_any_element(); - match &self.mode { - PromptEditorMode::Terminal { .. } => vec![ - accept, - IconButton::new("confirm", IconName::PlayFilled) - .icon_color(Color::Info) - .shape(IconButtonShape::Square) - .tooltip(|_window, cx| { - Tooltip::for_action( - "Execute Generated Command", - &menu::SecondaryConfirm, - cx, - ) - }) - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(PromptEditorEvent::ConfirmRequested { execute: true }); - })) + let mut buttons = Vec::new(); + + if show_rating_buttons { + buttons.push( + h_flex() + .pl_1() + .gap_1() + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .child( + IconButton::new("thumbs-up", IconName::ThumbsUp) + .shape(IconButtonShape::Square) + .map(|this| { + if rated { + this.disabled(true) + .icon_color(Color::Ignored) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Good Result", + None, + "You already rated this result", + cx, + ) + }) + } else { + this.icon_color(Color::Muted) + .tooltip(Tooltip::text("Good Result")) + } + }) + .on_click(cx.listener(|this, _, window, cx| { + this.thumbs_up(&ThumbsUpResult, window, cx); + })), + ) + .child( + IconButton::new("thumbs-down", IconName::ThumbsDown) + .shape(IconButtonShape::Square) + .map(|this| { + if rated { + this.disabled(true) + .icon_color(Color::Ignored) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Bad Result", + None, + "You already rated this result", + cx, + ) + }) + } else { + this.icon_color(Color::Muted) + .tooltip(Tooltip::text("Bad Result")) + } + }) + .on_click(cx.listener(|this, _, window, cx| { + this.thumbs_down(&ThumbsDownResult, window, cx); + })), + ) .into_any_element(), - ], - PromptEditorMode::Buffer { .. } => vec![accept], + ); + } + + buttons.push(accept); + + match &self.mode { + PromptEditorMode::Terminal { .. } => { + buttons.push( + IconButton::new("confirm", IconName::PlayFilled) + .icon_color(Color::Info) + .shape(IconButtonShape::Square) + .tooltip(|_window, cx| { + Tooltip::for_action( + "Execute Generated Command", + &menu::SecondaryConfirm, + cx, + ) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(PromptEditorEvent::ConfirmRequested { + execute: true, + }); + })) + .into_any_element(), + ); + buttons + } + PromptEditorMode::Buffer { .. } => buttons, } } } @@ -610,10 +960,21 @@ impl PromptEditor { } fn render_close_button(&self, cx: &mut Context) -> AnyElement { + let focus_handle = self.editor.focus_handle(cx); + IconButton::new("cancel", IconName::Close) .icon_color(Color::Muted) .shape(IconButtonShape::Square) - .tooltip(Tooltip::text("Close Assistant")) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Close Assistant", + &editor::actions::Cancel, + &focus_handle, + cx, + ) + } + }) .on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::CancelRequested))) .into_any_element() } @@ -759,6 +1120,10 @@ impl PromptEditor { }) .into_any_element() } + + fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { + MarkdownElement::new(markdown, style) + } } pub enum PromptEditorMode { @@ -830,6 +1195,7 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, history_store: Entity, prompt_store: Option>, @@ -900,6 +1266,10 @@ impl PromptEditor { editor_subscriptions: Vec::new(), show_rate_limit_notice: false, mode, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; @@ -910,7 +1280,7 @@ impl PromptEditor { fn handle_codegen_changed( &mut self, - _: Entity, + codegen: Entity, cx: &mut Context>, ) { match self.codegen_status(cx) { @@ -919,10 +1289,15 @@ impl PromptEditor { .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done => { + let completion = codegen.read(cx).active_completion(cx); + self.session_state.completion = CompletionState::Generated { + completion_text: completion, + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); @@ -978,6 +1353,7 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, history_store: Entity, prompt_store: Option>, @@ -1043,6 +1419,10 @@ impl PromptEditor { editor_subscriptions: Vec::new(), mode, show_rate_limit_notice: false, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; this.count_lines(cx); @@ -1075,17 +1455,21 @@ impl PromptEditor { } } - fn handle_codegen_changed(&mut self, _: Entity, cx: &mut Context) { + fn handle_codegen_changed(&mut self, codegen: Entity, cx: &mut Context) { match &self.codegen().read(cx).status { CodegenStatus::Idle => { self.editor .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done | CodegenStatus::Error(_) => { + self.session_state.completion = CompletionState::Generated { + completion_text: codegen.read(cx).completion(), + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 5b5a4513c6dca32e985c966e07ad84e84fc9a872..704e340ace35f33f757ab7708f96ffc940a8eb91 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -1,27 +1,33 @@ use std::{cmp::Reverse, sync::Arc}; -use collections::IndexMap; +use agent_settings::AgentSettings; +use collections::{HashMap, HashSet, IndexMap}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task, }; use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, - LanguageModelRegistry, + AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId, + LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*}; +use settings::Settings; +use ui::prelude::*; use zed_actions::agent::OpenSettings; +use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}; + type OnModelChanged = Arc, &mut App) + 'static>; type GetActiveModel = Arc Option + 'static>; +type OnToggleFavorite = Arc, bool, &App) + 'static>; pub type LanguageModelSelector = Picker; pub fn language_model_selector( get_active_model: impl Fn(&App) -> Option + 'static, on_model_changed: impl Fn(Arc, &mut App) + 'static, + on_toggle_favorite: impl Fn(Arc, bool, &App) + 'static, popover_styles: bool, focus_handle: FocusHandle, window: &mut Window, @@ -30,6 +36,7 @@ pub fn language_model_selector( let delegate = LanguageModelPickerDelegate::new( get_active_model, on_model_changed, + on_toggle_favorite, popover_styles, focus_handle, window, @@ -47,7 +54,17 @@ pub fn language_model_selector( } fn all_models(cx: &App) -> GroupedModels { - let providers = LanguageModelRegistry::global(cx).read(cx).providers(); + let lm_registry = LanguageModelRegistry::global(cx).read(cx); + let providers = lm_registry.visible_providers(); + + let mut favorites_index = FavoritesIndex::default(); + + for sel in &AgentSettings::get_global(cx).favorite_models { + favorites_index + .entry(sel.provider.0.clone().into()) + .or_default() + .insert(sel.model.clone().into()); + } let recommended = providers .iter() @@ -55,10 +72,7 @@ fn all_models(cx: &App) -> GroupedModels { provider .recommended_models(cx) .into_iter() - .map(|model| ModelInfo { - model, - icon: provider.icon(), - }) + .map(|model| ModelInfo::new(&**provider, model, &favorites_index)) }) .collect(); @@ -68,25 +82,44 @@ fn all_models(cx: &App) -> GroupedModels { provider .provided_models(cx) .into_iter() - .map(|model| ModelInfo { - model, - icon: provider.icon(), - }) + .map(|model| ModelInfo::new(&**provider, model, &favorites_index)) }) .collect(); GroupedModels::new(all, recommended) } +type FavoritesIndex = HashMap>; + #[derive(Clone)] struct ModelInfo { model: Arc, - icon: IconName, + icon: IconOrSvg, + is_favorite: bool, +} + +impl ModelInfo { + fn new( + provider: &dyn LanguageModelProvider, + model: Arc, + favorites_index: &FavoritesIndex, + ) -> Self { + let is_favorite = favorites_index + .get(&provider.id()) + .map_or(false, |set| set.contains(&model.id())); + + Self { + model, + icon: provider.icon(), + is_favorite, + } + } } pub struct LanguageModelPickerDelegate { on_model_changed: OnModelChanged, get_active_model: GetActiveModel, + on_toggle_favorite: OnToggleFavorite, all_models: Arc, filtered_entries: Vec, selected_index: usize, @@ -100,6 +133,7 @@ impl LanguageModelPickerDelegate { fn new( get_active_model: impl Fn(&App) -> Option + 'static, on_model_changed: impl Fn(Arc, &mut App) + 'static, + on_toggle_favorite: impl Fn(Arc, bool, &App) + 'static, popover_styles: bool, focus_handle: FocusHandle, window: &mut Window, @@ -115,6 +149,7 @@ impl LanguageModelPickerDelegate { selected_index: Self::get_active_model_index(&entries, get_active_model(cx)), filtered_entries: entries, get_active_model: Arc::new(get_active_model), + on_toggle_favorite: Arc::new(on_toggle_favorite), _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![cx.subscribe_in( &LanguageModelRegistry::global(cx), @@ -168,7 +203,7 @@ impl LanguageModelPickerDelegate { fn authenticate_all_providers(cx: &mut App) -> Task<()> { let authenticate_all_providers = LanguageModelRegistry::global(cx) .read(cx) - .providers() + .visible_providers() .iter() .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) .collect::>(); @@ -214,15 +249,57 @@ impl LanguageModelPickerDelegate { pub fn active_model(&self, cx: &App) -> Option { (self.get_active_model)(cx) } + + pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context>) { + if self.all_models.favorites.is_empty() { + return; + } + + let active_model = (self.get_active_model)(cx); + let active_provider_id = active_model.as_ref().map(|m| m.provider.id()); + let active_model_id = active_model.as_ref().map(|m| m.model.id()); + + let current_index = self + .all_models + .favorites + .iter() + .position(|info| { + Some(info.model.provider_id()) == active_provider_id + && Some(info.model.id()) == active_model_id + }) + .unwrap_or(usize::MAX); + + let next_index = if current_index == usize::MAX { + 0 + } else { + (current_index + 1) % self.all_models.favorites.len() + }; + + let next_model = self.all_models.favorites[next_index].model.clone(); + + (self.on_model_changed)(next_model, cx); + + // Align the picker selection with the newly-active model + let new_index = + Self::get_active_model_index(&self.filtered_entries, (self.get_active_model)(cx)); + self.set_selected_index(new_index, window, cx); + } } struct GroupedModels { + favorites: Vec, recommended: Vec, all: IndexMap>, } impl GroupedModels { pub fn new(all: Vec, recommended: Vec) -> Self { + let favorites = all + .iter() + .filter(|info| info.is_favorite) + .cloned() + .collect(); + let mut all_by_provider: IndexMap<_, Vec> = IndexMap::default(); for model in all { let provider = model.model.provider_id(); @@ -234,6 +311,7 @@ impl GroupedModels { } Self { + favorites, recommended, all: all_by_provider, } @@ -242,13 +320,18 @@ impl GroupedModels { fn entries(&self) -> Vec { let mut entries = Vec::new(); + if !self.favorites.is_empty() { + entries.push(LanguageModelPickerEntry::Separator("Favorite".into())); + for info in &self.favorites { + entries.push(LanguageModelPickerEntry::Model(info.clone())); + } + } + if !self.recommended.is_empty() { entries.push(LanguageModelPickerEntry::Separator("Recommended".into())); - entries.extend( - self.recommended - .iter() - .map(|info| LanguageModelPickerEntry::Model(info.clone())), - ); + for info in &self.recommended { + entries.push(LanguageModelPickerEntry::Model(info.clone())); + } } for models in self.all.values() { @@ -258,12 +341,11 @@ impl GroupedModels { entries.push(LanguageModelPickerEntry::Separator( models[0].model.provider_name().0, )); - entries.extend( - models - .iter() - .map(|info| LanguageModelPickerEntry::Model(info.clone())), - ); + for info in models { + entries.push(LanguageModelPickerEntry::Model(info.clone())); + } } + entries } } @@ -392,7 +474,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { let configured_providers = language_model_registry .read(cx) - .providers() + .visible_providers() .into_iter() .filter(|provider| provider.is_authenticated(cx)) .collect::>(); @@ -464,23 +546,9 @@ impl PickerDelegate for LanguageModelPickerDelegate { cx: &mut Context>, ) -> Option { match self.filtered_entries.get(ix)? { - LanguageModelPickerEntry::Separator(title) => Some( - div() - .px_2() - .pb_1() - .when(ix > 1, |this| { - this.mt_1() - .pt_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - Label::new(title) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - ), + LanguageModelPickerEntry::Separator(title) => { + Some(ModelSelectorHeader::new(title, ix > 1).into_any_element()) + } LanguageModelPickerEntry::Model(model_info) => { let active_model = (self.get_active_model)(cx); let active_provider_id = active_model.as_ref().map(|m| m.provider.id()); @@ -489,35 +557,23 @@ impl PickerDelegate for LanguageModelPickerDelegate { let is_selected = Some(model_info.model.provider_id()) == active_provider_id && Some(model_info.model.id()) == active_model_id; - let model_icon_color = if is_selected { - Color::Accent - } else { - Color::Muted + let is_favorite = model_info.is_favorite; + let handle_action_click = { + let model = model_info.model.clone(); + let on_toggle_favorite = self.on_toggle_favorite.clone(); + move |cx: &App| on_toggle_favorite(model.clone(), !is_favorite, cx) }; Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_1p5() - .child( - Icon::new(model_info.icon) - .color(model_icon_color) - .size(IconSize::Small), - ) - .child(Label::new(model_info.model.name().0).truncate()), - ) - .end_slot(div().pr_3().when(is_selected, |this| { - this.child( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - })) + ModelSelectorListItem::new(ix, model_info.model.name().0) + .map(|this| match &model_info.icon { + IconOrSvg::Icon(icon_name) => this.icon(*icon_name), + IconOrSvg::Svg(icon_path) => this.icon_path(icon_path.clone()), + }) + .is_selected(is_selected) + .is_focused(selected) + .is_favorite(is_favorite) + .on_toggle_favorite(handle_action_click) .into_any_element(), ) } @@ -527,7 +583,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { fn render_footer( &self, _window: &mut Window, - cx: &mut Context>, + _cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); @@ -535,26 +591,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { return None; } - Some( - h_flex() - .w_full() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenSettings.boxed_clone(), cx); - }), - ) - .into_any(), - ) + Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element()) } } @@ -653,11 +690,24 @@ mod tests { } fn create_models(model_specs: Vec<(&str, &str)>) -> Vec { + create_models_with_favorites(model_specs, vec![]) + } + + fn create_models_with_favorites( + model_specs: Vec<(&str, &str)>, + favorites: Vec<(&str, &str)>, + ) -> Vec { model_specs .into_iter() - .map(|(provider, name)| ModelInfo { - model: Arc::new(TestLanguageModel::new(name, provider)), - icon: IconName::Ai, + .map(|(provider, name)| { + let is_favorite = favorites + .iter() + .any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name); + ModelInfo { + model: Arc::new(TestLanguageModel::new(name, provider)), + icon: IconOrSvg::Icon(IconName::Ai), + is_favorite, + } }) .collect() } @@ -795,4 +845,93 @@ mod tests { vec!["zed/claude", "zed/gemini", "copilot/claude"], ); } + + #[gpui::test] + fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) { + let recommended_models = create_models(vec![("zed", "claude")]); + let all_models = create_models_with_favorites( + vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")], + vec![("zed", "gemini")], + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + assert!(matches!( + entries.first(), + Some(LanguageModelPickerEntry::Separator(s)) if s == "Favorite" + )); + + assert_models_eq(grouped_models.favorites, vec!["zed/gemini"]); + } + + #[gpui::test] + fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) { + let recommended_models = create_models(vec![("zed", "claude")]); + let all_models = create_models(vec![("zed", "claude"), ("zed", "gemini")]); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + assert!(matches!( + entries.first(), + Some(LanguageModelPickerEntry::Separator(s)) if s == "Recommended" + )); + + assert!(grouped_models.favorites.is_empty()); + } + + #[gpui::test] + fn test_models_have_correct_actions(_cx: &mut TestAppContext) { + let recommended_models = + create_models_with_favorites(vec![("zed", "claude")], vec![("zed", "claude")]); + let all_models = create_models_with_favorites( + vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")], + vec![("zed", "claude")], + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + for entry in &entries { + if let LanguageModelPickerEntry::Model(info) = entry { + if info.model.telemetry_id() == "zed/claude" { + assert!(info.is_favorite, "zed/claude should be a favorite"); + } else { + assert!( + !info.is_favorite, + "{} should not be a favorite", + info.model.telemetry_id() + ); + } + } + } + } + + #[gpui::test] + fn test_favorites_appear_in_other_sections(_cx: &mut TestAppContext) { + let favorites = vec![("zed", "gemini"), ("openai", "gpt-4")]; + + let recommended_models = + create_models_with_favorites(vec![("zed", "claude")], favorites.clone()); + + let all_models = create_models_with_favorites( + vec![ + ("zed", "claude"), + ("zed", "gemini"), + ("openai", "gpt-4"), + ("openai", "gpt-3.5"), + ], + favorites, + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + + assert_models_eq(grouped_models.favorites, vec!["zed/gemini", "openai/gpt-4"]); + assert_models_eq(grouped_models.recommended, vec!["zed/claude"]); + assert_models_eq( + grouped_models.all.values().flatten().cloned().collect(), + vec!["zed/claude", "zed/gemini", "openai/gpt-4", "openai/gpt-3.5"], + ); + } } diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index c1949d22e268e8744db7834a58d1a3303fa4e236..327d2c67e2d5e87e67935ecdfa7fb6cd41acbcb5 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -1,4 +1,4 @@ -use crate::{ManageProfiles, ToggleProfileSelector}; +use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector}; use agent_settings::{ AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles, }; @@ -70,6 +70,29 @@ impl ProfileSelector { self.picker_handle.clone() } + pub fn cycle_profile(&mut self, cx: &mut Context) { + if !self.provider.profiles_supported(cx) { + return; + } + + let profiles = AgentProfile::available_profiles(cx); + if profiles.is_empty() { + return; + } + + let current_profile_id = self.provider.profile_id(cx); + let current_index = profiles + .keys() + .position(|id| id == ¤t_profile_id) + .unwrap_or(0); + + let next_index = (current_index + 1) % profiles.len(); + + if let Some((next_profile_id, _)) = profiles.get_index(next_index) { + self.provider.set_profile(next_profile_id.clone(), cx); + } + } + fn ensure_picker( &mut self, window: &mut Window, @@ -163,14 +186,29 @@ impl Render for ProfileSelector { PickerPopoverMenu::new( picker, trigger_button, - move |_window, cx| { - Tooltip::for_action_in( - "Toggle Profile Menu", - &ToggleProfileSelector, - &focus_handle, - cx, - ) - }, + Tooltip::element({ + move |_window, cx| { + let container = || h_flex().gap_1().justify_between(); + v_flex() + .gap_1() + .child(container().child(Label::new("Toggle Profile Menu")).child( + KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx), + )) + .child( + container() + .pb_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Cycle Through Profiles")) + .child(KeyBinding::for_action_in( + &CycleModeSelector, + &focus_handle, + cx, + )), + ) + .into_any() + } + }), gpui::Corner::BottomRight, cx, ) @@ -542,7 +580,7 @@ impl PickerDelegate for ProfilePickerDelegate { let is_active = active_id == candidate.id; Some( - ListItem::new(SharedString::from(candidate.id.0.clone())) + ListItem::new(candidate.id.0.clone()) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/agent_ui/src/slash_command.rs b/crates/agent_ui/src/slash_command.rs index 7d3ea0105a0aafb4cfccf4076cb95e28c99dec28..e328ef6725e5e789bd402667da91417ad69a372d 100644 --- a/crates/agent_ui/src/slash_command.rs +++ b/crates/agent_ui/src/slash_command.rs @@ -341,7 +341,6 @@ impl CompletionProvider for SlashCommandCompletionProvider { position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); diff --git a/crates/agent_ui/src/terminal_codegen.rs b/crates/agent_ui/src/terminal_codegen.rs index 5a4a9d560a16e858dcaedf706f2067a24bc12c5f..e93d3d3991378ddb4156b264be1f0a5ab4d4faac 100644 --- a/crates/agent_ui/src/terminal_codegen.rs +++ b/crates/agent_ui/src/terminal_codegen.rs @@ -1,37 +1,38 @@ use crate::inline_prompt_editor::CodegenStatus; -use client::telemetry::Telemetry; use futures::{SinkExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; -use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, report_assistant_event, -}; -use std::{sync::Arc, time::Instant}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; +use language_model::{ConfiguredModel, LanguageModelRegistry, LanguageModelRequest}; +use std::time::Instant; use terminal::Terminal; +use uuid::Uuid; pub struct TerminalCodegen { pub status: CodegenStatus, - pub telemetry: Option>, terminal: Entity, generation: Task<()>, pub message_id: Option, transaction: Option, + session_id: Uuid, } impl EventEmitter for TerminalCodegen {} impl TerminalCodegen { - pub fn new(terminal: Entity, telemetry: Option>) -> Self { + pub fn new(terminal: Entity, session_id: Uuid) -> Self { Self { terminal, - telemetry, status: CodegenStatus::Idle, generation: Task::ready(()), message_id: None, transaction: None, + session_id, } } + pub fn session_id(&self) -> Uuid { + self.session_id + } + pub fn start(&mut self, prompt_task: Task, cx: &mut Context) { let Some(ConfiguredModel { model, .. }) = LanguageModelRegistry::read_global(cx).inline_assistant_model() @@ -39,15 +40,15 @@ impl TerminalCodegen { return; }; - let model_api_key = model.api_key(cx); - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + self.status = CodegenStatus::Pending; self.transaction = Some(TerminalTransaction::start(self.terminal.clone())); self.generation = cx.spawn(async move |this, cx| { let prompt = prompt_task.await; - let model_telemetry_id = model.telemetry_id(); - let model_provider_id = model.provider_id(); let response = model.stream_completion_text(prompt, cx).await; let generate = async { let message_id = response @@ -59,7 +60,7 @@ impl TerminalCodegen { let task = cx.background_spawn({ let message_id = message_id.clone(); - let executor = cx.background_executor().clone(); + let anthropic_reporter = anthropic_reporter.clone(); async move { let mut response_latency = None; let request_start = Instant::now(); @@ -79,24 +80,27 @@ impl TerminalCodegen { let result = task.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id.to_string(), - response_latency, - error_message, - language_name: None, - }, - telemetry, - http_client, - model_api_key, - &executor, + + telemetry::event!( + "Assistant Responded", + session_id = session_id.to_string(), + kind = "inline_terminal", + phase = "response", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = Option::<&str>::None, + message_id = message_id, + response_latency = response_latency, + error_message = error_message, ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: language_model::AnthropicEventType::Response, + language_name: None, + message_id, + }); + result?; anyhow::Ok(()) } @@ -135,6 +139,12 @@ impl TerminalCodegen { cx.notify(); } + pub fn completion(&self) -> Option { + self.transaction + .as_ref() + .map(|transaction| transaction.completion.clone()) + } + pub fn stop(&mut self, cx: &mut Context) { self.status = CodegenStatus::Done; self.generation = Task::ready(()); @@ -167,27 +177,32 @@ pub const CLEAR_INPUT: &str = "\x03"; const CARRIAGE_RETURN: &str = "\x0d"; struct TerminalTransaction { + completion: String, terminal: Entity, } impl TerminalTransaction { pub fn start(terminal: Entity) -> Self { - Self { terminal } + Self { + completion: String::new(), + terminal, + } } pub fn push(&mut self, hunk: String, cx: &mut App) { // Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal let input = Self::sanitize_input(hunk); + self.completion.push_str(&input); self.terminal .update(cx, |terminal, _| terminal.input(input.into_bytes())); } - pub fn undo(&self, cx: &mut App) { + pub fn undo(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes())); } - pub fn complete(&self, cx: &mut App) { + pub fn complete(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes())); } diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 43ea697bece318699f350259a0e2e38d1a4f4d8d..cacbc316bb84e74e5c369451791f777a9bf58e82 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -8,7 +8,7 @@ use crate::{ use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; + use cloud_llm_client::CompletionIntent; use collections::{HashMap, VecDeque}; use editor::{MultiBuffer, actions::SelectAll}; @@ -17,24 +17,19 @@ use gpui::{App, Entity, Focusable, Global, Subscription, Task, UpdateGlobal, Wea use language::Buffer; use language_model::{ ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - Role, report_assistant_event, + Role, report_anthropic_event, }; use project::Project; use prompt_store::{PromptBuilder, PromptStore}; use std::sync::Arc; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; use terminal_view::TerminalView; use ui::prelude::*; use util::ResultExt; +use uuid::Uuid; use workspace::{Toast, Workspace, notifications::NotificationId}; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder)); } const DEFAULT_CONTEXT_LINES: usize = 50; @@ -44,7 +39,6 @@ pub struct TerminalInlineAssistant { next_assist_id: TerminalInlineAssistId, assists: HashMap, prompt_history: VecDeque, - telemetry: Option>, fs: Arc, prompt_builder: Arc, } @@ -52,16 +46,11 @@ pub struct TerminalInlineAssistant { impl Global for TerminalInlineAssistant {} impl TerminalInlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: TerminalInlineAssistId::default(), assists: HashMap::default(), prompt_history: VecDeque::default(), - telemetry: Some(telemetry), fs, prompt_builder, } @@ -80,13 +69,14 @@ impl TerminalInlineAssistant { ) { let terminal = terminal_view.read(cx).terminal().clone(); let assist_id = self.next_assist_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), cx, ) }); - let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone())); + let codegen = cx.new(|_| TerminalCodegen::new(terminal, session_id)); let prompt_editor = cx.new(|cx| { PromptEditor::new_terminal( @@ -94,6 +84,7 @@ impl TerminalInlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen, + session_id, self.fs.clone(), thread_store.clone(), prompt_store.clone(), @@ -136,7 +127,7 @@ impl TerminalInlineAssistant { if let Some(prompt_editor) = assist.prompt_editor.as_ref() { prompt_editor.update(cx, |this, cx| { this.editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.select_all(&SelectAll, window, cx); }); }); @@ -301,7 +292,7 @@ impl TerminalInlineAssistant { .terminal .update(cx, |this, cx| { this.clear_block_below_cursor(cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }) .log_err(); @@ -309,27 +300,45 @@ impl TerminalInlineAssistant { LanguageModelRegistry::read_global(cx).inline_assistant_model() { let codegen = assist.codegen.read(cx); - let executor = cx.background_executor().clone(); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id: codegen.message_id.clone(), - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency: None, - error_message: None, + let session_id = codegen.session_id(); + let message_id = codegen.message_id.clone(); + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + // Fire Zed telemetry + telemetry::event!( + event_type, + kind = "inline_terminal", + phase = phase, + model = model_telemetry_id, + model_provider = model_provider_id, + message_id = message_id, + session_id = session_id, + ); + + report_anthropic_event( + &model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: anthropic_event_type, language_name: None, + message_id, }, - codegen.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - &executor, + cx, ); } @@ -360,7 +369,7 @@ impl TerminalInlineAssistant { .terminal .update(cx, |this, cx| { this.clear_block_below_cursor(cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }) .is_ok() } diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 8c245a0675a03e65efeaf3e92bc3b7a5062fdd53..514f45528427af89eeccf85512abf850a7a1be05 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -2,7 +2,7 @@ use crate::{ language_model_selector::{LanguageModelSelector, language_model_selector}, ui::BurnModeTooltip, }; -use agent_settings::CompletionMode; +use agent_settings::{AgentSettings, CompletionMode}; use anyhow::Result; use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet}; use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases}; @@ -33,7 +33,8 @@ use language::{ language_settings::{SoftWrap, all_language_settings}, }; use language_model::{ - ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role, + ConfigurationError, IconOrSvg, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, + Role, }; use multi_buffer::MultiBufferRow; use picker::{Picker, popover_menu::PickerPopoverMenu}; @@ -71,7 +72,9 @@ use workspace::{ pane, searchable::{SearchEvent, SearchableItem}, }; -use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector}; +use zed_actions::agent::{AddSelectionToThread, PasteRaw, ToggleModelSelector}; + +use crate::CycleFavoriteModels; use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker}; use assistant_text_thread::{ @@ -304,17 +307,31 @@ impl TextThreadEditor { language_model_selector: cx.new(|cx| { language_model_selector( |cx| LanguageModelRegistry::read_global(cx).default_model(), - move |model, cx| { - update_settings_file(fs.clone(), cx, move |settings, _| { - let provider = model.provider_id().0.to_string(); - let model = model.id().0.to_string(); - settings.agent.get_or_insert_default().set_model( - LanguageModelSelection { - provider: LanguageModelProviderSetting(provider), - model, - }, - ) - }); + { + let fs = fs.clone(); + move |model, cx| { + update_settings_file(fs.clone(), cx, move |settings, _| { + let provider = model.provider_id().0.to_string(); + let model = model.id().0.to_string(); + settings.agent.get_or_insert_default().set_model( + LanguageModelSelection { + provider: LanguageModelProviderSetting(provider), + model, + }, + ) + }); + } + }, + { + let fs = fs.clone(); + move |model, should_be_favorite, cx| { + crate::favorite_models::toggle_in_settings( + model, + should_be_favorite, + fs.clone(), + cx, + ); + } }, true, // Use popover styles for picker focus_handle, @@ -1325,7 +1342,7 @@ impl TextThreadEditor { if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, cx) { active_editor_view.update(cx, |editor, cx| { editor.insert(&text, window, cx); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }) } } @@ -1682,6 +1699,118 @@ impl TextThreadEditor { window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let editor_clipboard_selections = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + ClipboardEntry::String(text) => { + text.metadata_json::>() + } + _ => None, + }); + + // Insert creases for pasted clipboard selections that: + // 1. Contain exactly one selection + // 2. Have an associated file path + // 3. Span multiple lines (not single-line selections) + // 4. Belong to a file that exists in the current project + let should_insert_creases = util::maybe!({ + let selections = editor_clipboard_selections.as_ref()?; + if selections.len() > 1 { + return Some(false); + } + let selection = selections.first()?; + let file_path = selection.file_path.as_ref()?; + let line_range = selection.line_range.as_ref()?; + + if line_range.start() == line_range.end() { + return Some(false); + } + + Some( + workspace + .read(cx) + .project() + .read(cx) + .project_path_for_absolute_path(file_path, cx) + .is_some(), + ) + }) + .unwrap_or(false); + + if should_insert_creases && let Some(clipboard_item) = cx.read_from_clipboard() { + if let Some(ClipboardEntry::String(clipboard_text)) = clipboard_item.entries().first() { + if let Some(selections) = editor_clipboard_selections { + cx.stop_propagation(); + + let text = clipboard_text.text(); + self.editor.update(cx, |editor, cx| { + let mut current_offset = 0; + let weak_editor = cx.entity().downgrade(); + + for selection in selections { + if let (Some(file_path), Some(line_range)) = + (selection.file_path, selection.line_range) + { + let selected_text = + &text[current_offset..current_offset + selection.len]; + let fence = assistant_slash_commands::codeblock_fence_for_path( + file_path.to_str(), + Some(line_range.clone()), + ); + let formatted_text = format!("{fence}{selected_text}\n```"); + + let insert_point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + let start_row = MultiBufferRow(insert_point.row); + + editor.insert(&formatted_text, window, cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor_before = snapshot.anchor_after(insert_point); + let anchor_after = editor + .selections + .newest_anchor() + .head() + .bias_left(&snapshot); + + editor.insert("\n", window, cx); + + let crease_text = acp_thread::selection_name( + Some(file_path.as_ref()), + &line_range, + ); + + let fold_placeholder = quote_selection_fold_placeholder( + crease_text, + weak_editor.clone(), + ); + let crease = Crease::inline( + anchor_before..anchor_after, + fold_placeholder, + render_quote_selection_output_toggle, + |_, _, _, _| Empty.into_any(), + ); + editor.insert_creases(vec![crease], cx); + editor.fold_at(start_row, window, cx); + + current_offset += selection.len; + if !selection.is_entire_line && current_offset < text.len() { + current_offset += 1; + } + } + } + }); + return; + } + } + } + cx.stop_propagation(); let mut images = if let Some(item) = cx.read_from_clipboard() { @@ -1836,6 +1965,12 @@ impl TextThreadEditor { } } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.paste(&editor::actions::Paste, window, cx); + }); + } + fn update_image_blocks(&mut self, cx: &mut Context) { self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); @@ -2097,18 +2232,66 @@ impl TextThreadEditor { .default_model() .map(|default| default.provider); - let provider_icon = match active_provider { - Some(provider) => provider.icon(), - None => IconName::Ai, - }; + let provider_icon = active_provider + .as_ref() + .map(|p| p.icon()) + .unwrap_or(IconOrSvg::Icon(IconName::Ai)); let focus_handle = self.editor().focus_handle(cx); + let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() { (Color::Accent, IconName::ChevronUp) } else { (Color::Muted, IconName::ChevronDown) }; + let provider_icon_element = match provider_icon { + IconOrSvg::Svg(path) => Icon::from_external_svg(path), + IconOrSvg::Icon(name) => Icon::new(name), + } + .color(color) + .size(IconSize::XSmall); + + let tooltip = Tooltip::element({ + move |_, cx| { + let focus_handle = focus_handle.clone(); + let should_show_cycle_row = !AgentSettings::get_global(cx) + .favorite_model_ids() + .is_empty(); + + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Change Model")) + .child(KeyBinding::for_action_in( + &ToggleModelSelector, + &focus_handle, + cx, + )), + ) + .when(should_show_cycle_row, |this| { + this.child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new("Cycle Favorited Models")) + .child(KeyBinding::for_action_in( + &CycleFavoriteModels, + &focus_handle, + cx, + )), + ) + }) + .into_any() + } + }); + PickerPopoverMenu::new( self.language_model_selector.clone(), ButtonLike::new("active-model") @@ -2116,7 +2299,7 @@ impl TextThreadEditor { .child( h_flex() .gap_0p5() - .child(Icon::new(provider_icon).color(color).size(IconSize::XSmall)) + .child(provider_icon_element) .child( Label::new(model_name) .color(color) @@ -2125,9 +2308,7 @@ impl TextThreadEditor { ) .child(Icon::new(icon).color(color).size(IconSize::XSmall)), ), - move |_window, cx| { - Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) - }, + tooltip, gpui::Corner::BottomRight, cx, ) @@ -2480,6 +2661,7 @@ impl Render for TextThreadEditor { .capture_action(cx.listener(TextThreadEditor::copy)) .capture_action(cx.listener(TextThreadEditor::cut)) .capture_action(cx.listener(TextThreadEditor::paste)) + .on_action(cx.listener(TextThreadEditor::paste_raw)) .capture_action(cx.listener(TextThreadEditor::cycle_message_role)) .capture_action(cx.listener(TextThreadEditor::confirm_command)) .on_action(cx.listener(TextThreadEditor::assist)) @@ -2487,6 +2669,11 @@ impl Render for TextThreadEditor { .on_action(move |_: &ToggleModelSelector, window, cx| { language_model_selector.toggle(window, cx); }) + .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { + this.language_model_selector.update(cx, |selector, cx| { + selector.delegate.cycle_favorite_models(window, cx); + }); + })) .size_full() .child( div() @@ -2556,7 +2743,11 @@ impl Item for TextThreadEditor { Some(self.title(cx).to_string().into()) } - fn as_searchable(&self, handle: &Entity) -> Option> { + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { Some(Box::new(handle.clone())) } @@ -2618,11 +2809,13 @@ impl SearchableItem for TextThreadEditor { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |editor, cx| editor.update_matches(matches, window, cx)); + self.editor.update(cx, |editor, cx| { + editor.update_matches(matches, active_match_index, window, cx) + }); } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { @@ -3226,7 +3419,6 @@ mod tests { let mut text_thread = TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index e604df416e2725a6f1b7bff8eed883a8cc36e184..b484fdb6c6c480f1cffe78eea7a51f635d3906a1 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -4,8 +4,8 @@ mod burn_mode_tooltip; mod claude_code_onboarding_modal; mod end_trial_upsell; mod hold_for_default; +mod model_selector_components; mod onboarding_modal; -mod unavailable_editing_tooltip; mod usage_callout; pub use acp_onboarding_modal::*; @@ -14,6 +14,6 @@ pub use burn_mode_tooltip::*; pub use claude_code_onboarding_modal::*; pub use end_trial_upsell::*; pub use hold_for_default::*; +pub use model_selector_components::*; pub use onboarding_modal::*; -pub use unavailable_editing_tooltip::*; pub use usage_callout::*; diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs index 8433904fb3b540c2d78c8634b7a6755303d6e15c..e48a36bd5af3eff578e230195dc2247900977173 100644 --- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -222,8 +222,8 @@ impl Render for AcpOnboardingModal { acp_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child(illustration) .child( diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index af2a022f147b79a0a299c17dd26c7e9a8b62aeb9..34ca0bb32a82aa23d1b954554ce2dfec436bfe1c 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -106,9 +106,6 @@ impl Render for AgentNotification { .font(ui_font) .border_color(cx.theme().colors().border) .rounded_xl() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(AgentNotificationEvent::Accepted); - })) .child( h_flex() .items_start() diff --git a/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs index 06980f18977aefe228bb7f09962e69fe2b3a5068..a8f007666d8957a7195fdf36b612b578b16f543c 100644 --- a/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs @@ -230,8 +230,8 @@ impl Render for ClaudeCodeOnboardingModal { claude_code_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child(illustration) .child( diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs new file mode 100644 index 0000000000000000000000000000000000000000..beb0c13d761aa9e7e41c2ac4e35a8cfcc7e8d869 --- /dev/null +++ b/crates/agent_ui/src/ui/model_selector_components.rs @@ -0,0 +1,189 @@ +use gpui::{Action, FocusHandle, prelude::*}; +use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; + +enum ModelIcon { + Name(IconName), + Path(SharedString), +} + +#[derive(IntoElement)] +pub struct ModelSelectorHeader { + title: SharedString, + has_border: bool, +} + +impl ModelSelectorHeader { + pub fn new(title: impl Into, has_border: bool) -> Self { + Self { + title: title.into(), + has_border, + } + } +} + +impl RenderOnce for ModelSelectorHeader { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .px_2() + .pb_1() + .when(self.has_border, |this| { + this.mt_1() + .pt_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + Label::new(self.title) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + } +} + +#[derive(IntoElement)] +pub struct ModelSelectorListItem { + index: usize, + title: SharedString, + icon: Option, + is_selected: bool, + is_focused: bool, + is_favorite: bool, + on_toggle_favorite: Option>, +} + +impl ModelSelectorListItem { + pub fn new(index: usize, title: impl Into) -> Self { + Self { + index, + title: title.into(), + icon: None, + is_selected: false, + is_focused: false, + is_favorite: false, + on_toggle_favorite: None, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(ModelIcon::Name(icon)); + self + } + + pub fn icon_path(mut self, path: SharedString) -> Self { + self.icon = Some(ModelIcon::Path(path)); + self + } + + pub fn is_selected(mut self, is_selected: bool) -> Self { + self.is_selected = is_selected; + self + } + + pub fn is_focused(mut self, is_focused: bool) -> Self { + self.is_focused = is_focused; + self + } + + pub fn is_favorite(mut self, is_favorite: bool) -> Self { + self.is_favorite = is_favorite; + self + } + + pub fn on_toggle_favorite(mut self, handler: impl Fn(&App) + 'static) -> Self { + self.on_toggle_favorite = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for ModelSelectorListItem { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let model_icon_color = if self.is_selected { + Color::Accent + } else { + Color::Muted + }; + + let is_favorite = self.is_favorite; + + ListItem::new(self.index) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(self.is_focused) + .child( + h_flex() + .w_full() + .gap_1p5() + .when_some(self.icon, |this, icon| { + this.child( + match icon { + ModelIcon::Name(icon_name) => Icon::new(icon_name), + ModelIcon::Path(icon_path) => Icon::from_external_svg(icon_path), + } + .color(model_icon_color) + .size(IconSize::Small), + ) + }) + .child(Label::new(self.title).truncate()), + ) + .end_slot(div().pr_2().when(self.is_selected, |this| { + this.child(Icon::new(IconName::Check).color(Color::Accent)) + })) + .end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, { + |this, handle_click| { + let (icon, color, tooltip) = if is_favorite { + (IconName::StarFilled, Color::Accent, "Unfavorite Model") + } else { + (IconName::Star, Color::Default, "Favorite Model") + }; + this.child( + IconButton::new(("toggle-favorite", self.index), icon) + .layer(ElevationIndex::ElevatedSurface) + .icon_color(color) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text(tooltip)) + .on_click(move |_, _, cx| (handle_click)(cx)), + ) + } + })) + } +} + +#[derive(IntoElement)] +pub struct ModelSelectorFooter { + action: Box, + focus_handle: FocusHandle, +} + +impl ModelSelectorFooter { + pub fn new(action: Box, focus_handle: FocusHandle) -> Self { + Self { + action, + focus_handle, + } + } +} + +impl RenderOnce for ModelSelectorFooter { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let action = self.action; + let focus_handle = self.focus_handle; + + h_flex() + .w_full() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("configure", "Configure") + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in(action.as_ref(), &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(action.boxed_clone(), cx); + }), + ) + } +} diff --git a/crates/agent_ui/src/ui/onboarding_modal.rs b/crates/agent_ui/src/ui/onboarding_modal.rs index ad404afa784974631f914e6fece2de6b6c7d6a46..b8ec2b00657efca29fede32a5cc23b669ede66e7 100644 --- a/crates/agent_ui/src/ui/onboarding_modal.rs +++ b/crates/agent_ui/src/ui/onboarding_modal.rs @@ -83,8 +83,8 @@ impl Render for AgentOnboardingModal { agent_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs deleted file mode 100644 index 2993fb89a989619ecfe3d79b06d82a2a6f71fc31..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs +++ /dev/null @@ -1,29 +0,0 @@ -use gpui::{Context, IntoElement, Render, Window}; -use ui::{prelude::*, tooltip_container}; - -pub struct UnavailableEditingTooltip { - agent_name: SharedString, -} - -impl UnavailableEditingTooltip { - pub fn new(agent_name: SharedString) -> Self { - Self { agent_name } - } -} - -impl Render for UnavailableEditingTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |this, _| { - this.child(Label::new("Unavailable Editing")).child( - div().max_w_64().child( - Label::new(format!( - "Editing previous messages is not available for {} yet.", - self.agent_name - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - } -} diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..2b2cf337adf578432d594ce14f2f58e5911c45fb --- /dev/null +++ b/crates/agent_ui_v2/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "agent_ui_v2" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_ui_v2.rs" +doctest = false + +[features] +test-support = ["agent/test-support"] + + +[dependencies] +agent.workspace = true +agent_servers.workspace = true +agent_settings.workspace = true +agent_ui.workspace = true +anyhow.workspace = true +assistant_text_thread.workspace = true +chrono.workspace = true +db.workspace = true +editor.workspace = true +feature_flags.workspace = true +fs.workspace = true +fuzzy.workspace = true +gpui.workspace = true +menu.workspace = true +project.workspace = true +prompt_store.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +text.workspace = true +time.workspace = true +time_format.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +agent = { workspace = true, features = ["test-support"] } diff --git a/crates/cloud_zeta2_prompt/LICENSE-GPL b/crates/agent_ui_v2/LICENSE-GPL similarity index 100% rename from crates/cloud_zeta2_prompt/LICENSE-GPL rename to crates/agent_ui_v2/LICENSE-GPL diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs new file mode 100644 index 0000000000000000000000000000000000000000..72886f87eca38c630ec29b9b410930f1d3936b50 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -0,0 +1,287 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; +use agent_servers::AgentServer; +use agent_settings::AgentSettings; +use agent_ui::acp::AcpThreadView; +use fs::Fs; +use gpui::{ + Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*, +}; +use project::Project; +use prompt_store::PromptStore; +use serde::{Deserialize, Serialize}; +use settings::DockSide; +use settings::Settings as _; +use std::rc::Rc; +use std::sync::Arc; +use ui::{Tab, Tooltip, prelude::*}; +use workspace::{ + Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition}, + utility_pane::UtilityPaneSlot, +}; + +pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0); + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum SerializedHistoryEntryId { + AcpThread(String), + TextThread(String), +} + +impl From for SerializedHistoryEntryId { + fn from(id: HistoryEntryId) -> Self { + match id { + HistoryEntryId::AcpThread(session_id) => { + SerializedHistoryEntryId::AcpThread(session_id.0.to_string()) + } + HistoryEntryId::TextThread(path) => { + SerializedHistoryEntryId::TextThread(path.to_string_lossy().to_string()) + } + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SerializedAgentThreadPane { + pub expanded: bool, + pub width: Option, + pub thread_id: Option, +} + +pub enum AgentsUtilityPaneEvent { + StateChanged, +} + +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} + +struct ActiveThreadView { + view: Entity, + thread_id: HistoryEntryId, + _notify: Subscription, +} + +pub struct AgentThreadPane { + focus_handle: gpui::FocusHandle, + expanded: bool, + width: Option, + thread_view: Option, + workspace: WeakEntity, +} + +impl AgentThreadPane { + pub fn new(workspace: WeakEntity, cx: &mut ui::Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + focus_handle, + expanded: false, + width: None, + thread_view: None, + workspace, + } + } + + pub fn thread_id(&self) -> Option { + self.thread_view.as_ref().map(|tv| tv.thread_id.clone()) + } + + pub fn serialize(&self) -> SerializedAgentThreadPane { + SerializedAgentThreadPane { + expanded: self.expanded, + width: self.width, + thread_id: self.thread_id().map(SerializedHistoryEntryId::from), + } + } + + pub fn open_thread( + &mut self, + entry: HistoryEntry, + fs: Arc, + workspace: WeakEntity, + project: Entity, + history_store: Entity, + prompt_store: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let thread_id = entry.id(); + + let resume_thread = match &entry { + HistoryEntry::AcpThread(thread) => Some(thread.clone()), + HistoryEntry::TextThread(_) => None, + }; + + let agent: Rc = Rc::new(NativeAgentServer::new(fs, history_store.clone())); + + let thread_view = cx.new(|cx| { + AcpThreadView::new( + agent, + resume_thread, + None, + workspace, + project, + history_store, + prompt_store, + true, + window, + cx, + ) + }); + + let notify = cx.observe(&thread_view, |_, _, cx| { + cx.notify(); + }); + + self.thread_view = Some(ActiveThreadView { + view: thread_view, + thread_id, + _notify: notify, + }); + + cx.notify(); + } + + fn title(&self, cx: &App) -> SharedString { + if let Some(active_thread_view) = &self.thread_view { + let thread_view = active_thread_view.view.read(cx); + if let Some(thread) = thread_view.thread() { + let title = thread.read(cx).title(); + if !title.is_empty() { + return title; + } + } + thread_view.title(cx) + } else { + "Thread".into() + } + } + + fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let position = self.position(window, cx); + let slot = match position { + UtilityPanePosition::Left => UtilityPaneSlot::Left, + UtilityPanePosition::Right => UtilityPaneSlot::Right, + }; + + let workspace = self.workspace.clone(); + let toggle_icon = self.toggle_icon(cx); + let title = self.title(cx); + + let pane_toggle_button = |workspace: WeakEntity| { + IconButton::new("toggle_utility_pane", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) + .on_click(move |_, window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane(slot, window, cx) + }) + .ok(); + }) + }; + + h_flex() + .id("utility-pane-header") + .w_full() + .h(Tab::container_height(cx)) + .px_1p5() + .gap(DynamicSpacing::Base06.rems(cx)) + .when(slot == UtilityPaneSlot::Right, |this| { + this.flex_row_reverse() + }) + .flex_none() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(pane_toggle_button(workspace)) + .child( + h_flex() + .size_full() + .min_w_0() + .gap_1() + .map(|this| { + if slot == UtilityPaneSlot::Right { + this.flex_row_reverse().justify_start() + } else { + this.justify_between() + } + }) + .child(Label::new(title).truncate()) + .child( + IconButton::new("close_btn", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Close Agent Pane")) + .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| { + cx.emit(ClosePane); + this.thread_view = None; + cx.notify() + })), + ), + ) + } +} + +impl Focusable for AgentThreadPane { + fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle { + if let Some(thread_view) = &self.thread_view { + thread_view.view.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } +} + +impl UtilityPane for AgentThreadPane { + fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition { + match AgentSettings::get_global(cx).agents_panel_dock { + DockSide::Left => UtilityPanePosition::Left, + DockSide::Right => UtilityPanePosition::Right, + } + } + + fn toggle_icon(&self, _cx: &App) -> IconName { + IconName::Thread + } + + fn expanded(&self, _cx: &App) -> bool { + self.expanded + } + + fn set_expanded(&mut self, expanded: bool, cx: &mut Context) { + self.expanded = expanded; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } + + fn width(&self, _cx: &App) -> Pixels { + self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH) + } + + fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } +} + +impl Render for AgentThreadPane { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = if let Some(thread_view) = &self.thread_view { + div().size_full().child(thread_view.view.clone()) + } else { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Label::new("Select a thread to view details").size(LabelSize::Default)) + }; + + div() + .size_full() + .flex() + .flex_col() + .child(self.render_header(window, cx)) + .child(content) + } +} diff --git a/crates/agent_ui_v2/src/agent_ui_v2.rs b/crates/agent_ui_v2/src/agent_ui_v2.rs new file mode 100644 index 0000000000000000000000000000000000000000..92a4144e304e9afbdcdde54623a3bbf3c65b8746 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_ui_v2.rs @@ -0,0 +1,4 @@ +mod agent_thread_pane; +mod thread_history; + +pub mod agents_panel; diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..254b8d2999dd3f9ce99c07a20273cbb1ca9cb929 --- /dev/null +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -0,0 +1,437 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore}; +use agent_settings::AgentSettings; +use anyhow::Result; +use assistant_text_thread::TextThreadStore; +use db::kvp::KEY_VALUE_STORE; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use fs::Fs; +use gpui::{ + Action, AsyncWindowContext, Entity, EventEmitter, Focusable, Pixels, Subscription, Task, + WeakEntity, actions, prelude::*, +}; +use project::Project; +use prompt_store::{PromptBuilder, PromptStore}; +use serde::{Deserialize, Serialize}; +use settings::{Settings as _, update_settings_file}; +use std::sync::Arc; +use ui::{App, Context, IconName, IntoElement, ParentElement, Render, Styled, Window}; +use util::ResultExt; +use workspace::{ + Panel, Workspace, + dock::{ClosePane, DockPosition, PanelEvent, UtilityPane}, + utility_pane::{UtilityPaneSlot, utility_slot_for_dock_position}, +}; + +use crate::agent_thread_pane::{ + AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId, +}; +use crate::thread_history::{AcpThreadHistory, ThreadHistoryEvent}; + +const AGENTS_PANEL_KEY: &str = "agents_panel"; + +#[derive(Serialize, Deserialize, Debug)] +struct SerializedAgentsPanel { + width: Option, + pane: Option, +} + +actions!( + agents, + [ + /// Toggle the visibility of the agents panel. + ToggleAgentsPanel + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + workspace.register_action(|workspace, _: &ToggleAgentsPanel, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }); + }) + .detach(); +} + +pub struct AgentsPanel { + focus_handle: gpui::FocusHandle, + workspace: WeakEntity, + project: Entity, + agent_thread_pane: Option>, + history: Entity, + history_store: Entity, + prompt_store: Option>, + fs: Arc, + width: Option, + pending_serialization: Task>, + _subscriptions: Vec, +} + +impl AgentsPanel { + pub fn load( + workspace: WeakEntity, + cx: AsyncWindowContext, + ) -> Task, anyhow::Error>> { + cx.spawn(async move |cx| { + let serialized_panel = cx + .background_spawn(async move { + KEY_VALUE_STORE + .read_kvp(AGENTS_PANEL_KEY) + .ok() + .flatten() + .and_then(|panel| { + serde_json::from_str::(&panel).ok() + }) + }) + .await; + + let (fs, project, prompt_builder) = workspace.update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + let project = workspace.project().clone(); + let prompt_builder = PromptBuilder::load(fs.clone(), false, cx); + (fs, project, prompt_builder) + })?; + + let text_thread_store = workspace + .update(cx, |_, cx| { + TextThreadStore::new( + project.clone(), + prompt_builder.clone(), + Default::default(), + cx, + ) + })? + .await?; + + let prompt_store = workspace + .update(cx, |_, cx| PromptStore::global(cx))? + .await + .log_err(); + + workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + let mut panel = Self::new( + workspace.clone(), + fs, + project, + prompt_store, + text_thread_store, + window, + cx, + ); + if let Some(serialized_panel) = serialized_panel { + panel.width = serialized_panel.width; + if let Some(serialized_pane) = serialized_panel.pane { + panel.restore_utility_pane(serialized_pane, window, cx); + } + } + panel + }) + }) + }) + } + + fn new( + workspace: WeakEntity, + fs: Arc, + project: Entity, + prompt_store: Option>, + text_thread_store: Entity, + window: &mut Window, + cx: &mut ui::Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); + + let this = cx.weak_entity(); + let subscriptions = vec![ + cx.subscribe_in(&history, window, Self::handle_history_event), + cx.on_flags_ready(move |_, cx| { + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }), + ]; + + Self { + focus_handle, + workspace, + project, + agent_thread_pane: None, + history, + history_store, + prompt_store, + fs, + width: None, + pending_serialization: Task::ready(None), + _subscriptions: subscriptions, + } + } + + fn restore_utility_pane( + &mut self, + serialized_pane: SerializedAgentThreadPane, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread_id) = &serialized_pane.thread_id else { + return; + }; + + let entry = self + .history_store + .read(cx) + .entries() + .find(|e| match (&e.id(), thread_id) { + ( + HistoryEntryId::AcpThread(session_id), + SerializedHistoryEntryId::AcpThread(id), + ) => session_id.to_string() == *id, + (HistoryEntryId::TextThread(path), SerializedHistoryEntryId::TextThread(id)) => { + path.to_string_lossy() == *id + } + _ => false, + }); + + if let Some(entry) = entry { + self.open_thread( + entry, + serialized_pane.expanded, + serialized_pane.width, + window, + cx, + ); + } + } + + fn handle_utility_pane_event( + &mut self, + _utility_pane: Entity, + event: &AgentsUtilityPaneEvent, + cx: &mut Context, + ) { + match event { + AgentsUtilityPaneEvent::StateChanged => { + self.serialize(cx); + cx.notify(); + } + } + } + + fn handle_close_pane_event( + &mut self, + _utility_pane: Entity, + _event: &ClosePane, + cx: &mut Context, + ) { + self.agent_thread_pane = None; + self.serialize(cx); + cx.notify(); + } + + fn handle_history_event( + &mut self, + _history: &Entity, + event: &ThreadHistoryEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + ThreadHistoryEvent::Open(entry) => { + self.open_thread(entry.clone(), true, None, window, cx); + } + } + } + + fn open_thread( + &mut self, + entry: HistoryEntry, + expanded: bool, + width: Option, + window: &mut Window, + cx: &mut Context, + ) { + let entry_id = entry.id(); + + if let Some(existing_pane) = &self.agent_thread_pane { + if existing_pane.read(cx).thread_id() == Some(entry_id) { + existing_pane.update(cx, |pane, cx| { + pane.set_expanded(true, cx); + }); + return; + } + } + + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); + let project = self.project.clone(); + let history_store = self.history_store.clone(); + let prompt_store = self.prompt_store.clone(); + + let agent_thread_pane = cx.new(|cx| { + let mut pane = AgentThreadPane::new(workspace.clone(), cx); + pane.open_thread( + entry, + fs, + workspace.clone(), + project, + history_store, + prompt_store, + window, + cx, + ); + if let Some(width) = width { + pane.set_width(Some(width), cx); + } + pane.set_expanded(expanded, cx); + pane + }); + + let state_subscription = cx.subscribe(&agent_thread_pane, Self::handle_utility_pane_event); + let close_subscription = cx.subscribe(&agent_thread_pane, Self::handle_close_pane_event); + + self._subscriptions.push(state_subscription); + self._subscriptions.push(close_subscription); + + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, agent_thread_pane.clone(), cx); + }); + } + + self.agent_thread_pane = Some(agent_thread_pane); + self.serialize(cx); + cx.notify(); + } + + fn utility_slot(&self, window: &Window, cx: &App) -> UtilityPaneSlot { + let position = self.position(window, cx); + utility_slot_for_dock_position(position) + } + + fn re_register_utility_pane(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(pane) = &self.agent_thread_pane { + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + let pane = pane.clone(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, pane, cx); + }); + } + } + } + + fn serialize(&mut self, cx: &mut Context) { + let width = self.width; + let pane = self + .agent_thread_pane + .as_ref() + .map(|pane| pane.read(cx).serialize()); + + self.pending_serialization = cx.background_spawn(async move { + KEY_VALUE_STORE + .write_kvp( + AGENTS_PANEL_KEY.into(), + serde_json::to_string(&SerializedAgentsPanel { width, pane }).unwrap(), + ) + .await + .log_err() + }); + } +} + +impl EventEmitter for AgentsPanel {} + +impl Focusable for AgentsPanel { + fn focus_handle(&self, _cx: &ui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Panel for AgentsPanel { + fn persistent_name() -> &'static str { + "AgentsPanel" + } + + fn panel_key() -> &'static str { + AGENTS_PANEL_KEY + } + + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + match AgentSettings::get_global(cx).agents_panel_dock { + settings::DockSide::Left => DockPosition::Left, + settings::DockSide::Right => DockPosition::Right, + } + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position != DockPosition::Bottom + } + + fn set_position( + &mut self, + position: DockPosition, + window: &mut Window, + cx: &mut Context, + ) { + update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.agent.get_or_insert_default().agents_panel_dock = Some(match position { + DockPosition::Left => settings::DockSide::Left, + DockPosition::Right | DockPosition::Bottom => settings::DockSide::Right, + }); + }); + self.re_register_utility_pane(window, cx); + } + + fn size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or(settings.default_width) + } + DockPosition::Bottom => self.width.unwrap_or(settings.default_height), + } + } + + fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => {} + } + self.serialize(cx); + cx.notify(); + } + + fn icon(&self, _window: &Window, cx: &App) -> Option { + (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgentTwo) + } + + fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { + Some("Agents Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleAgentsPanel) + } + + fn activation_priority(&self) -> u32 { + 4 + } + + fn enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).enabled(cx) && cx.has_flag::() + } +} + +impl Render for AgentsPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div().size_full().child(self.history.clone()) + } +} diff --git a/crates/agent_ui_v2/src/thread_history.rs b/crates/agent_ui_v2/src/thread_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e379a24fc3047e6a686046ea16a94ef25efb52c --- /dev/null +++ b/crates/agent_ui_v2/src/thread_history.rs @@ -0,0 +1,753 @@ +use agent::{HistoryEntry, HistoryStore}; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, Window, actions, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, + prelude::*, +}; + +actions!( + agents, + [ + /// Removes all thread history. + RemoveHistory, + /// Removes the currently selected thread. + RemoveSelectedThread, + ] +); + +pub struct AcpThreadHistory { + pub(crate) history_store: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _update_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: HistoryEntry, + format: EntryTimeFormat, + }, + SearchResult { + entry: HistoryEntry, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&HistoryEntry> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +#[allow(dead_code)] +pub enum ThreadHistoryEvent { + Open(HistoryEntry), +} + +impl EventEmitter for AcpThreadHistory {} + +impl AcpThreadHistory { + pub fn new( + history_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_store_subscription = cx.observe(&history_store, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history_store, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_store_subscription], + _update_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self + .history_store + .update(cx, |store, _| store.entries().collect()); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._update_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + let history_entry_id = history_entry.id(); + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.id() == history_entry_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_date = entry + .updated_at() + .with_timezone(&Local) + .naive_local() + .date(); + let entry_bucket = TimeBucket::from_dates(today, entry_date); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, entry.title())); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&HistoryEntry> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.is_empty() { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + pub fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + pub fn select_next( + &mut self, + _: &menu::SelectNext, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + + let task = match entry { + HistoryEntry::AcpThread(thread) => self + .history_store + .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), + HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { + this.delete_text_thread(text_thread.path.clone(), cx) + }), + }; + task.detach_and_log_err(cx); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.history_store.update(cx, |store, cx| { + store.delete_threads(cx).detach_and_log_err(cx) + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &HistoryEntry, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let timestamp = entry.updated_at().timestamp(); + + let display_text = match format { + EntryTimeFormat::DateAndTime => { + let entry_time = entry.updated_at(); + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), + }; + + let title = entry.title().clone(); + let full_date = + EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(entry.title(), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(display_text) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) + }) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for AcpThreadHistory { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for AcpThreadHistory { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history_store.read(cx).is_empty(cx); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); + + let date = today; + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); + + let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); + + let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + // All: not in this week or last week + let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); + + // Test year boundary cases + let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + + let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); + assert_eq!( + TimeBucket::from_dates(new_year, date), + TimeBucket::Yesterday + ); + + let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); + assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); + } +} diff --git a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs index fadc4222ae44f3dbad862fd9479b89321dbd3016..47197ec2331b97dd4d7561d9f14c91c7f91c9fa0 100644 --- a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs +++ b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs @@ -1,9 +1,9 @@ use gpui::{Action, IntoElement, ParentElement, RenderOnce, point}; -use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; +use language_model::{IconOrSvg, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; use ui::{Divider, List, ListBulletItem, prelude::*}; pub struct ApiKeysWithProviders { - configured_providers: Vec<(IconName, SharedString)>, + configured_providers: Vec<(IconOrSvg, SharedString)>, } impl ApiKeysWithProviders { @@ -13,7 +13,8 @@ impl ApiKeysWithProviders { |this: &mut Self, _registry, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) - | language_model::Event::RemovedProvider(_) => { + | language_model::Event::RemovedProvider(_) + | language_model::Event::ProvidersChanged => { this.configured_providers = Self::compute_configured_providers(cx) } _ => {} @@ -26,9 +27,9 @@ impl ApiKeysWithProviders { } } - fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> { + fn compute_configured_providers(cx: &App) -> Vec<(IconOrSvg, SharedString)> { LanguageModelRegistry::read_global(cx) - .providers() + .visible_providers() .iter() .filter(|provider| { provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID @@ -47,7 +48,14 @@ impl Render for ApiKeysWithProviders { .map(|(icon, name)| { h_flex() .gap_1p5() - .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) + .child( + match icon { + IconOrSvg::Icon(icon_name) => Icon::new(icon_name), + IconOrSvg::Svg(icon_path) => Icon::from_external_svg(icon_path), + } + .size(IconSize::XSmall) + .color(Color::Muted), + ) .child(Label::new(name)) }); div() diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index 3c8ffc1663e0660829698b5449a006de5b3c6009..c2756927136449d649996ec3b4b87471114aca38 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding}; pub struct AgentPanelOnboarding { user_store: Entity, client: Arc, - configured_providers: Vec<(IconName, SharedString)>, + has_configured_providers: bool, continue_with_zed_ai: Arc, } @@ -27,8 +27,9 @@ impl AgentPanelOnboarding { |this: &mut Self, _registry, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) - | language_model::Event::RemovedProvider(_) => { - this.configured_providers = Self::compute_available_providers(cx) + | language_model::Event::RemovedProvider(_) + | language_model::Event::ProvidersChanged => { + this.has_configured_providers = Self::has_configured_providers(cx) } _ => {} }, @@ -38,20 +39,16 @@ impl AgentPanelOnboarding { Self { user_store, client, - configured_providers: Self::compute_available_providers(cx), + has_configured_providers: Self::has_configured_providers(cx), continue_with_zed_ai: Arc::new(continue_with_zed_ai), } } - fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> { + fn has_configured_providers(cx: &App) -> bool { LanguageModelRegistry::read_global(cx) - .providers() + .visible_providers() .iter() - .filter(|provider| { - provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID - }) - .map(|provider| (provider.icon(), provider.name().0)) - .collect() + .any(|provider| provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID) } } @@ -81,7 +78,7 @@ impl Render for AgentPanelOnboarding { }), ) .map(|this| { - if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() { + if enrolled_in_trial || is_pro_user || self.has_configured_providers { this } else { this.child(ApiKeysWithoutProviders::new()) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index d4f89808379b0bf10c8f3eaa22484b61fd8c26f1..f0dde3eedea657ea2d2ebe9ede457e329bd8b9a5 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -12,6 +12,8 @@ pub use settings::{AnthropicAvailableModel as AvailableModel, ModelMode}; use strum::{EnumIter, EnumString}; use thiserror::Error; +pub mod batches; + pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -67,6 +69,13 @@ pub enum Model { alias = "claude-opus-4-1-thinking-latest" )] ClaudeOpus4_1Thinking, + #[serde(rename = "claude-opus-4-5", alias = "claude-opus-4-5-latest")] + ClaudeOpus4_5, + #[serde( + rename = "claude-opus-4-5-thinking", + alias = "claude-opus-4-5-thinking-latest" + )] + ClaudeOpus4_5Thinking, #[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")] ClaudeSonnet4, #[serde( @@ -131,6 +140,14 @@ impl Model { } pub fn from_id(id: &str) -> Result { + if id.starts_with("claude-opus-4-5-thinking") { + return Ok(Self::ClaudeOpus4_5Thinking); + } + + if id.starts_with("claude-opus-4-5") { + return Ok(Self::ClaudeOpus4_5); + } + if id.starts_with("claude-opus-4-1-thinking") { return Ok(Self::ClaudeOpus4_1Thinking); } @@ -208,6 +225,8 @@ impl Model { Self::ClaudeOpus4_1 => "claude-opus-4-1-latest", Self::ClaudeOpus4Thinking => "claude-opus-4-thinking-latest", Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-thinking-latest", + Self::ClaudeOpus4_5 => "claude-opus-4-5-latest", + Self::ClaudeOpus4_5Thinking => "claude-opus-4-5-thinking-latest", Self::ClaudeSonnet4 => "claude-sonnet-4-latest", Self::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking-latest", Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-latest", @@ -230,6 +249,7 @@ impl Model { match self { Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking => "claude-opus-4-20250514", Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-20250805", + Self::ClaudeOpus4_5 | Self::ClaudeOpus4_5Thinking => "claude-opus-4-5-20251101", Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => "claude-sonnet-4-20250514", Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking => "claude-sonnet-4-5-20250929", Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest", @@ -249,6 +269,8 @@ impl Model { Self::ClaudeOpus4_1 => "Claude Opus 4.1", Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking", Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking", + Self::ClaudeOpus4_5 => "Claude Opus 4.5", + Self::ClaudeOpus4_5Thinking => "Claude Opus 4.5 Thinking", Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking", Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", @@ -274,6 +296,8 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -303,6 +327,8 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -326,6 +352,8 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -348,6 +376,8 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -372,6 +402,7 @@ impl Model { match self { Self::ClaudeOpus4 | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_5 | Self::ClaudeSonnet4 | Self::ClaudeSonnet4_5 | Self::Claude3_5Sonnet @@ -383,6 +414,7 @@ impl Model { | Self::Claude3Haiku => AnthropicModelMode::Default, Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5Thinking | Self::ClaudeHaiku4_5Thinking @@ -397,10 +429,24 @@ impl Model { let mut headers = vec![]; match self { + Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeSonnet4_5 + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5Thinking + | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5Thinking => { + // Fine-grained tool streaming for newer models + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); + } Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => { // Try beta token-efficient tool use (supported in Claude 3.7 Sonnet only) // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/token-efficient-tool-use headers.push("token-efficient-tools-2025-02-19".to_string()); + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); } Self::Custom { extra_beta_headers, .. @@ -435,6 +481,7 @@ impl Model { } } +/// Generate completion with streaming. pub async fn stream_completion( client: &dyn HttpClient, api_url: &str, @@ -447,6 +494,101 @@ pub async fn stream_completion( .map(|output| output.0) } +/// Generate completion without streaming. +pub async fn non_streaming_completion( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: Request, + beta_headers: Option, +) -> Result { + let (mut response, rate_limits) = + send_request(client, api_url, api_key, &request, beta_headers).await?; + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(handle_error_response(response, rate_limits).await) + } +} + +async fn send_request( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: impl Serialize, + beta_headers: Option, +) -> Result<(http::Response, RateLimitInfo), AnthropicError> { + let uri = format!("{api_url}/v1/messages"); + + let mut request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()) + .header("Content-Type", "application/json"); + + if let Some(beta_headers) = beta_headers { + request_builder = request_builder.header("Anthropic-Beta", beta_headers); + } + + let serialized_request = + serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; + let request = request_builder + .body(AsyncBody::from(serialized_request)) + .map_err(AnthropicError::BuildRequestBody)?; + + let response = client + .send(request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + Ok((response, rate_limits)) +} + +async fn handle_error_response( + mut response: http::Response, + rate_limits: RateLimitInfo, +) -> AnthropicError { + if response.status().as_u16() == 529 { + return AnthropicError::ServerOverloaded { + retry_after: rate_limits.retry_after, + }; + } + + if let Some(retry_after) = rate_limits.retry_after { + return AnthropicError::RateLimit { retry_after }; + } + + let mut body = String::new(); + let read_result = response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse); + + if let Err(err) = read_result { + return err; + } + + match serde_json::from_str::(&body) { + Ok(Event::Error { error }) => AnthropicError::ApiError(error), + Ok(_) | Err(_) => AnthropicError::HttpResponseError { + status_code: response.status(), + message: body, + }, + } +} + /// An individual rate limit. #[derive(Debug)] pub struct RateLimit { @@ -550,30 +692,10 @@ pub async fn stream_completion_with_rate_limit_info( base: request, stream: true, }; - let uri = format!("{api_url}/v1/messages"); - let mut request_builder = HttpRequest::builder() - .method(Method::POST) - .uri(uri) - .header("Anthropic-Version", "2023-06-01") - .header("X-Api-Key", api_key.trim()) - .header("Content-Type", "application/json"); - - if let Some(beta_headers) = beta_headers { - request_builder = request_builder.header("Anthropic-Beta", beta_headers); - } - - let serialized_request = - serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; - let request = request_builder - .body(AsyncBody::from(serialized_request)) - .map_err(AnthropicError::BuildRequestBody)?; + let (response, rate_limits) = + send_request(client, api_url, api_key, &request, beta_headers).await?; - let mut response = client - .send(request) - .await - .map_err(AnthropicError::HttpSend)?; - let rate_limits = RateLimitInfo::from_headers(response.headers()); if response.status().is_success() { let reader = BufReader::new(response.into_body()); let stream = reader @@ -592,27 +714,8 @@ pub async fn stream_completion_with_rate_limit_info( }) .boxed(); Ok((stream, Some(rate_limits))) - } else if response.status().as_u16() == 529 { - Err(AnthropicError::ServerOverloaded { - retry_after: rate_limits.retry_after, - }) - } else if let Some(retry_after) = rate_limits.retry_after { - Err(AnthropicError::RateLimit { retry_after }) } else { - let mut body = String::new(); - response - .body_mut() - .read_to_string(&mut body) - .await - .map_err(AnthropicError::ReadResponse)?; - - match serde_json::from_str::(&body) { - Ok(Event::Error { error }) => Err(AnthropicError::ApiError(error)), - Ok(_) | Err(_) => Err(AnthropicError::HttpResponseError { - status_code: response.status(), - message: body, - }), - } + Err(handle_error_response(response, rate_limits).await) } } @@ -949,6 +1052,71 @@ pub fn parse_prompt_too_long(message: &str) -> Option { .ok() } +/// Request body for the token counting API. +/// Similar to `Request` but without `max_tokens` since it's not needed for counting. +#[derive(Debug, Serialize)] +pub struct CountTokensRequest { + pub model: String, + pub messages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, +} + +/// Response from the token counting API. +#[derive(Debug, Deserialize)] +pub struct CountTokensResponse { + pub input_tokens: u64, +} + +/// Count the number of tokens in a message without creating it. +pub async fn count_tokens( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: CountTokensRequest, +) -> Result { + let uri = format!("{api_url}/v1/messages/count_tokens"); + + let request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()) + .header("Content-Type", "application/json"); + + let serialized_request = + serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; + let http_request = request_builder + .body(AsyncBody::from(serialized_request)) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(handle_error_response(response, rate_limits).await) + } +} + #[test] fn test_match_window_exceeded() { let error = ApiError { diff --git a/crates/anthropic/src/batches.rs b/crates/anthropic/src/batches.rs new file mode 100644 index 0000000000000000000000000000000000000000..5fb594348d45c84e8c246c2611f7cde3aa77a18d --- /dev/null +++ b/crates/anthropic/src/batches.rs @@ -0,0 +1,190 @@ +use anyhow::Result; +use futures::AsyncReadExt; +use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use serde::{Deserialize, Serialize}; + +use crate::{AnthropicError, ApiError, RateLimitInfo, Request, Response}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchRequest { + pub custom_id: String, + pub params: Request, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateBatchRequest { + pub requests: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageBatchRequestCounts { + pub processing: u64, + pub succeeded: u64, + pub errored: u64, + pub canceled: u64, + pub expired: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageBatch { + pub id: String, + #[serde(rename = "type")] + pub batch_type: String, + pub processing_status: String, + pub request_counts: MessageBatchRequestCounts, + pub ended_at: Option, + pub created_at: String, + pub expires_at: String, + pub archived_at: Option, + pub cancel_initiated_at: Option, + pub results_url: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum BatchResult { + #[serde(rename = "succeeded")] + Succeeded { message: Response }, + #[serde(rename = "errored")] + Errored { error: ApiError }, + #[serde(rename = "canceled")] + Canceled, + #[serde(rename = "expired")] + Expired, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchIndividualResponse { + pub custom_id: String, + pub result: BatchResult, +} + +pub async fn create_batch( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: CreateBatchRequest, +) -> Result { + let uri = format!("{api_url}/v1/messages/batches"); + + let request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()) + .header("Content-Type", "application/json"); + + let serialized_request = + serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; + let http_request = request_builder + .body(AsyncBody::from(serialized_request)) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} + +pub async fn retrieve_batch( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + message_batch_id: &str, +) -> Result { + let uri = format!("{api_url}/v1/messages/batches/{message_batch_id}"); + + let request_builder = HttpRequest::builder() + .method(Method::GET) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()); + + let http_request = request_builder + .body(AsyncBody::default()) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} + +pub async fn retrieve_batch_results( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + message_batch_id: &str, +) -> Result, AnthropicError> { + let uri = format!("{api_url}/v1/messages/batches/{message_batch_id}/results"); + + let request_builder = HttpRequest::builder() + .method(Method::GET) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()); + + let http_request = request_builder + .body(AsyncBody::default()) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + let mut results = Vec::new(); + for line in body.lines() { + if line.trim().is_empty() { + continue; + } + let result: BatchIndividualResponse = + serde_json::from_str(line).map_err(AnthropicError::DeserializeResponse)?; + results.push(result); + } + + Ok(results) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index 25db3144ccb10b9cac1b8d8555ea9924e193468c..ab4474aa62faedbfac0bc680a52648fa824c83a1 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -249,10 +249,15 @@ impl PasswordProxy { fs::write(&askpass_script_path, askpass_script) .await .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?; - make_file_executable(&askpass_script_path).await?; + make_file_executable(&askpass_script_path) + .await + .with_context(|| { + format!("marking askpass script executable at {askpass_script_path:?}") + })?; + // todo(shell): There might be no powershell on the system #[cfg(target_os = "windows")] let askpass_helper = format!( - "powershell.exe -ExecutionPolicy Bypass -File {}", + "powershell.exe -ExecutionPolicy Bypass -File \"{}\"", askpass_script_path.display() ); diff --git a/crates/assistant_slash_commands/Cargo.toml b/crates/assistant_slash_commands/Cargo.toml index 85dd92501f93fb79ba1d3f70b3a06f1077356cfa..b2a70449f449f73c7d0017c5d2ba3707e271559a 100644 --- a/crates/assistant_slash_commands/Cargo.toml +++ b/crates/assistant_slash_commands/Cargo.toml @@ -22,7 +22,6 @@ feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true -globset.workspace = true gpui.workspace = true html_to_markdown.workspace = true http_client.workspace = true diff --git a/crates/assistant_slash_commands/src/diagnostics_command.rs b/crates/assistant_slash_commands/src/diagnostics_command.rs index 3a9c33061575d385652b685dcca70ee87c6cac35..3b3e3f7b895d50b36c3981bf4ee442b09bfdf33f 100644 --- a/crates/assistant_slash_commands/src/diagnostics_command.rs +++ b/crates/assistant_slash_commands/src/diagnostics_command.rs @@ -233,18 +233,11 @@ fn collect_diagnostics( options: Options, cx: &mut App, ) -> Task>> { - let error_source = if let Some(path_matcher) = &options.path_matcher { - debug_assert_eq!(path_matcher.sources().len(), 1); - Some(path_matcher.sources().first().cloned().unwrap_or_default()) - } else { - None - }; - let path_style = project.read(cx).path_style(cx); let glob_is_exact_file_match = if let Some(path) = options .path_matcher .as_ref() - .and_then(|pm| pm.sources().first()) + .and_then(|pm| pm.sources().next()) { project .read(cx) @@ -266,6 +259,13 @@ fn collect_diagnostics( .collect(); cx.spawn(async move |cx| { + let error_source = if let Some(path_matcher) = &options.path_matcher { + debug_assert_eq!(path_matcher.sources().count(), 1); + Some(path_matcher.sources().next().unwrap_or_default()) + } else { + None + }; + let mut output = SlashCommandOutput::default(); if let Some(error_source) = error_source.as_ref() { @@ -277,7 +277,7 @@ fn collect_diagnostics( let mut project_summary = DiagnosticSummary::default(); for (project_path, path, summary) in diagnostic_summaries { if let Some(path_matcher) = &options.path_matcher - && !path_matcher.is_match(&path.as_std_path()) + && !path_matcher.is_match(&path) { continue; } diff --git a/crates/assistant_slash_commands/src/file_command.rs b/crates/assistant_slash_commands/src/file_command.rs index a17e198ed300f00f70d35149cbe0286af3a65a57..ae4e8363b40d520b9ea33e5cba5ffa68d783ab04 100644 --- a/crates/assistant_slash_commands/src/file_command.rs +++ b/crates/assistant_slash_commands/src/file_command.rs @@ -226,10 +226,10 @@ fn collect_files( let Ok(matchers) = glob_inputs .iter() .map(|glob_input| { - custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()]) + util::paths::PathMatcher::new(&[glob_input.to_owned()], project.read(cx).path_style(cx)) .with_context(|| format!("invalid path {glob_input}")) }) - .collect::>>() + .collect::>>() else { return futures::stream::once(async { anyhow::bail!("invalid path"); @@ -250,6 +250,7 @@ fn collect_files( let worktree_id = snapshot.id(); let path_style = snapshot.path_style(); let mut directory_stack: Vec> = Vec::new(); + let mut folded_directory_path: Option> = None; let mut folded_directory_names: Arc = RelPath::empty().into(); let mut is_top_level_directory = true; @@ -277,6 +278,16 @@ fn collect_files( )))?; } + if let Some(folded_path) = &folded_directory_path { + if !entry.path.starts_with(folded_path) { + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; + if directory_stack.is_empty() { + is_top_level_directory = true; + } + } + } + let filename = entry.path.file_name().unwrap_or_default().to_string(); if entry.is_dir() { @@ -292,13 +303,17 @@ fn collect_files( folded_directory_names = folded_directory_names.join(RelPath::unix(&filename).unwrap()); } + folded_directory_path = Some(entry.path.clone()); continue; } } else { // Skip empty directories folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; continue; } + + // Render the directory (either folded or normal) if folded_directory_names.is_empty() { let label = if is_top_level_directory { is_top_level_directory = false; @@ -334,6 +349,8 @@ fn collect_files( }, )))?; directory_stack.push(entry.path.clone()); + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; } events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { @@ -447,87 +464,6 @@ pub fn build_entry_output_section( } } -/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix -/// check. Only subpaths pass the prefix check, rather than any prefix. -mod custom_path_matcher { - use globset::{Glob, GlobSet, GlobSetBuilder}; - use std::fmt::Debug as _; - use util::{paths::SanitizedPath, rel_path::RelPath}; - - #[derive(Clone, Debug, Default)] - pub struct PathMatcher { - sources: Vec, - sources_with_trailing_slash: Vec, - glob: GlobSet, - } - - impl std::fmt::Display for PathMatcher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.sources.fmt(f) - } - } - - impl PartialEq for PathMatcher { - fn eq(&self, other: &Self) -> bool { - self.sources.eq(&other.sources) - } - } - - impl Eq for PathMatcher {} - - impl PathMatcher { - pub fn new(globs: &[String]) -> Result { - let globs = globs - .iter() - .map(|glob| Glob::new(&SanitizedPath::new(glob).to_string())) - .collect::, _>>()?; - let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); - let sources_with_trailing_slash = globs - .iter() - .map(|glob| glob.glob().to_string() + "/") - .collect(); - let mut glob_builder = GlobSetBuilder::new(); - for single_glob in globs { - glob_builder.add(single_glob); - } - let glob = glob_builder.build()?; - Ok(PathMatcher { - glob, - sources, - sources_with_trailing_slash, - }) - } - - pub fn is_match(&self, other: &RelPath) -> bool { - self.sources - .iter() - .zip(self.sources_with_trailing_slash.iter()) - .any(|(source, with_slash)| { - let as_bytes = other.as_unix_str().as_bytes(); - let with_slash = if source.ends_with('/') { - source.as_bytes() - } else { - with_slash.as_bytes() - }; - - as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes()) - }) - || self.glob.is_match(other.as_std_path()) - || self.check_with_end_separator(other) - } - - fn check_with_end_separator(&self, path: &RelPath) -> bool { - let path_str = path.as_unix_str(); - let separator = "/"; - if path_str.ends_with(separator) { - false - } else { - self.glob.is_match(path_str.to_string() + separator) - } - } - } -} - pub fn append_buffer_to_output( buffer: &BufferSnapshot, path: Option<&str>, diff --git a/crates/assistant_text_thread/Cargo.toml b/crates/assistant_text_thread/Cargo.toml index 8dfdfa3828340217456088a246eee5b1568a7a77..5ad429758ea1785ecb4fcecb2f3ad83a71afda0d 100644 --- a/crates/assistant_text_thread/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -29,6 +29,7 @@ fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +itertools.workspace = true language.workspace = true language_model.workspace = true log.workspace = true @@ -45,7 +46,7 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true smol.workspace = true -telemetry_events.workspace = true +telemetry.workspace = true text.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs index 75a414dfc4428b3c101a72454bb185b5a171d692..7232a03c212a9dfc4bfe9bcce4a78667d9210ad8 100644 --- a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs +++ b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs @@ -50,7 +50,6 @@ fn test_inserting_and_removing_messages(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -189,7 +188,6 @@ fn test_message_splitting(cx: &mut App) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -294,7 +292,6 @@ fn test_messages_for_offsets(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -405,7 +402,6 @@ async fn test_slash_commands(cx: &mut TestAppContext) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -677,7 +673,6 @@ async fn test_serialization(cx: &mut TestAppContext) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -724,7 +719,6 @@ async fn test_serialization(cx: &mut TestAppContext) { prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -780,7 +774,6 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -880,10 +873,9 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std let num_sections = rng.random_range(0..=3); let mut section_start = 0; for _ in 0..num_sections { - let mut section_end = rng.random_range(section_start..=output_text.len()); - while !output_text.is_char_boundary(section_end) { - section_end += 1; - } + let section_end = output_text.floor_char_boundary( + rng.random_range(section_start..=output_text.len()), + ); events.push(Ok(SlashCommandEvent::StartSection { icon: IconName::Ai, label: "section".into(), @@ -1042,7 +1034,6 @@ fn test_mark_cache_anchors(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -1369,7 +1360,6 @@ fn setup_context_editor_with_fake_model( TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, diff --git a/crates/assistant_text_thread/src/text_thread.rs b/crates/assistant_text_thread/src/text_thread.rs index a50e410ab7d1bd1eb34ba367dfbfd36a7b2ec826..5ec72eb0814f9ac09aba36f52d6f011af5b47249 100644 --- a/crates/assistant_text_thread/src/text_thread.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -5,7 +5,7 @@ use assistant_slash_command::{ SlashCommandResult, SlashCommandWorkingSet, }; use assistant_slash_commands::FileCommandMetadata; -use client::{self, ModelRequestUsage, RequestUsage, proto, telemetry::Telemetry}; +use client::{self, ModelRequestUsage, RequestUsage, proto}; use clock::ReplicaId; use cloud_llm_client::{CompletionIntent, UsageLimit}; use collections::{HashMap, HashSet}; @@ -14,14 +14,16 @@ use fs::{Fs, RenameOptions}; use futures::{FutureExt, StreamExt, future::Shared}; use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, RenderImage, SharedString, Subscription, - Task, + Task, WeakEntity, }; +use itertools::Itertools as _; use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset}; use language_model::{ - LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent, - LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + AnthropicCompletionType, AnthropicEventData, AnthropicEventType, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionEvent, LanguageModelImage, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolUseId, MessageContent, PaymentRequiredError, Role, StopReason, - report_assistant_event, + report_anthropic_event, }; use open_ai::Model as OpenAiModel; use paths::text_threads_dir; @@ -39,7 +41,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use text::{BufferSnapshot, ToPoint}; use ui::IconName; use util::{ResultExt, TryFutureExt, post_inc}; @@ -685,9 +687,8 @@ pub struct TextThread { pending_cache_warming_task: Task>, path: Option>, _subscriptions: Vec, - telemetry: Option>, language_registry: Arc, - project: Option>, + project: Option>, prompt_builder: Arc, completion_mode: agent_settings::CompletionMode, } @@ -707,8 +708,7 @@ impl EventEmitter for TextThread {} impl TextThread { pub fn local( language_registry: Arc, - project: Option>, - telemetry: Option>, + project: Option>, prompt_builder: Arc, slash_commands: Arc, cx: &mut Context, @@ -721,7 +721,6 @@ impl TextThread { prompt_builder, slash_commands, project, - telemetry, cx, ) } @@ -741,8 +740,7 @@ impl TextThread { language_registry: Arc, prompt_builder: Arc, slash_commands: Arc, - project: Option>, - telemetry: Option>, + project: Option>, cx: &mut Context, ) -> Self { let buffer = cx.new(|_cx| { @@ -783,7 +781,6 @@ impl TextThread { completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, path: None, buffer, - telemetry, project, language_registry, slash_commands, @@ -796,7 +793,7 @@ impl TextThread { }); let message = MessageAnchor { id: first_message_id, - start: language::Anchor::MIN, + start: language::Anchor::min_for_buffer(this.buffer.read(cx).remote_id()), }; this.messages_metadata.insert( first_message_id, @@ -872,8 +869,7 @@ impl TextThread { language_registry: Arc, prompt_builder: Arc, slash_commands: Arc, - project: Option>, - telemetry: Option>, + project: Option>, cx: &mut Context, ) -> Self { let id = saved_context.id.clone().unwrap_or_else(TextThreadId::new); @@ -885,7 +881,6 @@ impl TextThread { prompt_builder, slash_commands, project, - telemetry, cx, ); this.path = Some(path); @@ -1146,12 +1141,10 @@ impl TextThread { cx: &App, ) -> bool { let version = &self.buffer.read(cx).version; - let observed_start = range.start == language::Anchor::MIN - || range.start == language::Anchor::MAX - || version.observed(range.start.timestamp); - let observed_end = range.end == language::Anchor::MIN - || range.end == language::Anchor::MAX - || version.observed(range.end.timestamp); + let observed_start = + range.start.is_min() || range.start.is_max() || version.observed(range.start.timestamp); + let observed_end = + range.end.is_min() || range.end.is_max() || version.observed(range.end.timestamp); observed_start && observed_end } @@ -1168,10 +1161,6 @@ impl TextThread { self.language_registry.clone() } - pub fn project(&self) -> Option> { - self.project.clone() - } - pub fn prompt_builder(&self) -> Arc { self.prompt_builder.clone() } @@ -1853,14 +1842,17 @@ impl TextThread { } if ensure_trailing_newline - && buffer.contains_str_at(command_range_end, "\n") + && buffer + .chars_at(command_range_end) + .next() + .is_some_and(|c| c == '\n') { - let newline_offset = insert_position.saturating_sub(1); - if buffer.contains_str_at(newline_offset, "\n") + if let Some((prev_char, '\n')) = + buffer.reversed_chars_at(insert_position).next_tuple() && last_section_range.is_none_or(|last_section_range| { !last_section_range .to_offset(buffer) - .contains(&newline_offset) + .contains(&(insert_position - prev_char.len_utf8())) }) { deletions.push((command_range_end..command_range_end + 1, "")); @@ -2214,24 +2206,26 @@ impl TextThread { .read(cx) .language() .map(|language| language.name()); - report_assistant_event( - AssistantEventData { - conversation_id: Some(this.id.0.clone()), - kind: AssistantKind::Panel, - phase: AssistantPhase::Response, - message_id: None, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - this.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - cx.background_executor(), + + telemetry::event!( + "Assistant Responded", + conversation_id = this.id.0.clone(), + kind = "panel", + phase = "response", + model = model.telemetry_id(), + model_provider = model.provider_id().to_string(), + response_latency, + error_message, + language_name = language_name.as_ref().map(|name| name.to_proto()), ); + report_anthropic_event(&model, AnthropicEventData { + completion_type: AnthropicCompletionType::Panel, + event: AnthropicEventType::Response, + language_name: language_name.map(|name| name.to_proto()), + message_id: None, + }, cx); + if let Ok(stop_reason) = result { match stop_reason { StopReason::ToolUse => {} @@ -2854,7 +2848,8 @@ impl TextThread { messages.next(); } } - let message_end_anchor = message_end.unwrap_or(language::Anchor::MAX); + let message_end_anchor = + message_end.unwrap_or(language::Anchor::max_for_buffer(buffer.remote_id())); let message_end = message_end_anchor.to_offset(buffer); return Some(Message { @@ -2930,6 +2925,7 @@ impl TextThread { RenameOptions { overwrite: true, ignore_if_exists: true, + create_parents: false, }, ) .await?; @@ -2963,7 +2959,7 @@ impl TextThread { } fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut App) { - let Some(project) = &self.project else { + let Some(project) = self.project.as_ref().and_then(|project| project.upgrade()) else { return; }; project.read(cx).user_store().update(cx, |user_store, cx| { diff --git a/crates/assistant_text_thread/src/text_thread_store.rs b/crates/assistant_text_thread/src/text_thread_store.rs index 19c317baf0fa728c77faebc388b5e36008aa39b3..483baa73134334162ea30d269a1f955dd8fe023a 100644 --- a/crates/assistant_text_thread/src/text_thread_store.rs +++ b/crates/assistant_text_thread/src/text_thread_store.rs @@ -4,7 +4,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use assistant_slash_command::{SlashCommandId, SlashCommandWorkingSet}; -use client::{Client, TypedEnvelope, proto, telemetry::Telemetry}; +use client::{Client, TypedEnvelope, proto}; use clock::ReplicaId; use collections::HashMap; use context_server::ContextServerId; @@ -48,10 +48,9 @@ pub struct TextThreadStore { fs: Arc, languages: Arc, slash_commands: Arc, - telemetry: Arc, _watch_updates: Task>, client: Arc, - project: Entity, + project: WeakEntity, project_is_shared: bool, client_subscription: Option, _project_subscriptions: Vec, @@ -88,7 +87,6 @@ impl TextThreadStore { ) -> Task>> { let fs = project.read(cx).fs().clone(); let languages = project.read(cx).languages().clone(); - let telemetry = project.read(cx).client().telemetry().clone(); cx.spawn(async move |cx| { const CONTEXT_WATCH_DURATION: Duration = Duration::from_millis(100); let (mut events, _) = fs.watch(text_threads_dir(), CONTEXT_WATCH_DURATION).await; @@ -102,7 +100,6 @@ impl TextThreadStore { fs, languages, slash_commands, - telemetry, _watch_updates: cx.spawn(async move |this, cx| { async move { while events.next().await.is_some() { @@ -119,10 +116,10 @@ impl TextThreadStore { ], project_is_shared: false, client: project.read(cx).client(), - project: project.clone(), + project: project.downgrade(), prompt_builder, }; - this.handle_project_shared(project.clone(), cx); + this.handle_project_shared(cx); this.synchronize_contexts(cx); this.register_context_server_handlers(cx); this.reload(cx).detach_and_log_err(cx); @@ -143,10 +140,9 @@ impl TextThreadStore { fs: project.read(cx).fs().clone(), languages: project.read(cx).languages().clone(), slash_commands: Arc::default(), - telemetry: project.read(cx).client().telemetry().clone(), _watch_updates: Task::ready(None), client: project.read(cx).client(), - project, + project: project.downgrade(), project_is_shared: false, client_subscription: None, _project_subscriptions: Default::default(), @@ -180,8 +176,10 @@ impl TextThreadStore { ) -> Result { let context_id = TextThreadId::from_proto(envelope.payload.context_id); let operations = this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; + anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "only the host contexts can be opened" ); @@ -211,8 +209,9 @@ impl TextThreadStore { mut cx: AsyncApp, ) -> Result { let (context_id, operations) = this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "can only create contexts as the host" ); @@ -255,8 +254,9 @@ impl TextThreadStore { mut cx: AsyncApp, ) -> Result { this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "only the host can synchronize contexts" ); @@ -293,8 +293,12 @@ impl TextThreadStore { })? } - fn handle_project_shared(&mut self, _: Entity, cx: &mut Context) { - let is_shared = self.project.read(cx).is_shared(); + fn handle_project_shared(&mut self, cx: &mut Context) { + let Some(project) = self.project.upgrade() else { + return; + }; + + let is_shared = project.read(cx).is_shared(); let was_shared = mem::replace(&mut self.project_is_shared, is_shared); if is_shared == was_shared { return; @@ -309,7 +313,7 @@ impl TextThreadStore { false } }); - let remote_id = self.project.read(cx).remote_id().unwrap(); + let remote_id = project.read(cx).remote_id().unwrap(); self.client_subscription = self .client .subscribe_to_entity(remote_id) @@ -323,13 +327,13 @@ impl TextThreadStore { fn handle_project_event( &mut self, - project: Entity, + _project: Entity, event: &project::Event, cx: &mut Context, ) { match event { project::Event::RemoteIdChanged(_) => { - self.handle_project_shared(project, cx); + self.handle_project_shared(cx); } project::Event::Reshared => { self.advertise_contexts(cx); @@ -371,7 +375,6 @@ impl TextThreadStore { TextThread::local( self.languages.clone(), Some(self.project.clone()), - Some(self.telemetry.clone()), self.prompt_builder.clone(), self.slash_commands.clone(), cx, @@ -382,7 +385,10 @@ impl TextThreadStore { } pub fn create_remote(&mut self, cx: &mut Context) -> Task>> { - let project = self.project.read(cx); + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("project was dropped"))); + }; + let project = project.read(cx); let Some(project_id) = project.remote_id() else { return Task::ready(Err(anyhow::anyhow!("project was not remote"))); }; @@ -391,7 +397,7 @@ impl TextThreadStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); + let prompt_builder = self.prompt_builder.clone(); let slash_commands = self.slash_commands.clone(); let request = self.client.request(proto::CreateContext { project_id }); @@ -408,7 +414,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -446,7 +451,6 @@ impl TextThreadStore { let fs = self.fs.clone(); let languages = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let load = cx.background_spawn({ let path = path.clone(); async move { @@ -467,7 +471,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -541,7 +544,10 @@ impl TextThreadStore { text_thread_id: TextThreadId, cx: &mut Context, ) -> Task>> { - let project = self.project.read(cx); + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("project was dropped"))); + }; + let project = project.read(cx); let Some(project_id) = project.remote_id() else { return Task::ready(Err(anyhow::anyhow!("project was not remote"))); }; @@ -554,7 +560,6 @@ impl TextThreadStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let request = self.client.request(proto::OpenContext { project_id, context_id: text_thread_id.to_proto(), @@ -573,7 +578,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -618,7 +622,10 @@ impl TextThreadStore { event: &TextThreadEvent, cx: &mut Context, ) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; @@ -652,12 +659,14 @@ impl TextThreadStore { } fn advertise_contexts(&self, cx: &App) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; - // For now, only the host can advertise their open contexts. - if self.project.read(cx).is_via_collab() { + if project.read(cx).is_via_collab() { return; } @@ -689,7 +698,10 @@ impl TextThreadStore { } fn synchronize_contexts(&mut self, cx: &mut Context) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; @@ -828,7 +840,10 @@ impl TextThreadStore { } fn register_context_server_handlers(&self, cx: &mut Context) { - let context_server_store = self.project.read(cx).context_server_store(); + let Some(project) = self.project.upgrade() else { + return; + }; + let context_server_store = project.read(cx).context_server_store(); cx.subscribe(&context_server_store, Self::handle_context_server_event) .detach(); diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 599afcf62d610cfc57a1216f46b1910a88e99bea..0c122717d7a377e5aa5e8d23cab4de435bd67e33 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -510,7 +510,9 @@ impl AutoUpdater { (None, None, None) }; - let version = if let Some(version) = version { + let version = if let Some(mut version) = version { + version.pre = semver::Prerelease::EMPTY; + version.build = semver::BuildMetadata::EMPTY; version.to_string() } else { "latest".to_string() @@ -637,10 +639,11 @@ impl AutoUpdater { if let AutoUpdateStatus::Updated { version, .. } = status { match version { VersionCheckType::Sha(cached_version) => { - let should_download = parsed_fetched_version - .as_ref() - .ok() - .is_none_or(|version| version.build.as_str() != cached_version.full()); + let should_download = + parsed_fetched_version.as_ref().ok().is_none_or(|version| { + version.build.as_str().rsplit('.').next() + != Some(&cached_version.full()) + }); let newer_version = should_download .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); return Ok(newer_version); @@ -660,10 +663,9 @@ impl AutoUpdater { .ok() .flatten() .map(|sha| { - parsed_fetched_version - .as_ref() - .ok() - .is_none_or(|version| version.build.as_str() != sha) + parsed_fetched_version.as_ref().ok().is_none_or(|version| { + version.build.as_str().rsplit('.').next() != Some(&sha) + }) }) .unwrap_or(true); let newer_version = should_download @@ -717,9 +719,12 @@ impl AutoUpdater { } fn check_if_fetched_version_is_newer_non_nightly( - installed_version: Version, + mut installed_version: Version, fetched_version: Version, ) -> Result> { + // For non-nightly releases, ignore build and pre-release fields as they're not provided by our endpoints right now. + installed_version.build = semver::BuildMetadata::EMPTY; + installed_version.pre = semver::Prerelease::EMPTY; let should_download = fetched_version > installed_version; let newer_version = should_download.then(|| VersionCheckType::Semantic(fetched_version)); Ok(newer_version) diff --git a/crates/auto_update_ui/Cargo.toml b/crates/auto_update_ui/Cargo.toml index 0e31f94f5ee268cdc3274dea747bd0b05d9c80eb..2b1421e35dcbcf6fac40cd0e97a3dc839da58d9e 100644 --- a/crates/auto_update_ui/Cargo.toml +++ b/crates/auto_update_ui/Cargo.toml @@ -20,6 +20,7 @@ gpui.workspace = true http_client.workspace = true markdown_preview.workspace = true release_channel.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true smol.workspace = true diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index aeaa6ae93e635a6cab1487400fb58bd7be1bc6e1..6c32ee3b6c9b9c4974a287ff0e9a988472cecf3b 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -148,7 +148,9 @@ pub fn notify_if_app_was_updated(cx: &mut App) { let should_show_notification = should_show_notification.await?; if should_show_notification { cx.update(|cx| { - let version = updater.read(cx).current_version(); + let mut version = updater.read(cx).current_version(); + version.build = semver::BuildMetadata::EMPTY; + version.pre = semver::Prerelease::EMPTY; let app_name = ReleaseChannel::global(cx).display_name(); show_app_notification( NotificationId::unique::(), diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index ec0b4070906fdfd31195668312b3e7b425cd28ee..744dde38076a5a12c9bc957a75e2435b1b753d96 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -87,7 +87,7 @@ pub async fn stream_completion( Ok(None) => None, Err(err) => Some(( Err(BedrockError::ClientError(anyhow!( - "{:?}", + "{}", aws_sdk_bedrockruntime::error::DisplayErrorContext(err) ))), stream, diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index 1691ffe199975983fbb40b781aac00a2703871ea..51e1b29f9ad3cf953605c5c59090785f3ab45eac 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -51,6 +51,13 @@ pub enum Model { alias = "claude-opus-4-1-thinking-latest" )] ClaudeOpus4_1Thinking, + #[serde(rename = "claude-opus-4-5", alias = "claude-opus-4-5-latest")] + ClaudeOpus4_5, + #[serde( + rename = "claude-opus-4-5-thinking", + alias = "claude-opus-4-5-thinking-latest" + )] + ClaudeOpus4_5Thinking, #[serde(rename = "claude-3-5-sonnet-v2", alias = "claude-3-5-sonnet-latest")] Claude3_5SonnetV2, #[serde(rename = "claude-3-7-sonnet", alias = "claude-3-7-sonnet-latest")] @@ -141,7 +148,19 @@ impl Model { } pub fn from_id(id: &str) -> anyhow::Result { - if id.starts_with("claude-3-5-sonnet-v2") { + if id.starts_with("claude-opus-4-5-thinking") { + Ok(Self::ClaudeOpus4_5Thinking) + } else if id.starts_with("claude-opus-4-5") { + Ok(Self::ClaudeOpus4_5) + } else if id.starts_with("claude-opus-4-1-thinking") { + Ok(Self::ClaudeOpus4_1Thinking) + } else if id.starts_with("claude-opus-4-1") { + Ok(Self::ClaudeOpus4_1) + } else if id.starts_with("claude-opus-4-thinking") { + Ok(Self::ClaudeOpus4Thinking) + } else if id.starts_with("claude-opus-4") { + Ok(Self::ClaudeOpus4) + } else if id.starts_with("claude-3-5-sonnet-v2") { Ok(Self::Claude3_5SonnetV2) } else if id.starts_with("claude-3-opus") { Ok(Self::Claude3Opus) @@ -178,6 +197,8 @@ impl Model { Model::ClaudeOpus4_1 => "claude-opus-4-1", Model::ClaudeOpus4Thinking => "claude-opus-4-thinking", Model::ClaudeOpus4_1Thinking => "claude-opus-4-1-thinking", + Model::ClaudeOpus4_5 => "claude-opus-4-5", + Model::ClaudeOpus4_5Thinking => "claude-opus-4-5-thinking", Model::Claude3_5SonnetV2 => "claude-3-5-sonnet-v2", Model::Claude3_5Sonnet => "claude-3-5-sonnet", Model::Claude3Opus => "claude-3-opus", @@ -245,6 +266,9 @@ impl Model { Model::ClaudeOpus4_1 | Model::ClaudeOpus4_1Thinking => { "anthropic.claude-opus-4-1-20250805-v1:0" } + Model::ClaudeOpus4_5 | Model::ClaudeOpus4_5Thinking => { + "anthropic.claude-opus-4-5-20251101-v1:0" + } Model::Claude3_5SonnetV2 => "anthropic.claude-3-5-sonnet-20241022-v2:0", Model::Claude3_5Sonnet => "anthropic.claude-3-5-sonnet-20240620-v1:0", Model::Claude3Opus => "anthropic.claude-3-opus-20240229-v1:0", @@ -309,6 +333,8 @@ impl Model { Self::ClaudeOpus4_1 => "Claude Opus 4.1", Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking", Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking", + Self::ClaudeOpus4_5 => "Claude Opus 4.5", + Self::ClaudeOpus4_5Thinking => "Claude Opus 4.5 Thinking", Self::Claude3_5SonnetV2 => "Claude 3.5 Sonnet v2", Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", Self::Claude3Opus => "Claude 3 Opus", @@ -379,7 +405,9 @@ impl Model { | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking | Self::ClaudeOpus4Thinking - | Self::ClaudeOpus4_1Thinking => 200_000, + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking => 200_000, Self::AmazonNovaPremier => 1_000_000, Self::PalmyraWriterX5 => 1_000_000, Self::PalmyraWriterX4 => 128_000, @@ -393,7 +421,11 @@ impl Model { Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3_5Haiku => 4_096, Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => 128_000, Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => 64_000, - Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking | Self::ClaudeHaiku4_5 => 64_000, + Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4_5Thinking + | Self::ClaudeHaiku4_5 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking => 64_000, Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 @@ -418,6 +450,8 @@ impl Model { | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -443,6 +477,8 @@ impl Model { | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -484,7 +520,9 @@ impl Model { | Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_1Thinking => true, + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking => true, // Custom models - check if they have cache configuration Self::Custom { @@ -506,7 +544,9 @@ impl Model { | Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_1Thinking => Some(BedrockModelCacheConfiguration { + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking => Some(BedrockModelCacheConfiguration { max_cache_anchors: 4, min_total_token: 1024, }), @@ -535,50 +575,109 @@ impl Model { budget_tokens: Some(4096), } } - Model::ClaudeOpus4Thinking | Model::ClaudeOpus4_1Thinking => { - BedrockModelMode::Thinking { - budget_tokens: Some(4096), - } - } + Model::ClaudeOpus4Thinking + | Model::ClaudeOpus4_1Thinking + | Model::ClaudeOpus4_5Thinking => BedrockModelMode::Thinking { + budget_tokens: Some(4096), + }, _ => BedrockModelMode::Default, } } - pub fn cross_region_inference_id(&self, region: &str) -> anyhow::Result { + pub fn cross_region_inference_id( + &self, + region: &str, + allow_global: bool, + ) -> anyhow::Result { + // List derived from here: + // https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system + let model_id = self.request_id(); + + let supports_global = matches!( + self, + Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeHaiku4_5 + | Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking + ); + let region_group = if region.starts_with("us-gov-") { "us-gov" - } else if region.starts_with("us-") { - "us" + } else if region.starts_with("us-") + || region.starts_with("ca-") + || region.starts_with("sa-") + { + if allow_global && supports_global { + "global" + } else { + "us" + } } else if region.starts_with("eu-") { - "eu" + if allow_global && supports_global { + "global" + } else { + "eu" + } } else if region.starts_with("ap-") || region == "me-central-1" || region == "me-south-1" { - "apac" - } else if region.starts_with("ca-") || region.starts_with("sa-") { - // Canada and South America regions - default to US profiles - "us" + if allow_global && supports_global { + "global" + } else { + "apac" + } } else { anyhow::bail!("Unsupported Region {region}"); }; - let model_id = self.request_id(); + match (self, region_group, region) { + (Model::Custom { .. }, _, _) => Ok(self.request_id().into()), + + ( + Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeHaiku4_5 + | Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking, + "global", + _, + ) => Ok(format!("{}.{}", region_group, model_id)), - match (self, region_group) { - // Custom models can't have CRI IDs - (Model::Custom { .. }, _) => Ok(self.request_id().into()), + ( + Model::Claude3Haiku + | Model::Claude3_5Sonnet + | Model::Claude3_7Sonnet + | Model::Claude3_7SonnetThinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking, + "us-gov", + _, + ) => Ok(format!("{}.{}", region_group, model_id)), - // Models with US Gov only - (Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "us-gov") => { - Ok(format!("{}.{}", region_group, model_id)) - } + ( + Model::ClaudeHaiku4_5 | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking, + "apac", + "ap-southeast-2" | "ap-southeast-4", + ) => Ok(format!("au.{}", model_id)), - // Available everywhere - (Model::AmazonNovaLite | Model::AmazonNovaMicro | Model::AmazonNovaPro, _) => { - Ok(format!("{}.{}", region_group, model_id)) + ( + Model::ClaudeHaiku4_5 | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking, + "apac", + "ap-northeast-1" | "ap-northeast-3", + ) => Ok(format!("jp.{}", model_id)), + + (Model::AmazonNovaLite, "us", r) if r.starts_with("ca-") => { + Ok(format!("ca.{}", model_id)) } - // Models in US ( Model::AmazonNovaPremier + | Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro | Model::Claude3_5Haiku | Model::ClaudeHaiku4_5 | Model::Claude3_5Sonnet @@ -593,6 +692,8 @@ impl Model { | Model::ClaudeOpus4Thinking | Model::ClaudeOpus4_1 | Model::ClaudeOpus4_1Thinking + | Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking | Model::Claude3Haiku | Model::Claude3Opus | Model::Claude3Sonnet @@ -613,16 +714,18 @@ impl Model { | Model::PalmyraWriterX4 | Model::PalmyraWriterX5, "us", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Models available in EU ( - Model::Claude3_5Sonnet + Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro + | Model::Claude3_5Sonnet | Model::ClaudeHaiku4_5 | Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking | Model::ClaudeSonnet4 - | Model::ClaudeSonnet4Thinking | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking | Model::Claude3Haiku @@ -631,26 +734,26 @@ impl Model { | Model::MetaLlama323BInstructV1 | Model::MistralPixtralLarge2502V1, "eu", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Models available in APAC ( - Model::Claude3_5Sonnet + Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro + | Model::Claude3_5Sonnet | Model::Claude3_5SonnetV2 | Model::ClaudeHaiku4_5 - | Model::Claude3Haiku - | Model::Claude3Sonnet | Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking | Model::ClaudeSonnet4 - | Model::ClaudeSonnet4Thinking - | Model::ClaudeSonnet4_5 - | Model::ClaudeSonnet4_5Thinking, + | Model::Claude3Haiku + | Model::Claude3Sonnet, "apac", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Any other combination is not supported - _ => Ok(self.request_id().into()), + _ => Ok(model_id.into()), } } } @@ -663,15 +766,15 @@ mod tests { fn test_us_region_inference_ids() -> anyhow::Result<()> { // Test US regions assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("us-east-1")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("us-west-2")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("us-west-2", false)?, "us.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::AmazonNovaPro.cross_region_inference_id("us-east-2")?, + Model::AmazonNovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" ); Ok(()) @@ -681,19 +784,19 @@ mod tests { fn test_eu_region_inference_ids() -> anyhow::Result<()> { // Test European regions assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1")?, + Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1")?, + Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::Claude3Sonnet.cross_region_inference_id("eu-west-1")?, + Model::Claude3Sonnet.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-3-sonnet-20240229-v1:0" ); assert_eq!( - Model::AmazonNovaMicro.cross_region_inference_id("eu-north-1")?, + Model::AmazonNovaMicro.cross_region_inference_id("eu-north-1", false)?, "eu.amazon.nova-micro-v1:0" ); Ok(()) @@ -703,15 +806,15 @@ mod tests { fn test_apac_region_inference_ids() -> anyhow::Result<()> { // Test Asia-Pacific regions assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1", false)?, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2", false)?, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::AmazonNovaLite.cross_region_inference_id("ap-south-1")?, + Model::AmazonNovaLite.cross_region_inference_id("ap-south-1", false)?, "apac.amazon.nova-lite-v1:0" ); Ok(()) @@ -721,11 +824,11 @@ mod tests { fn test_gov_region_inference_ids() -> anyhow::Result<()> { // Test Government regions assert_eq!( - Model::Claude3_5Sonnet.cross_region_inference_id("us-gov-east-1")?, + Model::Claude3_5Sonnet.cross_region_inference_id("us-gov-east-1", false)?, "us-gov.anthropic.claude-3-5-sonnet-20240620-v1:0" ); assert_eq!( - Model::Claude3Haiku.cross_region_inference_id("us-gov-west-1")?, + Model::Claude3Haiku.cross_region_inference_id("us-gov-west-1", false)?, "us-gov.anthropic.claude-3-haiku-20240307-v1:0" ); Ok(()) @@ -735,15 +838,15 @@ mod tests { fn test_meta_models_inference_ids() -> anyhow::Result<()> { // Test Meta models assert_eq!( - Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?, + Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1", false)?, "meta.llama3-70b-instruct-v1:0" ); assert_eq!( - Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1")?, + Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1", false)?, "us.meta.llama3-1-70b-instruct-v1:0" ); assert_eq!( - Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?, + Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1", false)?, "eu.meta.llama3-2-1b-instruct-v1:0" ); Ok(()) @@ -754,11 +857,11 @@ mod tests { // Mistral models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::MistralMistralLarge2402V1.cross_region_inference_id("us-east-1")?, + Model::MistralMistralLarge2402V1.cross_region_inference_id("us-east-1", false)?, "mistral.mistral-large-2402-v1:0" ); assert_eq!( - Model::MistralMixtral8x7BInstructV0.cross_region_inference_id("eu-west-1")?, + Model::MistralMixtral8x7BInstructV0.cross_region_inference_id("eu-west-1", false)?, "mistral.mixtral-8x7b-instruct-v0:1" ); Ok(()) @@ -769,11 +872,11 @@ mod tests { // AI21 models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::AI21J2UltraV1.cross_region_inference_id("us-east-1")?, + Model::AI21J2UltraV1.cross_region_inference_id("us-east-1", false)?, "ai21.j2-ultra-v1" ); assert_eq!( - Model::AI21JambaInstructV1.cross_region_inference_id("eu-west-1")?, + Model::AI21JambaInstructV1.cross_region_inference_id("eu-west-1", false)?, "ai21.jamba-instruct-v1:0" ); Ok(()) @@ -784,11 +887,11 @@ mod tests { // Cohere models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::CohereCommandRV1.cross_region_inference_id("us-east-1")?, + Model::CohereCommandRV1.cross_region_inference_id("us-east-1", false)?, "cohere.command-r-v1:0" ); assert_eq!( - Model::CohereCommandTextV14_4k.cross_region_inference_id("ap-southeast-1")?, + Model::CohereCommandTextV14_4k.cross_region_inference_id("ap-southeast-1", false)?, "cohere.command-text-v14:7:4k" ); Ok(()) @@ -808,10 +911,17 @@ mod tests { // Custom model should return its name unchanged assert_eq!( - custom_model.cross_region_inference_id("us-east-1")?, + custom_model.cross_region_inference_id("us-east-1", false)?, "custom.my-model-v1:0" ); + // Test that models without global support fall back to regional when allow_global is true + assert_eq!( + Model::AmazonNovaPro.cross_region_inference_id("us-east-1", true)?, + "us.amazon.nova-pro-v1:0", + "Nova Pro should fall back to regional profile even when allow_global is true" + ); + Ok(()) } @@ -850,3 +960,28 @@ mod tests { ); } } + +#[test] +fn test_global_inference_ids() -> anyhow::Result<()> { + // Test global inference for models that support it when allow_global is true + assert_eq!( + Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-sonnet-4-20250514-v1:0" + ); + assert_eq!( + Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?, + "global.anthropic.claude-sonnet-4-5-20250929-v1:0" + ); + assert_eq!( + Model::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, + "global.anthropic.claude-haiku-4-5-20251001-v1:0" + ); + + // Test that regional prefix is used when allow_global is false + assert_eq!( + Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-sonnet-4-20250514-v1:0" + ); + + Ok(()) +} diff --git a/crates/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index 1be21f3a0f1ef7aafa222a611d858f8adb097454..6249ae418c593f5ae8bca3408d8f5f25df7c871b 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -12,7 +12,7 @@ workspace = true path = "src/buffer_diff.rs" [features] -test-support = [] +test-support = ["settings"] [dependencies] anyhow.workspace = true @@ -24,6 +24,7 @@ language.workspace = true log.workspace = true pretty_assertions.workspace = true rope.workspace = true +settings = { workspace = true, optional = true } sum_tree.workspace = true text.workspace = true util.workspace = true @@ -33,6 +34,7 @@ ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } rand.workspace = true serde_json.workspace = true +settings.workspace = true text = { workspace = true, features = ["test-support"] } unindent.workspace = true zlog.workspace = true diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index d6ae5545200bb47976554814e346be3039fa276e..22525096d3cbca456aa114b5acc9b4239b570dda 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1,7 +1,10 @@ use futures::channel::oneshot; use git2::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch}; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, TaskLabel}; -use language::{Language, LanguageRegistry}; +use language::{ + BufferRow, DiffOptions, File, Language, LanguageName, LanguageRegistry, + language_settings::language_settings, word_diff_ranges, +}; use rope::Rope; use std::{ cmp::Ordering, @@ -11,14 +14,16 @@ use std::{ sync::{Arc, LazyLock}, }; use sum_tree::SumTree; -use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point, ToOffset as _}; +use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point, ToOffset as _, ToPoint as _}; use util::ResultExt; pub static CALCULATE_DIFF_TASK: LazyLock = LazyLock::new(TaskLabel::new); +pub const MAX_WORD_DIFF_LINE_COUNT: usize = 5; pub struct BufferDiff { pub buffer_id: BufferId, inner: BufferDiffInner, + // diff of the index vs head secondary_diff: Option>, } @@ -31,6 +36,7 @@ pub struct BufferDiffSnapshot { #[derive(Clone)] struct BufferDiffInner { hunks: SumTree, + // Used for making staging mo pending_hunks: SumTree, base_text: language::BufferSnapshot, base_text_exists: bool, @@ -50,11 +56,18 @@ pub enum DiffHunkStatusKind { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Diff of Working Copy vs Index +/// aka 'is this hunk staged or not' pub enum DiffHunkSecondaryStatus { + /// Unstaged HasSecondaryHunk, + /// Partially staged OverlapsWithSecondaryHunk, + /// Staged NoSecondaryHunk, + /// We are unstaging SecondaryHunkAdditionPending, + /// We are stagind SecondaryHunkRemovalPending, } @@ -68,6 +81,10 @@ pub struct DiffHunk { /// The range in the buffer's diff base text to which this hunk corresponds. pub diff_base_byte_range: Range, pub secondary_status: DiffHunkSecondaryStatus, + // Anchors representing the word diff locations in the active buffer + pub buffer_word_diffs: Vec>, + // Offsets relative to the start of the deleted diff that represent word diff locations + pub base_word_diffs: Vec>, } /// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range. @@ -75,6 +92,8 @@ pub struct DiffHunk { struct InternalDiffHunk { buffer_range: Range, diff_base_byte_range: Range, + base_word_diffs: Vec>, + buffer_word_diffs: Vec>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -88,6 +107,7 @@ struct PendingHunk { #[derive(Debug, Clone)] pub struct DiffHunkSummary { buffer_range: Range, + diff_base_byte_range: Range, } impl sum_tree::Item for InternalDiffHunk { @@ -96,6 +116,7 @@ impl sum_tree::Item for InternalDiffHunk { fn summary(&self, _cx: &text::BufferSnapshot) -> Self::Summary { DiffHunkSummary { buffer_range: self.buffer_range.clone(), + diff_base_byte_range: self.diff_base_byte_range.clone(), } } } @@ -106,6 +127,7 @@ impl sum_tree::Item for PendingHunk { fn summary(&self, _cx: &text::BufferSnapshot) -> Self::Summary { DiffHunkSummary { buffer_range: self.buffer_range.clone(), + diff_base_byte_range: self.diff_base_byte_range.clone(), } } } @@ -116,6 +138,7 @@ impl sum_tree::Summary for DiffHunkSummary { fn zero(_cx: Self::Context<'_>) -> Self { DiffHunkSummary { buffer_range: Anchor::MIN..Anchor::MIN, + diff_base_byte_range: 0..0, } } @@ -125,6 +148,15 @@ impl sum_tree::Summary for DiffHunkSummary { .start .min(&other.buffer_range.start, buffer); self.buffer_range.end = *self.buffer_range.end.max(&other.buffer_range.end, buffer); + + self.diff_base_byte_range.start = self + .diff_base_byte_range + .start + .min(other.diff_base_byte_range.start); + self.diff_base_byte_range.end = self + .diff_base_byte_range + .end + .max(other.diff_base_byte_range.end); } } @@ -147,11 +179,16 @@ impl std::fmt::Debug for BufferDiffInner { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BufferDiffSnapshot") .field("hunks", &self.hunks) + .field("remote_id", &self.base_text.remote_id()) .finish() } } impl BufferDiffSnapshot { + pub fn buffer_diff_id(&self) -> BufferId { + self.inner.base_text.remote_id() + } + fn empty(buffer: &text::BufferSnapshot, cx: &mut App) -> BufferDiffSnapshot { BufferDiffSnapshot { inner: BufferDiffInner { @@ -190,6 +227,13 @@ impl BufferDiffSnapshot { let base_text_pair; let base_text_exists; let base_text_snapshot; + let diff_options = build_diff_options( + None, + language.as_ref().map(|l| l.name()), + language.as_ref().map(|l| l.default_scope()), + cx, + ); + if let Some(text) = &base_text { let base_text_rope = Rope::from(text.as_str()); base_text_pair = Some((text.clone(), base_text_rope.clone())); @@ -207,7 +251,7 @@ impl BufferDiffSnapshot { .background_executor() .spawn_labeled(*CALCULATE_DIFF_TASK, { let buffer = buffer.clone(); - async move { compute_hunks(base_text_pair, buffer) } + async move { compute_hunks(base_text_pair, buffer, diff_options) } }); async move { @@ -230,6 +274,12 @@ impl BufferDiffSnapshot { base_text_snapshot: language::BufferSnapshot, cx: &App, ) -> impl Future + use<> { + let diff_options = build_diff_options( + base_text_snapshot.file(), + base_text_snapshot.language().map(|l| l.name()), + base_text_snapshot.language().map(|l| l.default_scope()), + cx, + ); let base_text_exists = base_text.is_some(); let base_text_pair = base_text.map(|text| { debug_assert_eq!(&*text, &base_text_snapshot.text()); @@ -241,7 +291,7 @@ impl BufferDiffSnapshot { inner: BufferDiffInner { base_text: base_text_snapshot, pending_hunks: SumTree::new(&buffer), - hunks: compute_hunks(base_text_pair, buffer), + hunks: compute_hunks(base_text_pair, buffer, diff_options), base_text_exists, }, secondary_diff: None, @@ -300,6 +350,54 @@ impl BufferDiffSnapshot { let (new_id, new_empty) = (right.remote_id(), right.is_empty()); new_id == old_id || (new_empty && old_empty) } + + pub fn row_to_base_text_row(&self, row: BufferRow, buffer: &text::BufferSnapshot) -> u32 { + // TODO(split-diff) expose a parameter to reuse a cursor to avoid repeatedly seeking from the start + + // Find the last hunk that starts before this position. + let mut cursor = self.inner.hunks.cursor::(buffer); + let position = buffer.anchor_before(Point::new(row, 0)); + cursor.seek(&position, Bias::Left); + if cursor + .item() + .is_none_or(|hunk| hunk.buffer_range.start.cmp(&position, buffer).is_gt()) + { + cursor.prev(); + } + + let unclipped_point = if let Some(hunk) = cursor.item() + && hunk.buffer_range.start.cmp(&position, buffer).is_le() + { + let mut unclipped_point = cursor + .end() + .diff_base_byte_range + .end + .to_point(self.base_text()); + if position.cmp(&cursor.end().buffer_range.end, buffer).is_ge() { + unclipped_point += + Point::new(row, 0) - cursor.end().buffer_range.end.to_point(buffer); + } + // Move the cursor so that at the next step we can clip with the start of the next hunk. + cursor.next(); + unclipped_point + } else { + // Position is before the added region for the first hunk. + debug_assert!(self.inner.hunks.first().is_none_or(|first_hunk| { + position.cmp(&first_hunk.buffer_range.start, buffer).is_le() + })); + Point::new(row, 0) + }; + + let max_point = if let Some(next_hunk) = cursor.item() { + next_hunk + .diff_base_byte_range + .start + .to_point(self.base_text()) + } else { + self.base_text().max_point() + }; + unclipped_point.min(max_point).row + } } impl BufferDiffInner { @@ -339,7 +437,7 @@ impl BufferDiffInner { }; let hunk = PendingHunk { - buffer_range: Anchor::MIN..Anchor::MAX, + buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), diff_base_byte_range: 0..index_text.map_or(0, |rope| rope.len()), buffer_version: buffer.version().clone(), new_status, @@ -536,11 +634,15 @@ impl BufferDiffInner { [ ( &hunk.buffer_range.start, - (hunk.buffer_range.start, hunk.diff_base_byte_range.start), + ( + hunk.buffer_range.start, + hunk.diff_base_byte_range.start, + hunk, + ), ), ( &hunk.buffer_range.end, - (hunk.buffer_range.end, hunk.diff_base_byte_range.end), + (hunk.buffer_range.end, hunk.diff_base_byte_range.end, hunk), ), ] }); @@ -559,8 +661,11 @@ impl BufferDiffInner { let mut summaries = buffer.summaries_for_anchors_with_payload::(anchor_iter); iter::from_fn(move || { loop { - let (start_point, (start_anchor, start_base)) = summaries.next()?; - let (mut end_point, (mut end_anchor, end_base)) = summaries.next()?; + let (start_point, (start_anchor, start_base, hunk)) = summaries.next()?; + let (mut end_point, (mut end_anchor, end_base, _)) = summaries.next()?; + + let base_word_diffs = hunk.base_word_diffs.clone(); + let buffer_word_diffs = hunk.buffer_word_diffs.clone(); if !start_anchor.is_valid(buffer) { continue; @@ -630,6 +735,8 @@ impl BufferDiffInner { range: start_point..end_point, diff_base_byte_range: start_base..end_base, buffer_range: start_anchor..end_anchor, + base_word_diffs, + buffer_word_diffs, secondary_status, }); } @@ -661,6 +768,8 @@ impl BufferDiffInner { buffer_range: hunk.buffer_range.clone(), // The secondary status is not used by callers of this method. secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, + base_word_diffs: hunk.base_word_diffs.clone(), + buffer_word_diffs: hunk.buffer_word_diffs.clone(), }) }) } @@ -729,9 +838,36 @@ impl BufferDiffInner { } } +fn build_diff_options( + file: Option<&Arc>, + language: Option, + language_scope: Option, + cx: &App, +) -> Option { + #[cfg(any(test, feature = "test-support"))] + { + if !cx.has_global::() { + return Some(DiffOptions { + language_scope, + max_word_diff_line_count: MAX_WORD_DIFF_LINE_COUNT, + ..Default::default() + }); + } + } + + language_settings(language, file, cx) + .word_diff_enabled + .then_some(DiffOptions { + language_scope, + max_word_diff_line_count: MAX_WORD_DIFF_LINE_COUNT, + ..Default::default() + }) +} + fn compute_hunks( diff_base: Option<(Arc, Rope)>, buffer: text::BufferSnapshot, + diff_options: Option, ) -> SumTree { let mut tree = SumTree::new(&buffer); @@ -757,6 +893,8 @@ fn compute_hunks( InternalDiffHunk { buffer_range: buffer.anchor_before(0)..buffer.anchor_before(0), diff_base_byte_range: 0..diff_base.len() - 1, + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), }, &buffer, ); @@ -772,6 +910,7 @@ fn compute_hunks( &diff_base_rope, &buffer, &mut divergence, + diff_options.as_ref(), ); tree.push(hunk, &buffer); } @@ -779,8 +918,10 @@ fn compute_hunks( } else { tree.push( InternalDiffHunk { - buffer_range: Anchor::MIN..Anchor::MAX, + buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), diff_base_byte_range: 0..0, + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), }, &buffer, ); @@ -795,6 +936,7 @@ fn process_patch_hunk( diff_base: &Rope, buffer: &text::BufferSnapshot, buffer_row_divergence: &mut i64, + diff_options: Option<&DiffOptions>, ) -> InternalDiffHunk { let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap(); assert!(line_item_count > 0); @@ -859,9 +1001,49 @@ fn process_patch_hunk( let start = Point::new(buffer_row_range.start, 0); let end = Point::new(buffer_row_range.end, 0); let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end); + + let base_line_count = line_item_count.saturating_sub(buffer_row_range.len()); + + let (base_word_diffs, buffer_word_diffs) = if let Some(diff_options) = diff_options + && !buffer_row_range.is_empty() + && base_line_count == buffer_row_range.len() + && diff_options.max_word_diff_line_count >= base_line_count + { + let base_text: String = diff_base + .chunks_in_range(diff_base_byte_range.clone()) + .collect(); + + let buffer_text: String = buffer.text_for_range(buffer_range.clone()).collect(); + + let (base_word_diffs, buffer_word_diffs_relative) = word_diff_ranges( + &base_text, + &buffer_text, + DiffOptions { + language_scope: diff_options.language_scope.clone(), + ..*diff_options + }, + ); + + let buffer_start_offset = buffer_range.start.to_offset(buffer); + let buffer_word_diffs = buffer_word_diffs_relative + .into_iter() + .map(|range| { + let start = buffer.anchor_after(buffer_start_offset + range.start); + let end = buffer.anchor_after(buffer_start_offset + range.end); + start..end + }) + .collect(); + + (base_word_diffs, buffer_word_diffs) + } else { + (Vec::default(), Vec::default()) + }; + InternalDiffHunk { buffer_range, diff_base_byte_range, + base_word_diffs, + buffer_word_diffs, } } @@ -940,10 +1122,11 @@ impl BufferDiff { pub fn clear_pending_hunks(&mut self, cx: &mut Context) { if self.secondary_diff.is_some() { self.inner.pending_hunks = SumTree::from_summary(DiffHunkSummary { - buffer_range: Anchor::MIN..Anchor::MIN, + buffer_range: Anchor::min_min_range_for_buffer(self.buffer_id), + diff_base_byte_range: 0..0, }); cx.emit(BufferDiffEvent::DiffChanged { - changed_range: Some(Anchor::MIN..Anchor::MAX), + changed_range: Some(Anchor::min_max_range_for_buffer(self.buffer_id)), }); } } @@ -1064,7 +1247,10 @@ impl BufferDiff { { (false, new_state.compare(state, buffer)) } - _ => (true, Some(text::Anchor::MIN..text::Anchor::MAX)), + _ => ( + true, + Some(text::Anchor::min_max_range_for_buffer(self.buffer_id)), + ), }; if let Some(secondary_changed_range) = secondary_diff_change @@ -1125,7 +1311,11 @@ impl BufferDiff { buffer_snapshot: &'a text::BufferSnapshot, cx: &'a App, ) -> impl 'a + Iterator { - self.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer_snapshot, cx) + self.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer_snapshot.remote_id()), + buffer_snapshot, + cx, + ) } pub fn hunks_intersecting_range<'a>( @@ -1221,7 +1411,9 @@ impl BufferDiff { impl DiffHunk { pub fn is_created_file(&self) -> bool { - self.diff_base_byte_range == (0..0) && self.buffer_range == (Anchor::MIN..Anchor::MAX) + self.diff_base_byte_range == (0..0) + && self.buffer_range.start.is_min() + && self.buffer_range.end.is_max() } pub fn status(&self) -> DiffHunkStatus { @@ -1388,7 +1580,10 @@ mod tests { let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); let mut diff = BufferDiffSnapshot::new_sync(buffer.clone(), diff_base.clone(), cx); assert_hunks( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer, + ), &buffer, &diff_base, &[(1..2, "two\n", "HELLO\n", DiffHunkStatus::modified_none())], @@ -1397,7 +1592,10 @@ mod tests { buffer.edit([(0..0, "point five\n")]); diff = BufferDiffSnapshot::new_sync(buffer.clone(), diff_base.clone(), cx); assert_hunks( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer, + ), &buffer, &diff_base, &[ @@ -1408,7 +1606,10 @@ mod tests { diff = cx.update(|cx| BufferDiffSnapshot::empty(&buffer, cx)); assert_hunks::<&str, _>( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer, + ), &buffer, &diff_base, &[], @@ -1482,7 +1683,10 @@ mod tests { ]; assert_hunks( - uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), + uncommitted_diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer, + ), &buffer, &head_text, &expected_hunks, @@ -1541,8 +1745,11 @@ mod tests { }) .await; assert_eq!( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer) - .count(), + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + &buffer + ) + .count(), 8 ); @@ -1948,7 +2155,7 @@ mod tests { let range = diff_1.inner.compare(&empty_diff.inner, &buffer).unwrap(); assert_eq!(range.to_point(&buffer), Point::new(0, 0)..Point::new(8, 0)); - // Edit does not affect the diff. + // Edit does affects the diff because it recalculates word diffs. buffer.edit_via_marked_text( &" one @@ -1963,7 +2170,14 @@ mod tests { .unindent(), ); let diff_2 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx); - assert_eq!(None, diff_2.inner.compare(&diff_1.inner, &buffer)); + assert_eq!( + Point::new(4, 0)..Point::new(5, 0), + diff_2 + .inner + .compare(&diff_1.inner, &buffer) + .unwrap() + .to_point(&buffer) + ); // Edit turns a deletion hunk into a modification. buffer.edit_via_marked_text( @@ -2154,8 +2368,12 @@ mod tests { let mut diff = uncommitted_diff(&working_copy, &index_text, head_text.clone(), cx); let mut hunks = diff.update(cx, |diff, cx| { - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &working_copy, cx) - .collect::>() + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(diff.buffer_id), + &working_copy, + cx, + ) + .collect::>() }); if hunks.is_empty() { return; @@ -2184,8 +2402,12 @@ mod tests { diff = uncommitted_diff(&working_copy, &index_text, head_text.clone(), cx); let found_hunks = diff.update(cx, |diff, cx| { - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &working_copy, cx) - .collect::>() + diff.hunks_intersecting_range( + Anchor::min_max_range_for_buffer(diff.buffer_id), + &working_copy, + cx, + ) + .collect::>() }); assert_eq!(hunks.len(), found_hunks.len()); @@ -2203,4 +2425,62 @@ mod tests { hunks = found_hunks; } } + + #[gpui::test] + async fn test_row_to_base_text_row(cx: &mut TestAppContext) { + let base_text = " + zero + one + two + three + four + five + six + seven + eight + " + .unindent(); + let buffer_text = " + zero + ONE + two + NINE + five + seven + " + .unindent(); + + // zero + // - one + // + ONE + // two + // - three + // - four + // + NINE + // five + // - six + // seven + // + eight + + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); + let buffer_snapshot = buffer.snapshot(); + let diff = BufferDiffSnapshot::new_sync(buffer_snapshot.clone(), base_text, cx); + let expected_results = [ + // don't format me + (0, 0), + (1, 2), + (2, 2), + (3, 5), + (4, 5), + (5, 7), + (6, 9), + ]; + for (buffer_row, expected) in expected_results { + assert_eq!( + diff.row_to_base_text_row(buffer_row, &buffer_snapshot), + expected, + "{buffer_row}" + ); + } + } } diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index 2a540619d4576ec7fcf711b288ecc12bf89fd20c..ccc8c067c25a91aa44c01911be89c21f0ea9367c 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -305,6 +305,7 @@ impl Room { pub(crate) fn leave(&mut self, cx: &mut Context) -> Task> { cx.notify(); + self.emit_video_track_unsubscribed_events(cx); self.leave_internal(cx) } @@ -352,6 +353,14 @@ impl Room { self.maintain_connection.take(); } + fn emit_video_track_unsubscribed_events(&self, cx: &mut Context) { + for participant in self.remote_participants.values() { + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } + } + } + async fn maintain_connection( this: WeakEntity, client: Arc, @@ -524,6 +533,16 @@ impl Room { self.id } + pub fn room_id(&self) -> impl Future> + 'static { + let room = self.live_kit.as_ref().map(|lk| lk.room.clone()); + async move { + let room = room?; + let sid = room.sid().await; + let name = room.name(); + Some(format!("{} (sid: {sid})", name)) + } + } + pub fn status(&self) -> RoomStatus { self.status } @@ -872,6 +891,9 @@ impl Room { project_id: project.id, }); } + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } false } }); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 7dd8a3253c9a0c8440d9342e5c0b3fd19e7f9828..e1a7a1481b56633364cb011f46cd55e616244f2c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -12,7 +12,9 @@ use clap::Parser; use cli::{CliRequest, CliResponse, IpcHandshake, ipc::IpcOneShotServer}; use parking_lot::Mutex; use std::{ - env, fs, io, + env, + ffi::OsStr, + fs, io, path::{Path, PathBuf}, process::ExitStatus, sync::Arc, @@ -30,7 +32,7 @@ struct Detect; trait InstalledApp { fn zed_version_string(&self) -> String; - fn launch(&self, ipc_url: String) -> anyhow::Result<()>; + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()>; fn run_foreground( &self, ipc_url: String, @@ -59,6 +61,8 @@ Examples: )] struct Args { /// Wait for all of the given paths to be opened/closed before exiting. + /// + /// When opening a directory, waits until the created window is closed. #[arg(short, long)] wait: bool, /// Add files to the currently open workspace @@ -300,7 +304,6 @@ mod tests { fn parse_path_in_wsl(source: &str, wsl: &str) -> Result { let mut source = PathWithPosition::parse_str(source); - let mut command = util::command::new_std_command("wsl.exe"); let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') { if user.is_empty() { @@ -311,20 +314,34 @@ fn parse_path_in_wsl(source: &str, wsl: &str) -> Result { (None, wsl) }; + let mut args = vec!["--distribution", distro_name]; if let Some(user) = user { - command.arg("--user").arg(user); + args.push("--user"); + args.push(user); } - let output = command - .arg("--distribution") - .arg(distro_name) + let command = [ + OsStr::new("realpath"), + OsStr::new("-s"), + source.path.as_ref(), + ]; + + let output = util::command::new_std_command("wsl.exe") + .args(&args) .arg("--exec") - .arg("realpath") - .arg("-s") - .arg(&source.path) + .args(&command) .output()?; + let result = if output.status.success() { + String::from_utf8_lossy(&output.stdout).to_string() + } else { + let fallback = util::command::new_std_command("wsl.exe") + .args(&args) + .arg("--") + .args(&command) + .output()?; + String::from_utf8_lossy(&fallback.stdout).to_string() + }; - let result = String::from_utf8_lossy(&output.stdout); source.path = Path::new(result.trim()).to_owned(); Ok(source.to_string(|path| path.to_string_lossy().into_owned())) @@ -573,7 +590,7 @@ fn main() -> Result<()> { if args.foreground { app.run_foreground(url, user_data_dir.as_deref())?; } else { - app.launch(url)?; + app.launch(url, user_data_dir.as_deref())?; sender.join().unwrap()?; if let Some(handle) = stdin_pipe_handle { handle.join().unwrap()?; @@ -694,14 +711,18 @@ mod linux { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { - let sock_path = paths::data_dir().join(format!( + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { + let data_dir = user_data_dir + .map(PathBuf::from) + .unwrap_or_else(|| paths::data_dir().clone()); + + let sock_path = data_dir.join(format!( "zed-{}.sock", *release_channel::RELEASE_CHANNEL_NAME )); let sock = UnixDatagram::unbound()?; if sock.connect(&sock_path).is_err() { - self.boot_background(ipc_url)?; + self.boot_background(ipc_url, user_data_dir)?; } else { sock.send(ipc_url.as_bytes())?; } @@ -727,7 +748,11 @@ mod linux { } impl App { - fn boot_background(&self, ipc_url: String) -> anyhow::Result<()> { + fn boot_background( + &self, + ipc_url: String, + user_data_dir: Option<&str>, + ) -> anyhow::Result<()> { let path = &self.0; match fork::fork() { @@ -741,8 +766,13 @@ mod linux { if fork::close_fd().is_err() { eprintln!("failed to close_fd: {}", std::io::Error::last_os_error()); } - let error = - exec::execvp(path.clone(), &[path.as_os_str(), &OsString::from(ipc_url)]); + let mut args: Vec = + vec![path.as_os_str().to_owned(), OsString::from(ipc_url)]; + if let Some(dir) = user_data_dir { + args.push(OsString::from("--user-data-dir")); + args.push(OsString::from(dir)); + } + let error = exec::execvp(path.clone(), &args); // if exec succeeded, we never get here. eprintln!("failed to exec {:?}: {}", path, error); process::exit(1) @@ -928,11 +958,14 @@ mod windows { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { if check_single_instance() { - std::process::Command::new(self.0.clone()) - .arg(ipc_url) - .spawn()?; + let mut cmd = std::process::Command::new(self.0.clone()); + cmd.arg(ipc_url); + if let Some(dir) = user_data_dir { + cmd.arg("--user-data-dir").arg(dir); + } + cmd.spawn()?; } else { unsafe { let pipe = CreateFileW( @@ -1081,7 +1114,7 @@ mod mac_os { format!("Zed {} – {}", self.version(), self.path().display(),) } - fn launch(&self, url: String) -> anyhow::Result<()> { + fn launch(&self, url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { match self { Self::App { app_bundle, .. } => { let app_path = app_bundle; @@ -1131,8 +1164,11 @@ mod mac_os { format!("Cloning descriptor for file {subprocess_stdout_file:?}") })?; let mut command = std::process::Command::new(executable); - let command = command - .env(FORCE_CLI_MODE_ENV_VAR_NAME, "") + command.env(FORCE_CLI_MODE_ENV_VAR_NAME, ""); + if let Some(dir) = user_data_dir { + command.arg("--user-data-dir").arg(dir); + } + command .stderr(subprocess_stdout_file) .stdout(subprocess_stdin_file) .arg(url); diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7149ad4f55feaae5b596a39a3dd460d71cc5daa5..50cf12b977a62d56bf9d4a036165917a5dfff2fc 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -53,7 +53,7 @@ text.workspace = true thiserror.workspace = true time.workspace = true tiny_http.workspace = true -tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io"] } +tokio-socks.workspace = true tokio.workspace = true url.workspace = true util.workspace = true diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 96b15dc9fb13deea3cdc706f1927c4d6f016b57a..801c8c3de8d3f02e3d73809df2c651c6973f231a 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -150,9 +150,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach_and_log_err(cx); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client.clone(); move |_: &SignOut, cx| { if let Some(client) = client.upgrade() { @@ -162,9 +161,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach(); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client; move |_: &Reconnect, cx| { if let Some(client) = client.upgrade() { @@ -1723,28 +1721,68 @@ impl ProtoClient for Client { fn is_via_collab(&self) -> bool { true } + + fn has_wsl_interop(&self) -> bool { + false + } } /// prefix for the zed:// url scheme pub const ZED_URL_SCHEME: &str = "zed"; +/// A parsed Zed link that can be handled internally by the application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ZedLink { + /// Join a channel: `zed.dev/channel/channel-name-123` or `zed://channel/channel-name-123` + Channel { channel_id: u64 }, + /// Open channel notes: `zed.dev/channel/channel-name-123/notes` or with heading `notes#heading` + ChannelNotes { + channel_id: u64, + heading: Option, + }, +} + /// Parses the given link into a Zed link. /// -/// Returns a [`Some`] containing the unprefixed link if the link is a Zed link. -/// Returns [`None`] otherwise. -pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> { +/// Returns a [`Some`] containing the parsed link if the link is a recognized Zed link +/// that should be handled internally by the application. +/// Returns [`None`] for links that should be opened in the browser. +pub fn parse_zed_link(link: &str, cx: &App) -> Option { let server_url = &ClientSettings::get_global(cx).server_url; - if let Some(stripped) = link + let path = link .strip_prefix(server_url) .and_then(|result| result.strip_prefix('/')) - { - return Some(stripped); + .or_else(|| { + link.strip_prefix(ZED_URL_SCHEME) + .and_then(|result| result.strip_prefix("://")) + })?; + + let mut parts = path.split('/'); + + if parts.next() != Some("channel") { + return None; } - if let Some(stripped) = link - .strip_prefix(ZED_URL_SCHEME) - .and_then(|result| result.strip_prefix("://")) - { - return Some(stripped); + + let slug = parts.next()?; + let id_str = slug.split('-').next_back()?; + let channel_id = id_str.parse::().ok()?; + + let Some(next) = parts.next() else { + return Some(ZedLink::Channel { channel_id }); + }; + + if let Some(heading) = next.strip_prefix("notes#") { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: Some(heading.to_string()), + }); + } + + if next == "notes" { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: None, + }); } None diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 957d6c68f773db025b4ee604666f5b3d8101148b..2fe47251695446b54d6766c9a52bbd2da366d34e 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -59,3 +59,11 @@ pub fn agent_server_docs(cx: &App) -> String { server_url = server_url(cx) ) } + +/// Returns the URL to Zed's edit prediction documentation. +pub fn edit_prediction_docs(cx: &App) -> String { + format!( + "{server_url}/docs/ai/edit-prediction", + server_url = server_url(cx) + ) +} diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 241e760887cdf0c4455f6769c79a813de0626028..2c5b2649000bb071b9d206d9d2c204f1eea9bda1 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -169,6 +169,17 @@ pub struct PredictEditsBody { /// Info about the git repository state, only present when can_collect_data is true. #[serde(skip_serializing_if = "Option::is_none", default)] pub git_info: Option, + /// The trigger for this request. + #[serde(default)] + pub trigger: PredictEditsRequestTrigger, +} + +#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)] +pub enum PredictEditsRequestTrigger { + Diagnostics, + Cli, + #[default] + Other, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -195,17 +206,41 @@ pub struct AcceptEditPredictionBody { pub request_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RejectEditPredictionsBody { pub rejections: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] +pub struct RejectEditPredictionsBodyRef<'a> { + pub rejections: &'a [EditPredictionRejection], +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EditPredictionRejection { pub request_id: String, + #[serde(default)] + pub reason: EditPredictionRejectReason, pub was_shown: bool, } +#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub enum EditPredictionRejectReason { + /// New requests were triggered before this one completed + Canceled, + /// No edits returned + Empty, + /// Edits returned, but none remained after interpolation + InterpolatedEmpty, + /// The new prediction was preferred over the current one + Replaced, + /// The current prediction was preferred over the new one + CurrentPreferred, + /// The current prediction was discarded + #[default] + Discarded, +} + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CompletionMode { @@ -336,6 +371,8 @@ pub struct LanguageModel { pub supports_images: bool, pub supports_thinking: bool, pub supports_max_mode: bool, + #[serde(default)] + pub supports_streaming_tools: bool, // only used by OpenAI and xAI #[serde(default)] pub supports_parallel_tool_calls: bool, diff --git a/crates/cloud_llm_client/src/predict_edits_v3.rs b/crates/cloud_llm_client/src/predict_edits_v3.rs index 32a5a34d9d3b63332008a9f7df84a1990f87f17c..9e590dc4cf48a82ecdda8b007c38ab15f3b602be 100644 --- a/crates/cloud_llm_client/src/predict_edits_v3.rs +++ b/crates/cloud_llm_client/src/predict_edits_v3.rs @@ -3,13 +3,13 @@ use serde::{Deserialize, Serialize}; use std::{ fmt::{Display, Write as _}, ops::{Add, Range, Sub}, - path::{Path, PathBuf}, + path::Path, sync::Arc, }; use strum::EnumIter; use uuid::Uuid; -use crate::PredictEditsGitInfo; +use crate::{PredictEditsGitInfo, PredictEditsRequestTrigger}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlanContextRetrievalRequest { @@ -17,7 +17,7 @@ pub struct PlanContextRetrievalRequest { pub excerpt_path: Arc, pub excerpt_line_range: Range, pub cursor_file_max_row: Line, - pub events: Vec, + pub events: Vec>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -31,18 +31,10 @@ pub struct PredictEditsRequest { /// Within `signatures` pub excerpt_parent: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub included_files: Vec, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub signatures: Vec, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub referenced_declarations: Vec, - pub events: Vec, + pub related_files: Vec, + pub events: Vec>, #[serde(default)] pub can_collect_data: bool, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub diagnostic_groups: Vec, - #[serde(skip_serializing_if = "is_default", default)] - pub diagnostic_groups_truncated: bool, /// Info about the git repository state, only present when can_collect_data is true. #[serde(skip_serializing_if = "Option::is_none", default)] pub git_info: Option, @@ -53,10 +45,12 @@ pub struct PredictEditsRequest { pub prompt_max_bytes: Option, #[serde(default)] pub prompt_format: PromptFormat, + #[serde(default)] + pub trigger: PredictEditsRequestTrigger, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IncludedFile { +pub struct RelatedFile { pub path: Arc, pub max_row: Line, pub excerpts: Vec, @@ -70,11 +64,9 @@ pub struct Excerpt { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumIter)] pub enum PromptFormat { - MarkedExcerpt, - LabeledSections, - NumLinesUniDiff, + /// XML old_tex/new_text OldTextNewText, - /// Prompt format intended for use via zeta_cli + /// Prompt format intended for use via edit_prediction_cli OnlySnippets, /// One-sentence instructions used in fine-tuned models Minimal, @@ -85,7 +77,7 @@ pub enum PromptFormat { } impl PromptFormat { - pub const DEFAULT: PromptFormat = PromptFormat::NumLinesUniDiff; + pub const DEFAULT: PromptFormat = PromptFormat::Minimal; } impl Default for PromptFormat { @@ -103,10 +95,7 @@ impl PromptFormat { impl std::fmt::Display for PromptFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PromptFormat::MarkedExcerpt => write!(f, "Marked Excerpt"), - PromptFormat::LabeledSections => write!(f, "Labeled Sections"), PromptFormat::OnlySnippets => write!(f, "Only Snippets"), - PromptFormat::NumLinesUniDiff => write!(f, "Numbered Lines / Unified Diff"), PromptFormat::OldTextNewText => write!(f, "Old Text / New Text"), PromptFormat::Minimal => write!(f, "Minimal"), PromptFormat::MinimalQwen => write!(f, "Minimal + Qwen FIM"), @@ -120,10 +109,11 @@ impl std::fmt::Display for PromptFormat { #[serde(tag = "event")] pub enum Event { BufferChange { - path: Option, - old_path: Option, + path: Arc, + old_path: Arc, diff: String, predicted: bool, + in_open_source_repo: bool, }, } @@ -135,23 +125,21 @@ impl Display for Event { old_path, diff, predicted, + .. } => { - let new_path = path.as_deref().unwrap_or(Path::new("untitled")); - let old_path = old_path.as_deref().unwrap_or(new_path); - if *predicted { write!( f, "// User accepted prediction:\n--- a/{}\n+++ b/{}\n{diff}", DiffPathFmt(old_path), - DiffPathFmt(new_path) + DiffPathFmt(path) ) } else { write!( f, "--- a/{}\n+++ b/{}\n{diff}", DiffPathFmt(old_path), - DiffPathFmt(new_path) + DiffPathFmt(path) ) } } @@ -177,67 +165,6 @@ impl<'a> std::fmt::Display for DiffPathFmt<'a> { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Signature { - pub text: String, - pub text_is_truncated: bool, - #[serde(skip_serializing_if = "Option::is_none", default)] - pub parent_index: Option, - /// Range of `text` within the file, possibly truncated according to `text_is_truncated`. The - /// file is implicitly the file that contains the descendant declaration or excerpt. - pub range: Range, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferencedDeclaration { - pub path: Arc, - pub text: String, - pub text_is_truncated: bool, - /// Range of `text` within file, possibly truncated according to `text_is_truncated` - pub range: Range, - /// Range within `text` - pub signature_range: Range, - /// Index within `signatures`. - #[serde(skip_serializing_if = "Option::is_none", default)] - pub parent_index: Option, - pub score_components: DeclarationScoreComponents, - pub signature_score: f32, - pub declaration_score: f32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeclarationScoreComponents { - pub is_same_file: bool, - pub is_referenced_nearby: bool, - pub is_referenced_in_breadcrumb: bool, - pub reference_count: usize, - pub same_file_declaration_count: usize, - pub declaration_count: usize, - pub reference_line_distance: u32, - pub declaration_line_distance: u32, - pub excerpt_vs_item_jaccard: f32, - pub excerpt_vs_signature_jaccard: f32, - pub adjacent_vs_item_jaccard: f32, - pub adjacent_vs_signature_jaccard: f32, - pub excerpt_vs_item_weighted_overlap: f32, - pub excerpt_vs_signature_weighted_overlap: f32, - pub adjacent_vs_item_weighted_overlap: f32, - pub adjacent_vs_signature_weighted_overlap: f32, - pub path_import_match_count: usize, - pub wildcard_path_import_match_count: usize, - pub import_similarity: f32, - pub max_import_similarity: f32, - pub normalized_import_similarity: f32, - pub wildcard_import_similarity: f32, - pub normalized_wildcard_import_similarity: f32, - pub included_by_others: usize, - pub includes_others: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct DiagnosticGroup(pub Box); - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PredictEditsResponse { pub request_id: Uuid, @@ -261,10 +188,6 @@ pub struct Edit { pub content: String, } -fn is_default(value: &T) -> bool { - *value == T::default() -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)] pub struct Point { pub line: Line, @@ -300,10 +223,11 @@ mod tests { #[test] fn test_event_display() { let ev = Event::BufferChange { - path: None, - old_path: None, + path: Path::new("untitled").into(), + old_path: Path::new("untitled").into(), diff: "@@ -1,2 +1,2 @@\n-a\n-b\n".into(), predicted: false, + in_open_source_repo: true, }; assert_eq!( ev.to_string(), @@ -317,10 +241,11 @@ mod tests { ); let ev = Event::BufferChange { - path: Some(PathBuf::from("foo/bar.txt")), - old_path: Some(PathBuf::from("foo/bar.txt")), + path: Path::new("foo/bar.txt").into(), + old_path: Path::new("foo/bar.txt").into(), diff: "@@ -1,2 +1,2 @@\n-a\n-b\n".into(), predicted: false, + in_open_source_repo: true, }; assert_eq!( ev.to_string(), @@ -334,10 +259,11 @@ mod tests { ); let ev = Event::BufferChange { - path: Some(PathBuf::from("abc.txt")), - old_path: Some(PathBuf::from("123.txt")), + path: Path::new("abc.txt").into(), + old_path: Path::new("123.txt").into(), diff: "@@ -1,2 +1,2 @@\n-a\n-b\n".into(), predicted: false, + in_open_source_repo: true, }; assert_eq!( ev.to_string(), @@ -351,10 +277,11 @@ mod tests { ); let ev = Event::BufferChange { - path: Some(PathBuf::from("abc.txt")), - old_path: Some(PathBuf::from("123.txt")), + path: Path::new("abc.txt").into(), + old_path: Path::new("123.txt").into(), diff: "@@ -1,2 +1,2 @@\n-a\n-b\n".into(), predicted: true, + in_open_source_repo: true, }; assert_eq!( ev.to_string(), diff --git a/crates/cloud_zeta2_prompt/Cargo.toml b/crates/cloud_zeta2_prompt/Cargo.toml deleted file mode 100644 index fa8246950f8d03029388e0276954de946efc2346..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "cloud_zeta2_prompt" -version = "0.1.0" -publish.workspace = true -edition.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/cloud_zeta2_prompt.rs" - -[dependencies] -anyhow.workspace = true -cloud_llm_client.workspace = true -indoc.workspace = true -ordered-float.workspace = true -rustc-hash.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -strum.workspace = true diff --git a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs b/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs deleted file mode 100644 index 2ddabf750be763542bfc10b794afcb034ff08443..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs +++ /dev/null @@ -1,1075 +0,0 @@ -//! Zeta2 prompt planning and generation code shared with cloud. -pub mod retrieval_prompt; - -use anyhow::{Context as _, Result, anyhow}; -use cloud_llm_client::predict_edits_v3::{ - self, DiffPathFmt, Event, Excerpt, IncludedFile, Line, Point, PromptFormat, - ReferencedDeclaration, -}; -use indoc::indoc; -use ordered_float::OrderedFloat; -use rustc_hash::{FxHashMap, FxHashSet}; -use serde::Serialize; -use std::cmp; -use std::fmt::Write; -use std::sync::Arc; -use std::{cmp::Reverse, collections::BinaryHeap, ops::Range, path::Path}; -use strum::{EnumIter, IntoEnumIterator}; - -pub const DEFAULT_MAX_PROMPT_BYTES: usize = 10 * 1024; - -pub const CURSOR_MARKER: &str = "<|user_cursor|>"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_START_MARKER_WITH_NEWLINE: &str = "<|editable_region_start|>\n"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_END_MARKER_WITH_NEWLINE: &str = "<|editable_region_end|>\n"; - -// TODO: use constants for markers? -const MARKED_EXCERPT_INSTRUCTIONS: &str = indoc! {" - You are a code completion assistant and your task is to analyze user edits and then rewrite an excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking into account the cursor location. - - The excerpt to edit will be wrapped in markers <|editable_region_start|> and <|editable_region_end|>. The cursor position is marked with <|user_cursor|>. Please respond with edited code for that region. - - Other code is provided for context, and `…` indicates when code has been skipped. - - ## Edit History - -"}; - -const LABELED_SECTIONS_INSTRUCTIONS: &str = indoc! {r#" - You are a code completion assistant and your task is to analyze user edits, and suggest an edit to one of the provided sections of code. - - Sections of code are grouped by file and then labeled by `<|section_N|>` (e.g `<|section_8|>`). - - The cursor position is marked with `<|user_cursor|>` and it will appear within a special section labeled `<|current_section|>`. Prefer editing the current section until no more changes are needed within it. - - Respond ONLY with the name of the section to edit on a single line, followed by all of the code that should replace that section. For example: - - <|current_section|> - for i in 0..16 { - println!("{i}"); - } - - ## Edit History - -"#}; - -const NUMBERED_LINES_INSTRUCTIONS: &str = indoc! {r#" - # Instructions - - You are an edit prediction agent in a code editor. - Your job is to predict the next edit that the user will make, - based on their last few edits and their current cursor location. - - ## Output Format - - You must briefly explain your understanding of the user's goal, in one - or two sentences, and then specify their next edit in the form of a - unified diff, like this: - - ``` - --- a/src/myapp/cli.py - +++ b/src/myapp/cli.py - @@ ... @@ - import os - import time - import sys - +from constants import LOG_LEVEL_WARNING - @@ ... @@ - config.headless() - config.set_interactive(false) - -config.set_log_level(LOG_L) - +config.set_log_level(LOG_LEVEL_WARNING) - config.set_use_color(True) - ``` - - ## Edit History - -"#}; - -const STUDENT_MODEL_INSTRUCTIONS: &str = indoc! {r#" - You are a code completion assistant that analyzes edit history to identify and systematically complete incomplete refactorings or patterns across the entire codebase. - - ## Edit History - - "#}; - -const UNIFIED_DIFF_REMINDER: &str = indoc! {" - --- - - Analyze the edit history and the files, then provide the unified diff for your predicted edits. - Do not include the cursor marker in your output. - Your diff should include edited file paths in its file headers (lines beginning with `---` and `+++`). - Do not include line numbers in the hunk headers, use `@@ ... @@`. - Removed lines begin with `-`. - Added lines begin with `+`. - Context lines begin with an extra space. - Context and removed lines are used to match the target edit location, so make sure to include enough of them - to uniquely identify it amongst all excerpts of code provided. -"}; - -const MINIMAL_PROMPT_REMINDER: &str = indoc! {" - --- - - Please analyze the edit history and the files, then provide the unified diff for your predicted edits. - Do not include the cursor marker in your output. - If you're editing multiple files, be sure to reflect filename in the hunk's header. - "}; - -const XML_TAGS_INSTRUCTIONS: &str = indoc! {r#" - # Instructions - - You are an edit prediction agent in a code editor. - - Analyze the history of edits made by the user in order to infer what they are currently trying to accomplish. - Then complete the remainder of the current change if it is incomplete, or predict the next edit the user intends to make. - Always continue along the user's current trajectory, rather than changing course. - - ## Output Format - - You should briefly explain your understanding of the user's overall goal in one sentence, then explain what the next change - along the users current trajectory will be in another, and finally specify the next edit using the following XML-like format: - - - - OLD TEXT 1 HERE - - - NEW TEXT 1 HERE - - - - OLD TEXT 1 HERE - - - NEW TEXT 1 HERE - - - - - Specify the file to edit using the `path` attribute. - - Use `` and `` tags to replace content - - `` must exactly match existing file content, including indentation - - `` cannot be empty - - Do not escape quotes, newlines, or other characters within tags - - Always close all tags properly - - Don't include the <|user_cursor|> marker in your output. - - ## Edit History - -"#}; - -const OLD_TEXT_NEW_TEXT_REMINDER: &str = indoc! {r#" - --- - - Remember that the edits in the edit history have already been applied. -"#}; - -pub fn build_prompt( - request: &predict_edits_v3::PredictEditsRequest, -) -> Result<(String, SectionLabels)> { - let mut section_labels = Default::default(); - - let prompt_data = PromptData { - events: request.events.clone(), - cursor_point: request.cursor_point, - cursor_path: request.excerpt_path.clone(), - included_files: request.included_files.clone(), - }; - match request.prompt_format { - PromptFormat::MinimalQwen => { - return Ok((MinimalQwenPrompt.render(&prompt_data), section_labels)); - } - PromptFormat::SeedCoder1120 => { - return Ok((SeedCoder1120Prompt.render(&prompt_data), section_labels)); - } - _ => (), - }; - - let mut insertions = match request.prompt_format { - PromptFormat::MarkedExcerpt => vec![ - ( - Point { - line: request.excerpt_line_range.start, - column: 0, - }, - EDITABLE_REGION_START_MARKER_WITH_NEWLINE, - ), - (request.cursor_point, CURSOR_MARKER), - ( - Point { - line: request.excerpt_line_range.end, - column: 0, - }, - EDITABLE_REGION_END_MARKER_WITH_NEWLINE, - ), - ], - PromptFormat::LabeledSections - | PromptFormat::NumLinesUniDiff - | PromptFormat::Minimal - | PromptFormat::OldTextNewText => { - vec![(request.cursor_point, CURSOR_MARKER)] - } - PromptFormat::OnlySnippets => vec![], - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - }; - - let mut prompt = match request.prompt_format { - PromptFormat::MarkedExcerpt => MARKED_EXCERPT_INSTRUCTIONS.to_string(), - PromptFormat::LabeledSections => LABELED_SECTIONS_INSTRUCTIONS.to_string(), - PromptFormat::NumLinesUniDiff => NUMBERED_LINES_INSTRUCTIONS.to_string(), - PromptFormat::OldTextNewText => XML_TAGS_INSTRUCTIONS.to_string(), - PromptFormat::OnlySnippets => String::new(), - PromptFormat::Minimal => STUDENT_MODEL_INSTRUCTIONS.to_string(), - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - }; - - if request.events.is_empty() { - prompt.push_str("(No edit history)\n\n"); - } else { - let edit_preamble = if request.prompt_format == PromptFormat::Minimal { - "The following are the latest edits made by the user, from earlier to later.\n\n" - } else { - "Here are the latest edits made by the user, from earlier to later.\n\n" - }; - prompt.push_str(edit_preamble); - push_events(&mut prompt, &request.events); - } - - let excerpts_preamble = match request.prompt_format { - PromptFormat::Minimal => indoc! {" - ## Part of the file under the cursor - - (The cursor marker <|user_cursor|> indicates the current user cursor position. - The file is in current state, edits from edit history has been applied. - We only show part of the file around the cursor. - You can only edit exactly this part of the file. - We prepend line numbers (e.g., `123|`); they are not part of the file.) - "}, - PromptFormat::NumLinesUniDiff | PromptFormat::OldTextNewText => indoc! {" - ## Code Excerpts - - Here is some excerpts of code that you should take into account to predict the next edit. - - The cursor position is marked by `<|user_cursor|>` as it stands after the last edit in the history. - - In addition other excerpts are included to better understand what the edit will be, including the declaration - or references of symbols around the cursor, or other similar code snippets that may need to be updated - following patterns that appear in the edit history. - - Consider each of them carefully in relation to the edit history, and that the user may not have navigated - to the next place they want to edit yet. - - Lines starting with `…` indicate omitted line ranges. These may appear inside multi-line code constructs. - "}, - _ => indoc! {" - ## Code Excerpts - - The cursor marker <|user_cursor|> indicates the current user cursor position. - The file is in current state, edits from edit history have been applied. - "}, - }; - - prompt.push_str(excerpts_preamble); - prompt.push('\n'); - - if !request.referenced_declarations.is_empty() || !request.signatures.is_empty() { - let syntax_based_prompt = SyntaxBasedPrompt::populate(request)?; - section_labels = syntax_based_prompt.write(&mut insertions, &mut prompt)?; - } else { - if request.prompt_format == PromptFormat::LabeledSections { - anyhow::bail!("PromptFormat::LabeledSections cannot be used with ContextMode::Llm"); - } - - let include_line_numbers = matches!( - request.prompt_format, - PromptFormat::NumLinesUniDiff | PromptFormat::Minimal - ); - for related_file in &request.included_files { - if request.prompt_format == PromptFormat::Minimal { - write_codeblock_with_filename( - &related_file.path, - &related_file.excerpts, - if related_file.path == request.excerpt_path { - &insertions - } else { - &[] - }, - related_file.max_row, - include_line_numbers, - &mut prompt, - ); - } else { - write_codeblock( - &related_file.path, - &related_file.excerpts, - if related_file.path == request.excerpt_path { - &insertions - } else { - &[] - }, - related_file.max_row, - include_line_numbers, - &mut prompt, - ); - } - } - } - - match request.prompt_format { - PromptFormat::NumLinesUniDiff => { - prompt.push_str(UNIFIED_DIFF_REMINDER); - } - PromptFormat::OldTextNewText => { - prompt.push_str(OLD_TEXT_NEW_TEXT_REMINDER); - } - PromptFormat::Minimal => { - prompt.push_str(MINIMAL_PROMPT_REMINDER); - } - _ => {} - } - - Ok((prompt, section_labels)) -} - -pub fn generation_params(prompt_format: PromptFormat) -> GenerationParams { - match prompt_format { - PromptFormat::SeedCoder1120 => SeedCoder1120Prompt::generation_params(), - _ => GenerationParams::default(), - } -} - -pub fn write_codeblock<'a>( - path: &Path, - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &'a mut String, -) { - writeln!(output, "`````{}", DiffPathFmt(path)).unwrap(); - - write_excerpts( - excerpts, - sorted_insertions, - file_line_count, - include_line_numbers, - output, - ); - write!(output, "`````\n\n").unwrap(); -} - -fn write_codeblock_with_filename<'a>( - path: &Path, - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &'a mut String, -) { - writeln!(output, "`````filename={}", DiffPathFmt(path)).unwrap(); - - write_excerpts( - excerpts, - sorted_insertions, - file_line_count, - include_line_numbers, - output, - ); - write!(output, "`````\n\n").unwrap(); -} - -pub fn write_excerpts<'a>( - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &mut String, -) { - let mut current_row = Line(0); - let mut sorted_insertions = sorted_insertions.iter().peekable(); - - for excerpt in excerpts { - if excerpt.start_line > current_row { - writeln!(output, "…").unwrap(); - } - if excerpt.text.is_empty() { - return; - } - - current_row = excerpt.start_line; - - for mut line in excerpt.text.lines() { - if include_line_numbers { - write!(output, "{}|", current_row.0 + 1).unwrap(); - } - - while let Some((insertion_location, insertion_marker)) = sorted_insertions.peek() { - match current_row.cmp(&insertion_location.line) { - cmp::Ordering::Equal => { - let (prefix, suffix) = line.split_at(insertion_location.column as usize); - output.push_str(prefix); - output.push_str(insertion_marker); - line = suffix; - sorted_insertions.next(); - } - cmp::Ordering::Less => break, - cmp::Ordering::Greater => { - sorted_insertions.next(); - break; - } - } - } - output.push_str(line); - output.push('\n'); - current_row.0 += 1; - } - } - - if current_row < file_line_count { - writeln!(output, "…").unwrap(); - } -} - -pub fn push_events(output: &mut String, events: &[predict_edits_v3::Event]) { - if events.is_empty() { - return; - }; - - writeln!(output, "`````diff").unwrap(); - for event in events { - writeln!(output, "{}", event).unwrap(); - } - writeln!(output, "`````\n").unwrap(); -} - -pub struct SyntaxBasedPrompt<'a> { - request: &'a predict_edits_v3::PredictEditsRequest, - /// Snippets to include in the prompt. These may overlap - they are merged / deduplicated in - /// `to_prompt_string`. - snippets: Vec>, - budget_used: usize, -} - -#[derive(Clone, Debug)] -pub struct PlannedSnippet<'a> { - path: Arc, - range: Range, - text: &'a str, - // TODO: Indicate this in the output - #[allow(dead_code)] - text_is_truncated: bool, -} - -#[derive(EnumIter, Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] -pub enum DeclarationStyle { - Signature, - Declaration, -} - -#[derive(Default, Clone, Debug, Serialize)] -pub struct SectionLabels { - pub excerpt_index: usize, - pub section_ranges: Vec<(Arc, Range)>, -} - -impl<'a> SyntaxBasedPrompt<'a> { - /// Greedy one-pass knapsack algorithm to populate the prompt plan. Does the following: - /// - /// Initializes a priority queue by populating it with each snippet, finding the - /// DeclarationStyle that minimizes `score_density = score / snippet.range(style).len()`. When a - /// "signature" snippet is popped, insert an entry for the "declaration" variant that reflects - /// the cost of upgrade. - /// - /// TODO: Implement an early halting condition. One option might be to have another priority - /// queue where the score is the size, and update it accordingly. Another option might be to - /// have some simpler heuristic like bailing after N failed insertions, or based on how much - /// budget is left. - /// - /// TODO: Has the current known sources of imprecision: - /// - /// * Does not consider snippet overlap when ranking. For example, it might add a field to the - /// plan even though the containing struct is already included. - /// - /// * Does not consider cost of signatures when ranking snippets - this is tricky since - /// signatures may be shared by multiple snippets. - /// - /// * Does not include file paths / other text when considering max_bytes. - pub fn populate(request: &'a predict_edits_v3::PredictEditsRequest) -> Result { - let mut this = Self { - request, - snippets: Vec::new(), - budget_used: request.excerpt.len(), - }; - let mut included_parents = FxHashSet::default(); - let additional_parents = this.additional_parent_signatures( - &request.excerpt_path, - request.excerpt_parent, - &included_parents, - )?; - this.add_parents(&mut included_parents, additional_parents); - - let max_bytes = request.prompt_max_bytes.unwrap_or(DEFAULT_MAX_PROMPT_BYTES); - - if this.budget_used > max_bytes { - return Err(anyhow!( - "Excerpt + signatures size of {} already exceeds budget of {}", - this.budget_used, - max_bytes - )); - } - - #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] - struct QueueEntry { - score_density: OrderedFloat, - declaration_index: usize, - style: DeclarationStyle, - } - - // Initialize priority queue with the best score for each snippet. - let mut queue: BinaryHeap = BinaryHeap::new(); - for (declaration_index, declaration) in request.referenced_declarations.iter().enumerate() { - let (style, score_density) = DeclarationStyle::iter() - .map(|style| { - ( - style, - OrderedFloat(declaration_score_density(&declaration, style)), - ) - }) - .max_by_key(|(_, score_density)| *score_density) - .unwrap(); - queue.push(QueueEntry { - score_density, - declaration_index, - style, - }); - } - - // Knapsack selection loop - while let Some(queue_entry) = queue.pop() { - let Some(declaration) = request - .referenced_declarations - .get(queue_entry.declaration_index) - else { - return Err(anyhow!( - "Invalid declaration index {}", - queue_entry.declaration_index - )); - }; - - let mut additional_bytes = declaration_size(declaration, queue_entry.style); - if this.budget_used + additional_bytes > max_bytes { - continue; - } - - let additional_parents = this.additional_parent_signatures( - &declaration.path, - declaration.parent_index, - &mut included_parents, - )?; - additional_bytes += additional_parents - .iter() - .map(|(_, snippet)| snippet.text.len()) - .sum::(); - if this.budget_used + additional_bytes > max_bytes { - continue; - } - - this.budget_used += additional_bytes; - this.add_parents(&mut included_parents, additional_parents); - let planned_snippet = match queue_entry.style { - DeclarationStyle::Signature => { - let Some(text) = declaration.text.get(declaration.signature_range.clone()) - else { - return Err(anyhow!( - "Invalid declaration signature_range {:?} with text.len() = {}", - declaration.signature_range, - declaration.text.len() - )); - }; - let signature_start_line = declaration.range.start - + Line( - declaration.text[..declaration.signature_range.start] - .lines() - .count() as u32, - ); - let signature_end_line = signature_start_line - + Line( - declaration.text - [declaration.signature_range.start..declaration.signature_range.end] - .lines() - .count() as u32, - ); - let range = signature_start_line..signature_end_line; - - PlannedSnippet { - path: declaration.path.clone(), - range, - text, - text_is_truncated: declaration.text_is_truncated, - } - } - DeclarationStyle::Declaration => PlannedSnippet { - path: declaration.path.clone(), - range: declaration.range.clone(), - text: &declaration.text, - text_is_truncated: declaration.text_is_truncated, - }, - }; - this.snippets.push(planned_snippet); - - // When a Signature is consumed, insert an entry for Definition style. - if queue_entry.style == DeclarationStyle::Signature { - let signature_size = declaration_size(&declaration, DeclarationStyle::Signature); - let declaration_size = - declaration_size(&declaration, DeclarationStyle::Declaration); - let signature_score = declaration_score(&declaration, DeclarationStyle::Signature); - let declaration_score = - declaration_score(&declaration, DeclarationStyle::Declaration); - - let score_diff = declaration_score - signature_score; - let size_diff = declaration_size.saturating_sub(signature_size); - if score_diff > 0.0001 && size_diff > 0 { - queue.push(QueueEntry { - declaration_index: queue_entry.declaration_index, - score_density: OrderedFloat(score_diff / (size_diff as f32)), - style: DeclarationStyle::Declaration, - }); - } - } - } - - anyhow::Ok(this) - } - - fn add_parents( - &mut self, - included_parents: &mut FxHashSet, - snippets: Vec<(usize, PlannedSnippet<'a>)>, - ) { - for (parent_index, snippet) in snippets { - included_parents.insert(parent_index); - self.budget_used += snippet.text.len(); - self.snippets.push(snippet); - } - } - - fn additional_parent_signatures( - &self, - path: &Arc, - parent_index: Option, - included_parents: &FxHashSet, - ) -> Result)>> { - let mut results = Vec::new(); - self.additional_parent_signatures_impl(path, parent_index, included_parents, &mut results)?; - Ok(results) - } - - fn additional_parent_signatures_impl( - &self, - path: &Arc, - parent_index: Option, - included_parents: &FxHashSet, - results: &mut Vec<(usize, PlannedSnippet<'a>)>, - ) -> Result<()> { - let Some(parent_index) = parent_index else { - return Ok(()); - }; - if included_parents.contains(&parent_index) { - return Ok(()); - } - let Some(parent_signature) = self.request.signatures.get(parent_index) else { - return Err(anyhow!("Invalid parent index {}", parent_index)); - }; - results.push(( - parent_index, - PlannedSnippet { - path: path.clone(), - range: parent_signature.range.clone(), - text: &parent_signature.text, - text_is_truncated: parent_signature.text_is_truncated, - }, - )); - self.additional_parent_signatures_impl( - path, - parent_signature.parent_index, - included_parents, - results, - ) - } - - /// Renders the planned context. Each file starts with "```FILE_PATH\n` and ends with triple - /// backticks, with a newline after each file. Outputs a line with "..." between nonconsecutive - /// chunks. - pub fn write( - &'a self, - excerpt_file_insertions: &mut Vec<(Point, &'static str)>, - prompt: &mut String, - ) -> Result { - let mut file_to_snippets: FxHashMap<&'a std::path::Path, Vec<&PlannedSnippet<'a>>> = - FxHashMap::default(); - for snippet in &self.snippets { - file_to_snippets - .entry(&snippet.path) - .or_default() - .push(snippet); - } - - // Reorder so that file with cursor comes last - let mut file_snippets = Vec::new(); - let mut excerpt_file_snippets = Vec::new(); - for (file_path, snippets) in file_to_snippets { - if file_path == self.request.excerpt_path.as_ref() { - excerpt_file_snippets = snippets; - } else { - file_snippets.push((file_path, snippets, false)); - } - } - let excerpt_snippet = PlannedSnippet { - path: self.request.excerpt_path.clone(), - range: self.request.excerpt_line_range.clone(), - text: &self.request.excerpt, - text_is_truncated: false, - }; - excerpt_file_snippets.push(&excerpt_snippet); - file_snippets.push((&self.request.excerpt_path, excerpt_file_snippets, true)); - - let section_labels = - self.push_file_snippets(prompt, excerpt_file_insertions, file_snippets)?; - - Ok(section_labels) - } - - fn push_file_snippets( - &self, - output: &mut String, - excerpt_file_insertions: &mut Vec<(Point, &'static str)>, - file_snippets: Vec<(&'a Path, Vec<&'a PlannedSnippet>, bool)>, - ) -> Result { - let mut section_ranges = Vec::new(); - let mut excerpt_index = None; - - for (file_path, mut snippets, is_excerpt_file) in file_snippets { - snippets.sort_by_key(|s| (s.range.start, Reverse(s.range.end))); - - // TODO: What if the snippets get expanded too large to be editable? - let mut current_snippet: Option<(&PlannedSnippet, Range)> = None; - let mut disjoint_snippets: Vec<(&PlannedSnippet, Range)> = Vec::new(); - for snippet in snippets { - if let Some((_, current_snippet_range)) = current_snippet.as_mut() - && snippet.range.start <= current_snippet_range.end - { - current_snippet_range.end = current_snippet_range.end.max(snippet.range.end); - continue; - } - if let Some(current_snippet) = current_snippet.take() { - disjoint_snippets.push(current_snippet); - } - current_snippet = Some((snippet, snippet.range.clone())); - } - if let Some(current_snippet) = current_snippet.take() { - disjoint_snippets.push(current_snippet); - } - - writeln!(output, "`````path={}", file_path.display()).ok(); - let mut skipped_last_snippet = false; - for (snippet, range) in disjoint_snippets { - let section_index = section_ranges.len(); - - match self.request.prompt_format { - PromptFormat::MarkedExcerpt - | PromptFormat::OnlySnippets - | PromptFormat::OldTextNewText - | PromptFormat::Minimal - | PromptFormat::NumLinesUniDiff => { - if range.start.0 > 0 && !skipped_last_snippet { - output.push_str("…\n"); - } - } - PromptFormat::LabeledSections => { - if is_excerpt_file - && range.start <= self.request.excerpt_line_range.start - && range.end >= self.request.excerpt_line_range.end - { - writeln!(output, "<|current_section|>").ok(); - } else { - writeln!(output, "<|section_{}|>", section_index).ok(); - } - } - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - } - - let push_full_snippet = |output: &mut String| { - if self.request.prompt_format == PromptFormat::NumLinesUniDiff { - for (i, line) in snippet.text.lines().enumerate() { - writeln!(output, "{}|{}", i as u32 + range.start.0 + 1, line)?; - } - } else { - output.push_str(&snippet.text); - } - anyhow::Ok(()) - }; - - if is_excerpt_file { - if self.request.prompt_format == PromptFormat::OnlySnippets { - if range.start >= self.request.excerpt_line_range.start - && range.end <= self.request.excerpt_line_range.end - { - skipped_last_snippet = true; - } else { - skipped_last_snippet = false; - output.push_str(snippet.text); - } - } else if !excerpt_file_insertions.is_empty() { - let lines = snippet.text.lines().collect::>(); - let push_line = |output: &mut String, line_ix: usize| { - if self.request.prompt_format == PromptFormat::NumLinesUniDiff { - write!(output, "{}|", line_ix as u32 + range.start.0 + 1)?; - } - anyhow::Ok(writeln!(output, "{}", lines[line_ix])?) - }; - let mut last_line_ix = 0; - let mut insertion_ix = 0; - while insertion_ix < excerpt_file_insertions.len() { - let (point, insertion) = &excerpt_file_insertions[insertion_ix]; - let found = point.line >= range.start && point.line <= range.end; - if found { - excerpt_index = Some(section_index); - let insertion_line_ix = (point.line.0 - range.start.0) as usize; - for line_ix in last_line_ix..insertion_line_ix { - push_line(output, line_ix)?; - } - if let Some(next_line) = lines.get(insertion_line_ix) { - if self.request.prompt_format == PromptFormat::NumLinesUniDiff { - write!( - output, - "{}|", - insertion_line_ix as u32 + range.start.0 + 1 - )? - } - output.push_str(&next_line[..point.column as usize]); - output.push_str(insertion); - writeln!(output, "{}", &next_line[point.column as usize..])?; - } else { - writeln!(output, "{}", insertion)?; - } - last_line_ix = insertion_line_ix + 1; - excerpt_file_insertions.remove(insertion_ix); - continue; - } - insertion_ix += 1; - } - skipped_last_snippet = false; - for line_ix in last_line_ix..lines.len() { - push_line(output, line_ix)?; - } - } else { - skipped_last_snippet = false; - push_full_snippet(output)?; - } - } else { - skipped_last_snippet = false; - push_full_snippet(output)?; - } - - section_ranges.push((snippet.path.clone(), range)); - } - - output.push_str("`````\n\n"); - } - - Ok(SectionLabels { - // TODO: Clean this up - excerpt_index: match self.request.prompt_format { - PromptFormat::OnlySnippets => 0, - _ => excerpt_index.context("bug: no snippet found for excerpt")?, - }, - section_ranges, - }) - } -} - -fn declaration_score_density(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> f32 { - declaration_score(declaration, style) / declaration_size(declaration, style) as f32 -} - -fn declaration_score(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> f32 { - match style { - DeclarationStyle::Signature => declaration.signature_score, - DeclarationStyle::Declaration => declaration.declaration_score, - } -} - -fn declaration_size(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> usize { - match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.text.len(), - } -} - -struct PromptData { - events: Vec, - cursor_point: Point, - cursor_path: Arc, // TODO: make a common struct with cursor_point - included_files: Vec, -} - -#[derive(Default)] -pub struct GenerationParams { - pub temperature: Option, - pub top_p: Option, - pub stop: Option>, -} - -trait PromptFormatter { - fn render(&self, data: &PromptData) -> String; - - fn generation_params() -> GenerationParams { - return GenerationParams::default(); - } -} - -struct MinimalQwenPrompt; - -impl PromptFormatter for MinimalQwenPrompt { - fn render(&self, data: &PromptData) -> String { - let edit_history = self.fmt_edit_history(data); - let context = self.fmt_context(data); - - format!( - "{instructions}\n\n{edit_history}\n\n{context}", - instructions = MinimalQwenPrompt::INSTRUCTIONS, - edit_history = edit_history, - context = context - ) - } -} - -impl MinimalQwenPrompt { - const INSTRUCTIONS: &str = "You are a code completion assistant that analyzes edit history to identify and systematically complete incomplete refactorings or patterns across the entire codebase.\n"; - - fn fmt_edit_history(&self, data: &PromptData) -> String { - if data.events.is_empty() { - "(No edit history)\n\n".to_string() - } else { - let mut events_str = String::new(); - push_events(&mut events_str, &data.events); - format!( - "The following are the latest edits made by the user, from earlier to later.\n\n{}", - events_str - ) - } - } - - fn fmt_context(&self, data: &PromptData) -> String { - let mut context = String::new(); - let include_line_numbers = true; - - for related_file in &data.included_files { - writeln!(context, "<|file_sep|>{}", DiffPathFmt(&related_file.path)).unwrap(); - - if related_file.path == data.cursor_path { - write!(context, "<|fim_prefix|>").unwrap(); - write_excerpts( - &related_file.excerpts, - &[(data.cursor_point, "<|fim_suffix|>")], - related_file.max_row, - include_line_numbers, - &mut context, - ); - writeln!(context, "<|fim_middle|>").unwrap(); - } else { - write_excerpts( - &related_file.excerpts, - &[], - related_file.max_row, - include_line_numbers, - &mut context, - ); - } - } - context - } -} - -struct SeedCoder1120Prompt; - -impl PromptFormatter for SeedCoder1120Prompt { - fn render(&self, data: &PromptData) -> String { - let edit_history = self.fmt_edit_history(data); - let context = self.fmt_context(data); - - format!( - "# Edit History:\n{edit_history}\n\n{context}", - edit_history = edit_history, - context = context - ) - } - - fn generation_params() -> GenerationParams { - GenerationParams { - temperature: Some(0.2), - top_p: Some(0.9), - stop: Some(vec!["<[end_of_sentence]>".into()]), - } - } -} - -impl SeedCoder1120Prompt { - fn fmt_edit_history(&self, data: &PromptData) -> String { - if data.events.is_empty() { - "(No edit history)\n\n".to_string() - } else { - let mut events_str = String::new(); - push_events(&mut events_str, &data.events); - events_str - } - } - - fn fmt_context(&self, data: &PromptData) -> String { - let mut context = String::new(); - let include_line_numbers = true; - - for related_file in &data.included_files { - writeln!(context, "# Path: {}\n", DiffPathFmt(&related_file.path)).unwrap(); - - if related_file.path == data.cursor_path { - let fim_prompt = self.fmt_fim(&related_file, data.cursor_point); - context.push_str(&fim_prompt); - } else { - write_excerpts( - &related_file.excerpts, - &[], - related_file.max_row, - include_line_numbers, - &mut context, - ); - } - } - context - } - - fn fmt_fim(&self, file: &IncludedFile, cursor_point: Point) -> String { - let mut buf = String::new(); - const FIM_SUFFIX: &str = "<[fim-suffix]>"; - const FIM_PREFIX: &str = "<[fim-prefix]>"; - const FIM_MIDDLE: &str = "<[fim-middle]>"; - write!(buf, "{}", FIM_PREFIX).unwrap(); - write_excerpts( - &file.excerpts, - &[(cursor_point, FIM_SUFFIX)], - file.max_row, - true, - &mut buf, - ); - - // Swap prefix and suffix parts - let index = buf.find(FIM_SUFFIX).unwrap(); - let prefix = &buf[..index]; - let suffix = &buf[index..]; - - format!("{}{}{}", suffix, prefix, FIM_MIDDLE) - } -} diff --git a/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs b/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs deleted file mode 100644 index fd35f63f03ff967491a28d817852f6622e4919ca..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs +++ /dev/null @@ -1,244 +0,0 @@ -use anyhow::Result; -use cloud_llm_client::predict_edits_v3::{self, Excerpt}; -use indoc::indoc; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; - -use crate::{push_events, write_codeblock}; - -pub fn build_prompt(request: predict_edits_v3::PlanContextRetrievalRequest) -> Result { - let mut prompt = SEARCH_INSTRUCTIONS.to_string(); - - if !request.events.is_empty() { - writeln!(&mut prompt, "\n## User Edits\n\n")?; - push_events(&mut prompt, &request.events); - } - - writeln!(&mut prompt, "## Cursor context\n")?; - write_codeblock( - &request.excerpt_path, - &[Excerpt { - start_line: request.excerpt_line_range.start, - text: request.excerpt.into(), - }], - &[], - request.cursor_file_max_row, - true, - &mut prompt, - ); - - writeln!(&mut prompt, "{TOOL_USE_REMINDER}")?; - - Ok(prompt) -} - -/// Search for relevant code -/// -/// For the best results, run multiple queries at once with a single invocation of this tool. -#[derive(Clone, Deserialize, Serialize, JsonSchema)] -pub struct SearchToolInput { - /// An array of queries to run for gathering context relevant to the next prediction - #[schemars(length(max = 3))] - #[serde(deserialize_with = "deserialize_queries")] - pub queries: Box<[SearchToolQuery]>, -} - -fn deserialize_queries<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - use serde::de::Error; - - #[derive(Deserialize)] - #[serde(untagged)] - enum QueryCollection { - Array(Box<[SearchToolQuery]>), - DoubleArray(Box<[Box<[SearchToolQuery]>]>), - Single(SearchToolQuery), - } - - #[derive(Deserialize)] - #[serde(untagged)] - enum MaybeDoubleEncoded { - SingleEncoded(QueryCollection), - DoubleEncoded(String), - } - - let result = MaybeDoubleEncoded::deserialize(deserializer)?; - - let normalized = match result { - MaybeDoubleEncoded::SingleEncoded(value) => value, - MaybeDoubleEncoded::DoubleEncoded(value) => { - serde_json::from_str(&value).map_err(D::Error::custom)? - } - }; - - Ok(match normalized { - QueryCollection::Array(items) => items, - QueryCollection::Single(search_tool_query) => Box::new([search_tool_query]), - QueryCollection::DoubleArray(double_array) => double_array.into_iter().flatten().collect(), - }) -} - -/// Search for relevant code by path, syntax hierarchy, and content. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Hash)] -pub struct SearchToolQuery { - /// 1. A glob pattern to match file paths in the codebase to search in. - pub glob: String, - /// 2. Regular expressions to match syntax nodes **by their first line** and hierarchy. - /// - /// Subsequent regexes match nodes within the full content of the nodes matched by the previous regexes. - /// - /// Example: Searching for a `User` class - /// ["class\s+User"] - /// - /// Example: Searching for a `get_full_name` method under a `User` class - /// ["class\s+User", "def\sget_full_name"] - /// - /// Skip this field to match on content alone. - #[schemars(length(max = 3))] - #[serde(default)] - pub syntax_node: Vec, - /// 3. An optional regular expression to match the final content that should appear in the results. - /// - /// - Content will be matched within all lines of the matched syntax nodes. - /// - If syntax node regexes are provided, this field can be skipped to include as much of the node itself as possible. - /// - If no syntax node regexes are provided, the content will be matched within the entire file. - pub content: Option, -} - -pub const TOOL_NAME: &str = "search"; - -const SEARCH_INSTRUCTIONS: &str = indoc! {r#" - You are part of an edit prediction system in a code editor. - Your role is to search for code that will serve as context for predicting the next edit. - - - Analyze the user's recent edits and current cursor context - - Use the `search` tool to find code that is relevant for predicting the next edit - - Focus on finding: - - Code patterns that might need similar changes based on the recent edits - - Functions, variables, types, and constants referenced in the current cursor context - - Related implementations, usages, or dependencies that may require consistent updates - - How items defined in the cursor excerpt are used or altered - - You will not be able to filter results or perform subsequent queries, so keep searches as targeted as possible - - Use `syntax_node` parameter whenever you're looking for a particular type, class, or function - - Avoid using wildcard globs if you already know the file path of the content you're looking for -"#}; - -const TOOL_USE_REMINDER: &str = indoc! {" - -- - Analyze the user's intent in one to two sentences, then call the `search` tool. -"}; - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn test_deserialize_queries() { - let single_query_json = indoc! {r#"{ - "queries": { - "glob": "**/*.rs", - "syntax_node": ["fn test"], - "content": "assert" - } - }"#}; - - let flat_input: SearchToolInput = serde_json::from_str(single_query_json).unwrap(); - assert_eq!(flat_input.queries.len(), 1); - assert_eq!(flat_input.queries[0].glob, "**/*.rs"); - assert_eq!(flat_input.queries[0].syntax_node, vec!["fn test"]); - assert_eq!(flat_input.queries[0].content, Some("assert".to_string())); - - let flat_json = indoc! {r#"{ - "queries": [ - { - "glob": "**/*.rs", - "syntax_node": ["fn test"], - "content": "assert" - }, - { - "glob": "**/*.ts", - "syntax_node": [], - "content": null - } - ] - }"#}; - - let flat_input: SearchToolInput = serde_json::from_str(flat_json).unwrap(); - assert_eq!(flat_input.queries.len(), 2); - assert_eq!(flat_input.queries[0].glob, "**/*.rs"); - assert_eq!(flat_input.queries[0].syntax_node, vec!["fn test"]); - assert_eq!(flat_input.queries[0].content, Some("assert".to_string())); - assert_eq!(flat_input.queries[1].glob, "**/*.ts"); - assert_eq!(flat_input.queries[1].syntax_node.len(), 0); - assert_eq!(flat_input.queries[1].content, None); - - let nested_json = indoc! {r#"{ - "queries": [ - [ - { - "glob": "**/*.rs", - "syntax_node": ["fn test"], - "content": "assert" - } - ], - [ - { - "glob": "**/*.ts", - "syntax_node": [], - "content": null - } - ] - ] - }"#}; - - let nested_input: SearchToolInput = serde_json::from_str(nested_json).unwrap(); - - assert_eq!(nested_input.queries.len(), 2); - - assert_eq!(nested_input.queries[0].glob, "**/*.rs"); - assert_eq!(nested_input.queries[0].syntax_node, vec!["fn test"]); - assert_eq!(nested_input.queries[0].content, Some("assert".to_string())); - assert_eq!(nested_input.queries[1].glob, "**/*.ts"); - assert_eq!(nested_input.queries[1].syntax_node.len(), 0); - assert_eq!(nested_input.queries[1].content, None); - - let double_encoded_queries = serde_json::to_string(&json!({ - "queries": serde_json::to_string(&json!([ - { - "glob": "**/*.rs", - "syntax_node": ["fn test"], - "content": "assert" - }, - { - "glob": "**/*.ts", - "syntax_node": [], - "content": null - } - ])).unwrap() - })) - .unwrap(); - - let double_encoded_input: SearchToolInput = - serde_json::from_str(&double_encoded_queries).unwrap(); - - assert_eq!(double_encoded_input.queries.len(), 2); - - assert_eq!(double_encoded_input.queries[0].glob, "**/*.rs"); - assert_eq!(double_encoded_input.queries[0].syntax_node, vec!["fn test"]); - assert_eq!( - double_encoded_input.queries[0].content, - Some("assert".to_string()) - ); - assert_eq!(double_encoded_input.queries[1].glob, "**/*.ts"); - assert_eq!(double_encoded_input.queries[1].syntax_node.len(), 0); - assert_eq!(double_encoded_input.queries[1].content, None); - - // ### ERROR Switching from var declarations to lexical declarations [RUN 073] - // invalid search json {"queries": ["express/lib/response.js", "var\\s+[a-zA-Z_][a-zA-Z0-9_]*\\s*=.*;", "function.*\\(.*\\).*\\{.*\\}"]} - } -} diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml index b402274a33530424349081da764a4b6766e419e9..7f3bf3b22dda8f9dbde1923c76855342c6cbac4c 100644 --- a/crates/codestral/Cargo.toml +++ b/crates/codestral/Cargo.toml @@ -10,7 +10,7 @@ path = "src/codestral.rs" [dependencies] anyhow.workspace = true -edit_prediction.workspace = true +edit_prediction_types.workspace = true edit_prediction_context.workspace = true futures.workspace = true gpui.workspace = true diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 6a500acbf6ec5eea63c35a8deb83a8545cee497e..9cf2fab80b78ba06c6a2523013e2f73934f50052 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -1,6 +1,6 @@ use anyhow::{Context as _, Result}; -use edit_prediction::{Direction, EditPrediction, EditPredictionProvider}; use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions}; +use edit_prediction_types::{EditPrediction, EditPredictionDelegate}; use futures::AsyncReadExt; use gpui::{App, Context, Entity, Task}; use http_client::HttpClient; @@ -43,17 +43,17 @@ impl CurrentCompletion { /// Attempts to adjust the edits based on changes made to the buffer since the completion was generated. /// Returns None if the user's edits conflict with the predicted edits. fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option, Arc)>> { - edit_prediction::interpolate_edits(&self.snapshot, new_snapshot, &self.edits) + edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits) } } -pub struct CodestralCompletionProvider { +pub struct CodestralEditPredictionDelegate { http_client: Arc, pending_request: Option>>, current_completion: Option, } -impl CodestralCompletionProvider { +impl CodestralEditPredictionDelegate { pub fn new(http_client: Arc) -> Self { Self { http_client, @@ -165,7 +165,7 @@ impl CodestralCompletionProvider { } } -impl EditPredictionProvider for CodestralCompletionProvider { +impl EditPredictionDelegate for CodestralEditPredictionDelegate { fn name() -> &'static str { "codestral" } @@ -174,7 +174,7 @@ impl EditPredictionProvider for CodestralCompletionProvider { "Codestral" } - fn show_completions_in_menu() -> bool { + fn show_predictions_in_menu() -> bool { true } @@ -239,7 +239,6 @@ impl EditPredictionProvider for CodestralCompletionProvider { cursor_point, &snapshot, &EXCERPT_OPTIONS, - None, ) .context("Line containing cursor doesn't fit in excerpt max bytes")?; @@ -301,16 +300,6 @@ impl EditPredictionProvider for CodestralCompletionProvider { })); } - fn cycle( - &mut self, - _buffer: Entity, - _cursor_position: Anchor, - _direction: Direction, - _cx: &mut Context, - ) { - // Codestral doesn't support multiple completions, so cycling does nothing - } - fn accept(&mut self, _cx: &mut Context) { log::debug!("Codestral: Completion accepted"); self.pending_request = None; diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index b8a4c035499d45adc494c9f8175a772d15aa96df..79fc21fe33423d7eb887744b4ad84094a022862e 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -65,7 +65,7 @@ tokio = { workspace = true, features = ["full"] } toml.workspace = true tower = "0.4" tower-http = { workspace = true, features = ["trace"] } -tracing = "0.1.40" +tracing.workspace = true tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "registry", "tracing-log"] } # workaround for https://github.com/tokio-rs/tracing/issues/2927 util.workspace = true uuid.workspace = true diff --git a/crates/collab/README.md b/crates/collab/README.md index 0ec6d8008ba313357c4ac8e44555ff978d9a1121..902c9841e2d7fb0b52b3143002fd0da29b980802 100644 --- a/crates/collab/README.md +++ b/crates/collab/README.md @@ -63,15 +63,3 @@ Deployment is triggered by pushing to the `collab-staging` (or `collab-productio - `./script/deploy-collab production` You can tell what is currently deployed with `./script/what-is-deployed`. - -# Database Migrations - -To create a new migration: - -```sh -./script/create-migration -``` - -Migrations are run automatically on service start, so run `foreman start` again. The service will crash if the migrations fail. - -When you create a new migration, you also need to update the [SQLite schema](./migrations.sqlite/20221109000000_test_schema.sql) that is used for testing. diff --git a/crates/collab/k8s/migrate.template.yml b/crates/collab/k8s/migrate.template.yml deleted file mode 100644 index c890d7b330c0eca260ca327e5f7db10259f91eaa..0000000000000000000000000000000000000000 --- a/crates/collab/k8s/migrate.template.yml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: ${ZED_MIGRATE_JOB_NAME} -spec: - template: - spec: - restartPolicy: Never - containers: - - name: migrator - imagePullPolicy: Always - image: ${ZED_IMAGE_ID} - args: - - migrate - env: - - name: DATABASE_URL - valueFrom: - secretKeyRef: - name: database - key: url diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index a736ddfd1fe3334b1b847e820bd1816cb625ddca..32a2ed2e1331fc7b16f859accd895a7bce055804 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -121,6 +121,8 @@ CREATE TABLE "project_repositories" ( "merge_message" VARCHAR, "branch_summary" VARCHAR, "head_commit_details" VARCHAR, + "remote_upstream_url" VARCHAR, + "remote_origin_url" VARCHAR, PRIMARY KEY (project_id, id) ); diff --git a/crates/collab/migrations/20210527024318_initial_schema.sql b/crates/collab/migrations/20210527024318_initial_schema.sql deleted file mode 100644 index 4b065318484a5c352dde00da9ba55744c4da9adb..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210527024318_initial_schema.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE IF NOT EXISTS "sessions" ( - "id" VARCHAR NOT NULL PRIMARY KEY, - "expires" TIMESTAMP WITH TIME ZONE NULL, - "session" TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS "users" ( - "id" SERIAL PRIMARY KEY, - "github_login" VARCHAR, - "admin" BOOLEAN -); - -CREATE UNIQUE INDEX "index_users_github_login" ON "users" ("github_login"); - -CREATE TABLE IF NOT EXISTS "signups" ( - "id" SERIAL PRIMARY KEY, - "github_login" VARCHAR, - "email_address" VARCHAR, - "about" TEXT -); diff --git a/crates/collab/migrations/20210607190313_create_access_tokens.sql b/crates/collab/migrations/20210607190313_create_access_tokens.sql deleted file mode 100644 index 60745a98bae9ac8bc3e2016e598480e74e0b6473..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210607190313_create_access_tokens.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS "access_tokens" ( - "id" SERIAL PRIMARY KEY, - "user_id" INTEGER REFERENCES users (id), - "hash" VARCHAR(128) -); - -CREATE INDEX "index_access_tokens_user_id" ON "access_tokens" ("user_id"); diff --git a/crates/collab/migrations/20210805175147_create_chat_tables.sql b/crates/collab/migrations/20210805175147_create_chat_tables.sql deleted file mode 100644 index 5bba4689d9c21e65d989cf05e2e1eedb0151621d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210805175147_create_chat_tables.sql +++ /dev/null @@ -1,46 +0,0 @@ -CREATE TABLE IF NOT EXISTS "orgs" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL, - "slug" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_orgs_slug" ON "orgs" ("slug"); - -CREATE TABLE IF NOT EXISTS "org_memberships" ( - "id" SERIAL PRIMARY KEY, - "org_id" INTEGER REFERENCES orgs (id) NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "admin" BOOLEAN NOT NULL -); - -CREATE INDEX "index_org_memberships_user_id" ON "org_memberships" ("user_id"); -CREATE UNIQUE INDEX "index_org_memberships_org_id_and_user_id" ON "org_memberships" ("org_id", "user_id"); - -CREATE TABLE IF NOT EXISTS "channels" ( - "id" SERIAL PRIMARY KEY, - "owner_id" INTEGER NOT NULL, - "owner_is_user" BOOLEAN NOT NULL, - "name" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_channels_owner_and_name" ON "channels" ("owner_is_user", "owner_id", "name"); - -CREATE TABLE IF NOT EXISTS "channel_memberships" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER REFERENCES channels (id) NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "admin" BOOLEAN NOT NULL -); - -CREATE INDEX "index_channel_memberships_user_id" ON "channel_memberships" ("user_id"); -CREATE UNIQUE INDEX "index_channel_memberships_channel_id_and_user_id" ON "channel_memberships" ("channel_id", "user_id"); - -CREATE TABLE IF NOT EXISTS "channel_messages" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER REFERENCES channels (id) NOT NULL, - "sender_id" INTEGER REFERENCES users (id) NOT NULL, - "body" TEXT NOT NULL, - "sent_at" TIMESTAMP -); - -CREATE INDEX "index_channel_messages_channel_id" ON "channel_messages" ("channel_id"); diff --git a/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql b/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql deleted file mode 100644 index ee4d4aa319f6417e854137332011115570153eae..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "channel_messages" -ADD "nonce" UUID NOT NULL DEFAULT gen_random_uuid(); - -CREATE UNIQUE INDEX "index_channel_messages_nonce" ON "channel_messages" ("nonce"); diff --git a/crates/collab/migrations/20210920192001_add_interests_to_signups.sql b/crates/collab/migrations/20210920192001_add_interests_to_signups.sql deleted file mode 100644 index 2457abfc757a3d3c6171d9639e2c280981689ead..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210920192001_add_interests_to_signups.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "signups" - ADD "wants_releases" BOOLEAN, - ADD "wants_updates" BOOLEAN, - ADD "wants_community" BOOLEAN; \ No newline at end of file diff --git a/crates/collab/migrations/20220421165757_drop_signups.sql b/crates/collab/migrations/20220421165757_drop_signups.sql deleted file mode 100644 index d7cd6e204c95e2ea10ee4b4b8183cbe701abb4c0..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220421165757_drop_signups.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS "signups"; diff --git a/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql b/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql deleted file mode 100644 index 3d6fd3179a236bf8407464f69f1e67469eb31d27..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE INDEX trigram_index_users_on_github_login ON users USING GIN(github_login gin_trgm_ops); diff --git a/crates/collab/migrations/20220506130724_create_contacts.sql b/crates/collab/migrations/20220506130724_create_contacts.sql deleted file mode 100644 index 56beb70fd06ce8a3b7bb00d2f0ada2e465906c69..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220506130724_create_contacts.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS "contacts" ( - "id" SERIAL PRIMARY KEY, - "user_id_a" INTEGER REFERENCES users (id) NOT NULL, - "user_id_b" INTEGER REFERENCES users (id) NOT NULL, - "a_to_b" BOOLEAN NOT NULL, - "should_notify" BOOLEAN NOT NULL, - "accepted" BOOLEAN NOT NULL -); - -CREATE UNIQUE INDEX "index_contacts_user_ids" ON "contacts" ("user_id_a", "user_id_b"); -CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b"); diff --git a/crates/collab/migrations/20220518151305_add_invites_to_users.sql b/crates/collab/migrations/20220518151305_add_invites_to_users.sql deleted file mode 100644 index 2ac89b649e8adab60ac93aeba36476d92484dc93..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220518151305_add_invites_to_users.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE users -ADD email_address VARCHAR(255) DEFAULT NULL, -ADD invite_code VARCHAR(64), -ADD invite_count INTEGER NOT NULL DEFAULT 0, -ADD inviter_id INTEGER REFERENCES users (id), -ADD connected_once BOOLEAN NOT NULL DEFAULT false, -ADD created_at TIMESTAMP NOT NULL DEFAULT NOW(); - -CREATE UNIQUE INDEX "index_invite_code_users" ON "users" ("invite_code"); diff --git a/crates/collab/migrations/20220523232954_allow_user_deletes.sql b/crates/collab/migrations/20220523232954_allow_user_deletes.sql deleted file mode 100644 index ddf3f6f9bd094bfa96f75efe72b64da06e47d0c5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220523232954_allow_user_deletes.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE contacts DROP CONSTRAINT contacts_user_id_a_fkey; -ALTER TABLE contacts DROP CONSTRAINT contacts_user_id_b_fkey; -ALTER TABLE contacts ADD CONSTRAINT contacts_user_id_a_fkey FOREIGN KEY (user_id_a) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE contacts ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE users DROP CONSTRAINT users_inviter_id_fkey; -ALTER TABLE users ADD CONSTRAINT users_inviter_id_fkey FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE SET NULL; diff --git a/crates/collab/migrations/20220620211403_create_projects.sql b/crates/collab/migrations/20220620211403_create_projects.sql deleted file mode 100644 index d813c9f7a1811e50227c312c66df0fd679c35166..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220620211403_create_projects.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE IF NOT EXISTS "projects" ( - "id" SERIAL PRIMARY KEY, - "host_user_id" INTEGER REFERENCES users (id) NOT NULL, - "unregistered" BOOLEAN NOT NULL DEFAULT false -); - -CREATE TABLE IF NOT EXISTS "worktree_extensions" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER REFERENCES projects (id) NOT NULL, - "worktree_id" INTEGER NOT NULL, - "extension" VARCHAR(255), - "count" INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS "project_activity_periods" ( - "id" SERIAL PRIMARY KEY, - "duration_millis" INTEGER NOT NULL, - "ended_at" TIMESTAMP NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "project_id" INTEGER REFERENCES projects (id) NOT NULL -); - -CREATE INDEX "index_project_activity_periods_on_ended_at" ON "project_activity_periods" ("ended_at"); -CREATE UNIQUE INDEX "index_worktree_extensions_on_project_id_and_worktree_id_and_extension" ON "worktree_extensions" ("project_id", "worktree_id", "extension"); \ No newline at end of file diff --git a/crates/collab/migrations/20220913211150_create_signups.sql b/crates/collab/migrations/20220913211150_create_signups.sql deleted file mode 100644 index 19559b747c33b0fc146572201ba3dd1d1c37bf47..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220913211150_create_signups.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE IF NOT EXISTS "signups" ( - "id" SERIAL PRIMARY KEY, - "email_address" VARCHAR NOT NULL, - "email_confirmation_code" VARCHAR(64) NOT NULL, - "email_confirmation_sent" BOOLEAN NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "device_id" VARCHAR, - "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, - "inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL, - - "platform_mac" BOOLEAN NOT NULL, - "platform_linux" BOOLEAN NOT NULL, - "platform_windows" BOOLEAN NOT NULL, - "platform_unknown" BOOLEAN NOT NULL, - - "editor_features" VARCHAR[], - "programming_languages" VARCHAR[] -); - -CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address"); -CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent"); - -ALTER TABLE "users" - ADD "github_user_id" INTEGER; - -CREATE INDEX "index_users_on_email_address" ON "users" ("email_address"); -CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"); diff --git a/crates/collab/migrations/20220929182110_add_metrics_id.sql b/crates/collab/migrations/20220929182110_add_metrics_id.sql deleted file mode 100644 index 665d6323bf13b5553859ae6763392770dc33bebb..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220929182110_add_metrics_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "users" - ADD "metrics_id" uuid NOT NULL DEFAULT gen_random_uuid(); diff --git a/crates/collab/migrations/20221111092550_reconnection_support.sql b/crates/collab/migrations/20221111092550_reconnection_support.sql deleted file mode 100644 index 3289f6bbddb63e08acdc5e89a900193359423b2c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221111092550_reconnection_support.sql +++ /dev/null @@ -1,90 +0,0 @@ -CREATE TABLE IF NOT EXISTS "rooms" ( - "id" SERIAL PRIMARY KEY, - "live_kit_room" VARCHAR NOT NULL -); - -ALTER TABLE "projects" - ADD "room_id" INTEGER REFERENCES rooms (id), - ADD "host_connection_id" INTEGER, - ADD "host_connection_epoch" UUID; -CREATE INDEX "index_projects_on_host_connection_epoch" ON "projects" ("host_connection_epoch"); - -CREATE TABLE "worktrees" ( - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "id" INT8 NOT NULL, - "root_name" VARCHAR NOT NULL, - "abs_path" VARCHAR NOT NULL, - "visible" BOOL NOT NULL, - "scan_id" INT8 NOT NULL, - "is_complete" BOOL NOT NULL, - PRIMARY KEY(project_id, id) -); -CREATE INDEX "index_worktrees_on_project_id" ON "worktrees" ("project_id"); - -CREATE TABLE "worktree_entries" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "id" INT8 NOT NULL, - "is_dir" BOOL NOT NULL, - "path" VARCHAR NOT NULL, - "inode" INT8 NOT NULL, - "mtime_seconds" INT8 NOT NULL, - "mtime_nanos" INTEGER NOT NULL, - "is_symlink" BOOL NOT NULL, - "is_ignored" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, id), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_entries_on_project_id" ON "worktree_entries" ("project_id"); -CREATE INDEX "index_worktree_entries_on_project_id_and_worktree_id" ON "worktree_entries" ("project_id", "worktree_id"); - -CREATE TABLE "worktree_diagnostic_summaries" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "path" VARCHAR NOT NULL, - "language_server_id" INT8 NOT NULL, - "error_count" INTEGER NOT NULL, - "warning_count" INTEGER NOT NULL, - PRIMARY KEY(project_id, worktree_id, path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_diagnostic_summaries_on_project_id" ON "worktree_diagnostic_summaries" ("project_id"); -CREATE INDEX "index_worktree_diagnostic_summaries_on_project_id_and_worktree_id" ON "worktree_diagnostic_summaries" ("project_id", "worktree_id"); - -CREATE TABLE "language_servers" ( - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "id" INT8 NOT NULL, - "name" VARCHAR NOT NULL, - PRIMARY KEY(project_id, id) -); -CREATE INDEX "index_language_servers_on_project_id" ON "language_servers" ("project_id"); - -CREATE TABLE "project_collaborators" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_epoch" UUID NOT NULL, - "user_id" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - "is_host" BOOLEAN NOT NULL -); -CREATE INDEX "index_project_collaborators_on_project_id" ON "project_collaborators" ("project_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_and_replica_id" ON "project_collaborators" ("project_id", "replica_id"); -CREATE INDEX "index_project_collaborators_on_connection_epoch" ON "project_collaborators" ("connection_epoch"); - -CREATE TABLE "room_participants" ( - "id" SERIAL PRIMARY KEY, - "room_id" INTEGER NOT NULL REFERENCES rooms (id), - "user_id" INTEGER NOT NULL REFERENCES users (id), - "answering_connection_id" INTEGER, - "answering_connection_epoch" UUID, - "location_kind" INTEGER, - "location_project_id" INTEGER, - "initial_project_id" INTEGER, - "calling_user_id" INTEGER NOT NULL REFERENCES users (id), - "calling_connection_id" INTEGER NOT NULL, - "calling_connection_epoch" UUID NOT NULL -); -CREATE UNIQUE INDEX "index_room_participants_on_user_id" ON "room_participants" ("user_id"); -CREATE INDEX "index_room_participants_on_answering_connection_epoch" ON "room_participants" ("answering_connection_epoch"); -CREATE INDEX "index_room_participants_on_calling_connection_epoch" ON "room_participants" ("calling_connection_epoch"); diff --git a/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql b/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql deleted file mode 100644 index b154396df1259aa73b5e1a17c9db27d04510e062..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "signups" - ADD "added_to_mailing_list" BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql b/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql deleted file mode 100644 index ed0cf972bc97f517fb878806b0929e8122b2b8a2..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE "room_participants" - ADD "answering_connection_lost" BOOLEAN NOT NULL DEFAULT FALSE; - -CREATE INDEX "index_project_collaborators_on_connection_id" ON "project_collaborators" ("connection_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_epoch" ON "project_collaborators" ("project_id", "connection_id", "connection_epoch"); -CREATE INDEX "index_room_participants_on_answering_connection_id" ON "room_participants" ("answering_connection_id"); -CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_epoch" ON "room_participants" ("answering_connection_id", "answering_connection_epoch"); diff --git a/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql b/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql deleted file mode 100644 index f40ca81906f41e8c30530ce349f892bb69111657..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id"); diff --git a/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql b/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql deleted file mode 100644 index 5e02f76ce25d59d799d5e5d9719e4e038d1bac02..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE servers ( - id SERIAL PRIMARY KEY, - environment VARCHAR NOT NULL -); - -DROP TABLE worktree_extensions; -DROP TABLE project_activity_periods; -DELETE from projects; -ALTER TABLE projects - DROP COLUMN host_connection_epoch, - ADD COLUMN host_connection_server_id INTEGER REFERENCES servers (id) ON DELETE CASCADE; -CREATE INDEX "index_projects_on_host_connection_server_id" ON "projects" ("host_connection_server_id"); -CREATE INDEX "index_projects_on_host_connection_id_and_host_connection_server_id" ON "projects" ("host_connection_id", "host_connection_server_id"); - -DELETE FROM project_collaborators; -ALTER TABLE project_collaborators - DROP COLUMN connection_epoch, - ADD COLUMN connection_server_id INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE; -CREATE INDEX "index_project_collaborators_on_connection_server_id" ON "project_collaborators" ("connection_server_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_server_id" ON "project_collaborators" ("project_id", "connection_id", "connection_server_id"); - -DELETE FROM room_participants; -ALTER TABLE room_participants - DROP COLUMN answering_connection_epoch, - DROP COLUMN calling_connection_epoch, - ADD COLUMN answering_connection_server_id INTEGER REFERENCES servers (id) ON DELETE CASCADE, - ADD COLUMN calling_connection_server_id INTEGER REFERENCES servers (id) ON DELETE SET NULL; -CREATE INDEX "index_room_participants_on_answering_connection_server_id" ON "room_participants" ("answering_connection_server_id"); -CREATE INDEX "index_room_participants_on_calling_connection_server_id" ON "room_participants" ("calling_connection_server_id"); -CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_server_id" ON "room_participants" ("answering_connection_id", "answering_connection_server_id"); diff --git a/crates/collab/migrations/20221219181850_project_reconnection_support.sql b/crates/collab/migrations/20221219181850_project_reconnection_support.sql deleted file mode 100644 index 6efef5571c5855beb0c4b59d5f52ff92b323bb20..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221219181850_project_reconnection_support.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "worktree_entries" - ADD COLUMN "scan_id" INT8, - ADD COLUMN "is_deleted" BOOL; diff --git a/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql b/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql deleted file mode 100644 index 1894d888b92a89508981abe5de7f5fc3e710184f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE worktrees - ALTER COLUMN is_complete SET DEFAULT FALSE, - ADD COLUMN completed_scan_id INT8; diff --git a/crates/collab/migrations/20230202155735_followers.sql b/crates/collab/migrations/20230202155735_followers.sql deleted file mode 100644 index c82d6ba3bdaa4f2b2a60771bca7401c47678f247..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230202155735_followers.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE IF NOT EXISTS "followers" ( - "id" SERIAL PRIMARY KEY, - "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "leader_connection_id" INTEGER NOT NULL, - "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "follower_connection_id" INTEGER NOT NULL -); - -CREATE UNIQUE INDEX - "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" -ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id"); - -CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); diff --git a/crates/collab/migrations/20230508211523_add-repository-entries.sql b/crates/collab/migrations/20230508211523_add-repository-entries.sql deleted file mode 100644 index 1e593479394c8434f56f3519b41ce2fa2a9fc2a3..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230508211523_add-repository-entries.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE "worktree_repositories" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "work_directory_id" INT8 NOT NULL, - "scan_id" INT8 NOT NULL, - "branch" VARCHAR, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, work_directory_id), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE, - FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_repositories_on_project_id" ON "worktree_repositories" ("project_id"); -CREATE INDEX "index_worktree_repositories_on_project_id_and_worktree_id" ON "worktree_repositories" ("project_id", "worktree_id"); diff --git a/crates/collab/migrations/20230511004019_add_repository_statuses.sql b/crates/collab/migrations/20230511004019_add_repository_statuses.sql deleted file mode 100644 index 862561c6866d361ca628924a15b925d97d0c39cb..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230511004019_add_repository_statuses.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE "worktree_repository_statuses" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "work_directory_id" INT8 NOT NULL, - "repo_path" VARCHAR NOT NULL, - "status" INT8 NOT NULL, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, work_directory_id, repo_path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE, - FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_wt_repos_statuses_on_project_id" ON "worktree_repository_statuses" ("project_id"); -CREATE INDEX "index_wt_repos_statuses_on_project_id_and_wt_id" ON "worktree_repository_statuses" ("project_id", "worktree_id"); -CREATE INDEX "index_wt_repos_statuses_on_project_id_and_wt_id_and_wd_id" ON "worktree_repository_statuses" ("project_id", "worktree_id", "work_directory_id"); diff --git a/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql b/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql deleted file mode 100644 index 973a40af0f21908e5dbe0d5a30373629f24b7f1e..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE "worktree_settings_files" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "path" VARCHAR NOT NULL, - "content" TEXT NOT NULL, - PRIMARY KEY(project_id, worktree_id, path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_settings_files_on_project_id" ON "worktree_settings_files" ("project_id"); -CREATE INDEX "index_settings_files_on_project_id_and_wt_id" ON "worktree_settings_files" ("project_id", "worktree_id"); diff --git a/crates/collab/migrations/20230605191135_remove_repository_statuses.sql b/crates/collab/migrations/20230605191135_remove_repository_statuses.sql deleted file mode 100644 index 3e5f907c442d3604ebff5f2fbf60e9c34caa25d9..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230605191135_remove_repository_statuses.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "git_status" INT8; diff --git a/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql b/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql deleted file mode 100644 index e4348af0cc5c12a43fac3adecc106eb16a6de005..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "is_external" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql deleted file mode 100644 index df981838bf72d7ef7392ed6f4e302ffdc57631db..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230727150500_add_channels.sql +++ /dev/null @@ -1,30 +0,0 @@ -DROP TABLE "channel_messages"; -DROP TABLE "channel_memberships"; -DROP TABLE "org_memberships"; -DROP TABLE "orgs"; -DROP TABLE "channels"; - -CREATE TABLE "channels" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT now() -); - -CREATE TABLE "channel_paths" ( - "id_path" VARCHAR NOT NULL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE -); -CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id"); - -CREATE TABLE "channel_members" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "admin" BOOLEAN NOT NULL DEFAULT false, - "accepted" BOOLEAN NOT NULL DEFAULT false, - "updated_at" TIMESTAMP NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id"); - -ALTER TABLE rooms ADD COLUMN "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE; diff --git a/crates/collab/migrations/20230819154600_add_channel_buffers.sql b/crates/collab/migrations/20230819154600_add_channel_buffers.sql deleted file mode 100644 index 5e6e7ce3393a628c86cbcdabf2349ebfa6667bd6..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230819154600_add_channel_buffers.sql +++ /dev/null @@ -1,40 +0,0 @@ -CREATE TABLE "buffers" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL DEFAULT 0 -); - -CREATE INDEX "index_buffers_on_channel_id" ON "buffers" ("channel_id"); - -CREATE TABLE "buffer_operations" ( - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - "lamport_timestamp" INTEGER NOT NULL, - "value" BYTEA NOT NULL, - PRIMARY KEY(buffer_id, epoch, lamport_timestamp, replica_id) -); - -CREATE TABLE "buffer_snapshots" ( - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "text" TEXT NOT NULL, - "operation_serialization_version" INTEGER NOT NULL, - PRIMARY KEY(buffer_id, epoch) -); - -CREATE TABLE "channel_buffer_collaborators" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "connection_lost" BOOLEAN NOT NULL DEFAULT FALSE, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "replica_id" INTEGER NOT NULL -); - -CREATE INDEX "index_channel_buffer_collaborators_on_channel_id" ON "channel_buffer_collaborators" ("channel_id"); -CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_and_replica_id" ON "channel_buffer_collaborators" ("channel_id", "replica_id"); -CREATE INDEX "index_channel_buffer_collaborators_on_connection_server_id" ON "channel_buffer_collaborators" ("connection_server_id"); -CREATE INDEX "index_channel_buffer_collaborators_on_connection_id" ON "channel_buffer_collaborators" ("connection_id"); -CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_connection_id_and_server_id" ON "channel_buffer_collaborators" ("channel_id", "connection_id", "connection_server_id"); diff --git a/crates/collab/migrations/20230825190322_add_server_feature_flags.sql b/crates/collab/migrations/20230825190322_add_server_feature_flags.sql deleted file mode 100644 index fffde54a20e4869ccbef2093de4e7fe5044132e2..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230825190322_add_server_feature_flags.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE "feature_flags" ( - "id" SERIAL PRIMARY KEY, - "flag" VARCHAR(255) NOT NULL UNIQUE -); - -CREATE UNIQUE INDEX "index_feature_flags" ON "feature_flags" ("id"); - -CREATE TABLE "user_features" ( - "user_id" INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - "feature_id" INTEGER NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, feature_id) -); - -CREATE UNIQUE INDEX "index_user_features_user_id_and_feature_id" ON "user_features" ("user_id", "feature_id"); -CREATE INDEX "index_user_features_on_user_id" ON "user_features" ("user_id"); -CREATE INDEX "index_user_features_on_feature_id" ON "user_features" ("feature_id"); diff --git a/crates/collab/migrations/20230907114200_add_channel_messages.sql b/crates/collab/migrations/20230907114200_add_channel_messages.sql deleted file mode 100644 index abe7753ca69fb45a1f0a56b732963d8dc5605e31..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230907114200_add_channel_messages.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE IF NOT EXISTS "channel_messages" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "sender_id" INTEGER NOT NULL REFERENCES users (id), - "body" TEXT NOT NULL, - "sent_at" TIMESTAMP, - "nonce" UUID NOT NULL -); -CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id"); -CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce"); - -CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( - "id" SERIAL PRIMARY KEY, - "user_id" INTEGER NOT NULL REFERENCES users (id), - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE -); -CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id"); diff --git a/crates/collab/migrations/20230925210437_add_channel_changes.sql b/crates/collab/migrations/20230925210437_add_channel_changes.sql deleted file mode 100644 index 250a9ac731b59489e85cf34a6754307bfac543ee..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230925210437_add_channel_changes.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE IF NOT EXISTS "observed_buffer_edits" ( - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "lamport_timestamp" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - PRIMARY KEY (user_id, buffer_id) -); - -CREATE UNIQUE INDEX "index_observed_buffer_user_and_buffer_id" ON "observed_buffer_edits" ("user_id", "buffer_id"); - -CREATE TABLE IF NOT EXISTS "observed_channel_messages" ( - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "channel_message_id" INTEGER NOT NULL, - PRIMARY KEY (user_id, channel_id) -); - -CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id"); diff --git a/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql b/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql deleted file mode 100644 index 1493119e2a97ac42f5d69ebc82ac3d3d0dc4dd63..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE room_participants ADD COLUMN participant_index INTEGER; diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql deleted file mode 100644 index 93c282c631f3d5545593b7c71f013d8457cd088a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE "notification_kinds" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name"); - -CREATE TABLE notifications ( - "id" SERIAL PRIMARY KEY, - "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "entity_id" INTEGER, - "content" TEXT, - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, - "response" BOOLEAN -); - -CREATE INDEX - "index_notifications_on_recipient_id_is_read_kind_entity_id" - ON "notifications" - ("recipient_id", "is_read", "kind", "entity_id"); diff --git a/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql b/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql deleted file mode 100644 index 8f3a704adde0c385b26bd553d273eff322a17702..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE rooms ADD COLUMN enviroment TEXT; diff --git a/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql b/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql deleted file mode 100644 index 21ec4cfbb75a574ad3704179a0ae14c8050149d1..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id"); diff --git a/crates/collab/migrations/20231011214412_add_guest_role.sql b/crates/collab/migrations/20231011214412_add_guest_role.sql deleted file mode 100644 index 17135471583a03bd6b39e82b3644b683cfc96d57..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231011214412_add_guest_role.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE channel_members ADD COLUMN role TEXT; -UPDATE channel_members SET role = CASE WHEN admin THEN 'admin' ELSE 'member' END; - -ALTER TABLE channels ADD COLUMN visibility TEXT NOT NULL DEFAULT 'members'; diff --git a/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql b/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql deleted file mode 100644 index be535ff7fa6e707182b8698647ecc92d9f976183..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Add migration script here - -ALTER TABLE projects - DROP CONSTRAINT projects_room_id_fkey, - ADD CONSTRAINT projects_room_id_fkey - FOREIGN KEY (room_id) - REFERENCES rooms (id) - ON DELETE CASCADE; diff --git a/crates/collab/migrations/20231018102700_create_mentions.sql b/crates/collab/migrations/20231018102700_create_mentions.sql deleted file mode 100644 index 221a1748cfe16276deb4fc3dd2329983340307e7..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231018102700_create_mentions.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE "channel_message_mentions" ( - "message_id" INTEGER NOT NULL REFERENCES channel_messages (id) ON DELETE CASCADE, - "start_offset" INTEGER NOT NULL, - "end_offset" INTEGER NOT NULL, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - PRIMARY KEY(message_id, start_offset) -); - --- We use 'on conflict update' with this index, so it should be per-user. -CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce"); -DROP INDEX "index_channel_messages_on_nonce"; diff --git a/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql b/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql deleted file mode 100644 index d9fc6c872267b89fe30c958b4b01bb7fdf1fc448..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql +++ /dev/null @@ -1,12 +0,0 @@ -ALTER TABLE channels ADD COLUMN parent_path TEXT; - -UPDATE channels -SET parent_path = substr( - channel_paths.id_path, - 2, - length(channel_paths.id_path) - length('/' || channel_paths.channel_id::text || '/') -) -FROM channel_paths -WHERE channel_paths.channel_id = channels.id; - -CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path"); diff --git a/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql b/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql deleted file mode 100644 index 2748e00ebaa18ec375111c648a7accafe90c5dbb..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE room_participants ADD COLUMN role TEXT; diff --git a/crates/collab/migrations/20240111085546_fix_column_name.sql b/crates/collab/migrations/20240111085546_fix_column_name.sql deleted file mode 100644 index 3f32ee35c59107e12fda98159911dbba6e13434a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240111085546_fix_column_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE rooms ADD COLUMN environment TEXT; diff --git a/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql b/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql deleted file mode 100644 index 8c79640cd88bfad58e5f9eafda90ae2d80e4e834..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE access_tokens ADD COLUMN impersonated_user_id integer; diff --git a/crates/collab/migrations/20240122174606_add_contributors.sql b/crates/collab/migrations/20240122174606_add_contributors.sql deleted file mode 100644 index 16bec82d4f2bd0a1b3f4221366cd822ebcd70bb1..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240122174606_add_contributors.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE contributors ( - user_id INTEGER REFERENCES users(id), - signed_at TIMESTAMP NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id) -); diff --git a/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql b/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql deleted file mode 100644 index a9248d294a2178b73986ab20cd06383d0397626b..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "channels" ADD COLUMN "requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20240129193601_fix_parent_path_index.sql b/crates/collab/migrations/20240129193601_fix_parent_path_index.sql deleted file mode 100644 index 73dd6e37cdf82f2f5d77e8b3cd14a2fbe43f2320..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240129193601_fix_parent_path_index.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here - -DROP INDEX index_channels_on_parent_path; -CREATE INDEX index_channels_on_parent_path ON channels (parent_path text_pattern_ops); diff --git a/crates/collab/migrations/20240203113741_add_reply_to_message.sql b/crates/collab/migrations/20240203113741_add_reply_to_message.sql deleted file mode 100644 index 6f40b62822bb4936f0f90e3be65f640e323d09d0..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240203113741_add_reply_to_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE channel_messages ADD reply_to_message_id INTEGER DEFAULT NULL diff --git a/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql b/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql deleted file mode 100644 index 09463c6e784d4e13df5376ad3cd53c8cb9ebcf45..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here - -ALTER TABLE room_participants ADD COLUMN in_call BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql b/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql deleted file mode 100644 index dc4897af48afd3fa9ebc403b6f8103b933993ac4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here -ALTER TABLE rooms DROP COLUMN enviroment; -ALTER TABLE rooms DROP COLUMN environment; -ALTER TABLE room_participants DROP COLUMN in_call; diff --git a/crates/collab/migrations/20240214102900_add_extensions.sql b/crates/collab/migrations/20240214102900_add_extensions.sql deleted file mode 100644 index b32094036d6a8993a8dbc6dc2407dec0e53aea47..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240214102900_add_extensions.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE IF NOT EXISTS extensions ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - external_id TEXT NOT NULL, - latest_version TEXT NOT NULL, - total_download_count BIGINT NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS extension_versions ( - extension_id INTEGER REFERENCES extensions(id), - version TEXT NOT NULL, - published_at TIMESTAMP NOT NULL DEFAULT now(), - authors TEXT NOT NULL, - repository TEXT NOT NULL, - description TEXT NOT NULL, - download_count BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY(extension_id, version) -); - -CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id"); -CREATE INDEX "trigram_index_extensions_name" ON "extensions" USING GIN(name gin_trgm_ops); -CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count"); diff --git a/crates/collab/migrations/20240220234826_add_rate_buckets.sql b/crates/collab/migrations/20240220234826_add_rate_buckets.sql deleted file mode 100644 index 864a4373034fc53ea357f0b4d46b1b127a9f8db5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240220234826_add_rate_buckets.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS rate_buckets ( - user_id INT NOT NULL, - rate_limit_name VARCHAR(255) NOT NULL, - token_count INT NOT NULL, - last_refill TIMESTAMP WITHOUT TIME ZONE NOT NULL, - PRIMARY KEY (user_id, rate_limit_name), - CONSTRAINT fk_user - FOREIGN KEY (user_id) REFERENCES users(id) -); - -CREATE INDEX idx_user_id_rate_limit ON rate_buckets (user_id, rate_limit_name); diff --git a/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql b/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql deleted file mode 100644 index 1d07b07de7bf382fe0610ce170a639ab769567d4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE channel_messages ADD edited_at TIMESTAMP DEFAULT NULL; diff --git a/crates/collab/migrations/20240226163408_hosted_projects.sql b/crates/collab/migrations/20240226163408_hosted_projects.sql deleted file mode 100644 index c6ade7161cce7eafc00564f3e1b934be93b08186..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240226163408_hosted_projects.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Add migration script here - -CREATE TABLE hosted_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - name TEXT NOT NULL, - visibility TEXT NOT NULL, - deleted_at TIMESTAMP NULL -); -CREATE INDEX idx_hosted_projects_on_channel_id ON hosted_projects (channel_id); -CREATE UNIQUE INDEX uix_hosted_projects_on_channel_id_and_name ON hosted_projects (channel_id, name) WHERE (deleted_at IS NULL); diff --git a/crates/collab/migrations/20240226164505_unique_channel_names.sql b/crates/collab/migrations/20240226164505_unique_channel_names.sql deleted file mode 100644 index c9d9f0a1cbf55249ee1fc80c5338bdc45381b46f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240226164505_unique_channel_names.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here - -CREATE UNIQUE INDEX uix_channels_parent_path_name ON channels(parent_path, name) WHERE (parent_path IS NOT NULL AND parent_path != ''); diff --git a/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql b/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql deleted file mode 100644 index 69905d12f6d0e9945f291dc7b86f446bdaab08ac..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here -ALTER TABLE projects ALTER COLUMN host_user_id DROP NOT NULL; -ALTER TABLE projects ADD COLUMN hosted_project_id INTEGER REFERENCES hosted_projects(id) UNIQUE NULL; diff --git a/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql b/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql deleted file mode 100644 index a332a20d52c4564ac90989a60fc0dd850d86034c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Add migration script here - -ALTER TABLE buffers ADD COLUMN latest_operation_epoch INTEGER; -ALTER TABLE buffers ADD COLUMN latest_operation_lamport_timestamp INTEGER; -ALTER TABLE buffers ADD COLUMN latest_operation_replica_id INTEGER; - -WITH ops AS ( - SELECT DISTINCT ON (buffer_id) buffer_id, epoch, lamport_timestamp, replica_id - FROM buffer_operations - ORDER BY buffer_id, epoch DESC, lamport_timestamp DESC, replica_id DESC -) -UPDATE buffers -SET latest_operation_epoch = ops.epoch, - latest_operation_lamport_timestamp = ops.lamport_timestamp, - latest_operation_replica_id = ops.replica_id -FROM ops -WHERE buffers.id = ops.buffer_id; diff --git a/crates/collab/migrations/20240315182903_non_null_channel_role.sql b/crates/collab/migrations/20240315182903_non_null_channel_role.sql deleted file mode 100644 index 2d359f8058f0591e47bc733f1c8b4c60fe3a56cd..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240315182903_non_null_channel_role.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here - -ALTER TABLE channel_members ALTER role SET NOT NULL; -ALTER TABLE channel_members DROP COLUMN admin; diff --git a/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql b/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql deleted file mode 100644 index 5703578b008817bacaa6e1956b28cdac7919e5fa..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add migration script here -ALTER TABLE channels ALTER parent_path SET NOT NULL; diff --git a/crates/collab/migrations/20240320124800_add_extension_schema_version.sql b/crates/collab/migrations/20240320124800_add_extension_schema_version.sql deleted file mode 100644 index 75fd0f40e4f9013f41e57cd094b3e93bf5f46101..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240320124800_add_extension_schema_version.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add migration script here -ALTER TABLE extension_versions ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 0; diff --git a/crates/collab/migrations/20240321162658_add_devservers.sql b/crates/collab/migrations/20240321162658_add_devservers.sql deleted file mode 100644 index cb1ff4df405f9f1deb9d2c9e86f4234b9ba6d2b2..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240321162658_add_devservers.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE dev_servers ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - name TEXT NOT NULL, - hashed_token TEXT NOT NULL -); -CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id); diff --git a/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql b/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql deleted file mode 100644 index 3b95323d262f1666dfbe8696a780dd5e8b674c99..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE extension_versions ADD COLUMN wasm_api_version TEXT; diff --git a/crates/collab/migrations/20240402155003_add_dev_server_projects.sql b/crates/collab/migrations/20240402155003_add_dev_server_projects.sql deleted file mode 100644 index 003c43f4e27f4f7fb4cbfbb84b4be11d3a42ecc0..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240402155003_add_dev_server_projects.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE remote_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - dev_server_id INT NOT NULL REFERENCES dev_servers(id), - name TEXT NOT NULL, - path TEXT NOT NULL -); - -ALTER TABLE projects ADD COLUMN remote_project_id INTEGER REFERENCES remote_projects(id); diff --git a/crates/collab/migrations/20240409082755_create_embeddings.sql b/crates/collab/migrations/20240409082755_create_embeddings.sql deleted file mode 100644 index ae4b4bcb61c049ea75726fb92eaf2c795891370e..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240409082755_create_embeddings.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS "embeddings" ( - "model" TEXT, - "digest" BYTEA, - "dimensions" FLOAT4[1536], - "retrieved_at" TIMESTAMP NOT NULL DEFAULT now(), - PRIMARY KEY ("model", "digest") -); - -CREATE INDEX IF NOT EXISTS "idx_retrieved_at_on_embeddings" ON "embeddings" ("retrieved_at"); diff --git a/crates/collab/migrations/20240412165156_dev_servers_per_user.sql b/crates/collab/migrations/20240412165156_dev_servers_per_user.sql deleted file mode 100644 index 7ef9e2fde0530aeca9598a0a1030eabdba3036f0..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240412165156_dev_servers_per_user.sql +++ /dev/null @@ -1,7 +0,0 @@ -DELETE FROM remote_projects; -DELETE FROM dev_servers; - -ALTER TABLE dev_servers DROP COLUMN channel_id; -ALTER TABLE dev_servers ADD COLUMN user_id INT NOT NULL REFERENCES users(id); - -ALTER TABLE remote_projects DROP COLUMN channel_id; diff --git a/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql b/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql deleted file mode 100644 index 923b948ceeb3ebe27502cb773a5232bc6cc39fc4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE remote_projects DROP COLUMN name; -ALTER TABLE remote_projects -ADD CONSTRAINT unique_path_constraint UNIQUE(dev_server_id, path); diff --git a/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql b/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql deleted file mode 100644 index 0d8e9de5e6ada47c617401b53dcca5d18b643aa6..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE dev_server_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY (START WITH 100), - dev_server_id INT NOT NULL REFERENCES dev_servers(id) ON DELETE CASCADE, - path TEXT NOT NULL -); -INSERT INTO dev_server_projects OVERRIDING SYSTEM VALUE SELECT * FROM remote_projects; - -ALTER TABLE dev_server_projects ADD CONSTRAINT uix_dev_server_projects_dev_server_id_path UNIQUE(dev_server_id, path); - -ALTER TABLE projects ADD COLUMN dev_server_project_id INTEGER REFERENCES dev_server_projects(id); -UPDATE projects SET dev_server_project_id = remote_project_id; diff --git a/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql b/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql deleted file mode 100644 index 01ace43fab08bfeee9b7e665ccd28ebc50f27134..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE projects DROP COLUMN remote_project_id; -DROP TABLE remote_projects; diff --git a/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql b/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql deleted file mode 100644 index 5085ca271bd130ab25d173391daae8542930ae27..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE dev_servers ADD COLUMN ssh_connection_string TEXT; diff --git a/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql b/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql deleted file mode 100644 index 675df4885bb531722cf80a76bf93eac58add5b8c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE dev_server_projects ADD COLUMN paths JSONB NULL; -UPDATE dev_server_projects SET paths = to_json(ARRAY[path]); -ALTER TABLE dev_server_projects ALTER COLUMN paths SET NOT NULL; -ALTER TABLE dev_server_projects ALTER COLUMN path DROP NOT NULL; diff --git a/crates/collab/migrations/20240729170526_add_billing_subscription.sql b/crates/collab/migrations/20240729170526_add_billing_subscription.sql deleted file mode 100644 index acec4b3ddb43c3a59e221daabad4b3161c867d09..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240729170526_add_billing_subscription.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS billing_subscriptions ( - id SERIAL PRIMARY KEY, - created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stripe_customer_id TEXT NOT NULL, - stripe_subscription_id TEXT NOT NULL, - stripe_subscription_status TEXT NOT NULL -); - -CREATE INDEX "ix_billing_subscriptions_on_user_id" ON billing_subscriptions (user_id); -CREATE INDEX "ix_billing_subscriptions_on_stripe_customer_id" ON billing_subscriptions (stripe_customer_id); -CREATE UNIQUE INDEX "uix_billing_subscriptions_on_stripe_subscription_id" ON billing_subscriptions (stripe_subscription_id); diff --git a/crates/collab/migrations/20240730014107_add_billing_customer.sql b/crates/collab/migrations/20240730014107_add_billing_customer.sql deleted file mode 100644 index 7f7d4a0f85608ba07595b81d90dd617a8acd4e0c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240730014107_add_billing_customer.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE IF NOT EXISTS billing_customers ( - id SERIAL PRIMARY KEY, - created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stripe_customer_id TEXT NOT NULL -); - -CREATE UNIQUE INDEX "uix_billing_customers_on_user_id" ON billing_customers (user_id); -CREATE UNIQUE INDEX "uix_billing_customers_on_stripe_customer_id" ON billing_customers (stripe_customer_id); - --- Make `billing_subscriptions` reference `billing_customers` instead of having its --- own `user_id` and `stripe_customer_id`. -DROP INDEX IF EXISTS "ix_billing_subscriptions_on_user_id"; -DROP INDEX IF EXISTS "ix_billing_subscriptions_on_stripe_customer_id"; -ALTER TABLE billing_subscriptions DROP COLUMN user_id; -ALTER TABLE billing_subscriptions DROP COLUMN stripe_customer_id; -ALTER TABLE billing_subscriptions ADD COLUMN billing_customer_id INTEGER NOT NULL REFERENCES billing_customers (id) ON DELETE CASCADE; -CREATE INDEX "ix_billing_subscriptions_on_billing_customer_id" ON billing_subscriptions (billing_customer_id); diff --git a/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql b/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql deleted file mode 100644 index 477eadd742e3a128356fdeb75d5f35c1e8f77795..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE billing_customers ADD COLUMN last_stripe_event_id TEXT; -ALTER TABLE billing_subscriptions ADD COLUMN last_stripe_event_id TEXT; diff --git a/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql b/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql deleted file mode 100644 index baf1aa3122e8009a705708e9b8880f5eba5f36f5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE billing_customers DROP COLUMN last_stripe_event_id; -ALTER TABLE billing_subscriptions DROP COLUMN last_stripe_event_id; - -CREATE TABLE IF NOT EXISTS processed_stripe_events ( - stripe_event_id TEXT PRIMARY KEY, - stripe_event_type TEXT NOT NULL, - stripe_event_created_timestamp BIGINT NOT NULL, - processed_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now() -); - -CREATE INDEX "ix_processed_stripe_events_on_stripe_event_created_timestamp" ON processed_stripe_events (stripe_event_created_timestamp); diff --git a/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql b/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql deleted file mode 100644 index b09640bb1eabafda7b9eef9d0763db31d78d1e96..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE billing_subscriptions ADD COLUMN stripe_cancel_at TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql b/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql deleted file mode 100644 index 43fa0e7bbdcbfe1c0f05a5ae3a74966dcecd7f1b..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD accepted_tos_at TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql b/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql deleted file mode 100644 index a5f713ef7c489f9b87ea9c3b47345903f1e4b5b5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "users" ADD COLUMN "github_user_created_at" TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql b/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql deleted file mode 100644 index a56c87b97a41de260ccb4aa7d44fd35d2c026293..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql +++ /dev/null @@ -1 +0,0 @@ -alter table feature_flags add column enabled_for_all boolean not null default false; diff --git a/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql b/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql deleted file mode 100644 index 3b418f7e2669adfed83919605387d2c613b7f01a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table users alter column github_user_id set not null; - -drop index index_users_on_github_user_id; -create unique index uix_users_on_github_user_id on users (github_user_id); diff --git a/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql b/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql deleted file mode 100644 index af6fdac19d2498e990605d9851cb690dff41e830..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "is_fifo" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20241002120231_add_local_settings_kind.sql b/crates/collab/migrations/20241002120231_add_local_settings_kind.sql deleted file mode 100644 index aec4ffb8f8519b3fb30c90db4d9bd1221237d7c7..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241002120231_add_local_settings_kind.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "worktree_settings_files" ADD COLUMN "kind" VARCHAR; diff --git a/crates/collab/migrations/20241009190639_add_billing_preferences.sql b/crates/collab/migrations/20241009190639_add_billing_preferences.sql deleted file mode 100644 index 9aa5a1a303668eac7032555f0ea04c6c34b1718f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241009190639_add_billing_preferences.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table if not exists billing_preferences ( - id serial primary key, - created_at timestamp without time zone not null default now(), - user_id integer not null references users(id) on delete cascade, - max_monthly_llm_usage_spending_in_cents integer not null -); - -create unique index "uix_billing_preferences_on_user_id" on billing_preferences (user_id); diff --git a/crates/collab/migrations/20241019184824_adjust_symlink_data.sql b/crates/collab/migrations/20241019184824_adjust_symlink_data.sql deleted file mode 100644 index a38dd21cde85e6ac48c2e45afd5397a81952e712..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241019184824_adjust_symlink_data.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_entries ADD COLUMN canonical_path text; -ALTER TABLE worktree_entries ALTER COLUMN is_symlink SET DEFAULT false; diff --git a/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql b/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql deleted file mode 100644 index 60a9bfa91074b4bff08e2cd686e6203beb6b2cf4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql +++ /dev/null @@ -1 +0,0 @@ -alter table users add column custom_llm_monthly_allowance_in_cents integer; diff --git a/crates/collab/migrations/20241023201725_remove_dev_servers.sql b/crates/collab/migrations/20241023201725_remove_dev_servers.sql deleted file mode 100644 index c5da673a29b1e08b371c712c2786676829f3c25f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241023201725_remove_dev_servers.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE projects DROP COLUMN dev_server_project_id; -ALTER TABLE projects DROP COLUMN hosted_project_id; - -DROP TABLE hosted_projects; -DROP TABLE dev_server_projects; -DROP TABLE dev_servers; diff --git a/crates/collab/migrations/20241121185750_add_breakpoints.sql b/crates/collab/migrations/20241121185750_add_breakpoints.sql deleted file mode 100644 index 4b3071457392f433959ce8270b4dd91f6b99bb78..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241121185750_add_breakpoints.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS "breakpoints" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "position" INTEGER NOT NULL, - "log_message" TEXT NULL, - "worktree_id" BIGINT NOT NULL, - "path" TEXT NOT NULL, - "kind" VARCHAR NOT NULL -); - -CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); diff --git a/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql b/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql deleted file mode 100644 index 31686f56bbc06e21797e5389664bf2b2850a6263..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions -add column stripe_cancellation_reason text; diff --git a/crates/collab/migrations/20250113230049_expand_git_status_information.sql b/crates/collab/migrations/20250113230049_expand_git_status_information.sql deleted file mode 100644 index eada39fe304020556bd39dedf640a890144ae5dd..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250113230049_expand_git_status_information.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE worktree_repository_statuses -ADD COLUMN status_kind INTEGER, -ADD COLUMN first_status INTEGER, -ADD COLUMN second_status INTEGER; - -UPDATE worktree_repository_statuses -SET - status_kind = 0; - -ALTER TABLE worktree_repository_statuses -ALTER COLUMN status_kind -SET - NOT NULL; diff --git a/crates/collab/migrations/20250117100620_add_user_name.sql b/crates/collab/migrations/20250117100620_add_user_name.sql deleted file mode 100644 index fff7f95b6052c54c47bcc0c40ef49c65e35b95b4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250117100620_add_user_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN name TEXT; diff --git a/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql b/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql deleted file mode 100644 index 07c40303994395e8f43c33df130955e0d82ab627..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers -add column has_overdue_invoices bool not null default false; diff --git a/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql b/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql deleted file mode 100644 index 50dcb0508f35cc69a87bbcbf83fed9809f539b41..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql +++ /dev/null @@ -1,10 +0,0 @@ -alter table extension_versions -add column provides_themes bool not null default false, -add column provides_icon_themes bool not null default false, -add column provides_languages bool not null default false, -add column provides_grammars bool not null default false, -add column provides_language_servers bool not null default false, -add column provides_context_servers bool not null default false, -add column provides_slash_commands bool not null default false, -add column provides_indexed_docs_providers bool not null default false, -add column provides_snippets bool not null default false; diff --git a/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql b/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql deleted file mode 100644 index e6e0770bba8cbbb7649689705c526ead9629518d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_repositories -ADD COLUMN current_merge_conflicts VARCHAR NULL; diff --git a/crates/collab/migrations/20250210223746_add_branch_summary.sql b/crates/collab/migrations/20250210223746_add_branch_summary.sql deleted file mode 100644 index 3294f38b94114a73713b6282d401b97fcdc383e5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250210223746_add_branch_summary.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_repositories -ADD COLUMN worktree_repositories VARCHAR NULL; diff --git a/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql b/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql deleted file mode 100644 index d7e3c04e2ff7844ed8d47907b0d21b64ae7d9a1c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE worktree_repositories ADD COLUMN branch_summary TEXT NULL; diff --git a/crates/collab/migrations/20250319182812_create_project_repositories.sql b/crates/collab/migrations/20250319182812_create_project_repositories.sql deleted file mode 100644 index 8ca8c3444e60ccc4105e01e7a0d035930d57da4d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250319182812_create_project_repositories.sql +++ /dev/null @@ -1,32 +0,0 @@ -CREATE TABLE "project_repositories" ( - "project_id" INTEGER NOT NULL, - "abs_path" VARCHAR, - "id" INT8 NOT NULL, - "legacy_worktree_id" INT8, - "entry_ids" VARCHAR, - "branch" VARCHAR, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - "current_merge_conflicts" VARCHAR, - "branch_summary" VARCHAR, - PRIMARY KEY (project_id, id) -); - -CREATE INDEX "index_project_repositories_on_project_id" ON "project_repositories" ("project_id"); - -CREATE TABLE "project_repository_statuses" ( - "project_id" INTEGER NOT NULL, - "repository_id" INT8 NOT NULL, - "repo_path" VARCHAR NOT NULL, - "status" INT8 NOT NULL, - "status_kind" INT4 NOT NULL, - "first_status" INT4 NULL, - "second_status" INT4 NULL, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY (project_id, repository_id, repo_path) -); - -CREATE INDEX "index_project_repos_statuses_on_project_id" ON "project_repository_statuses" ("project_id"); - -CREATE INDEX "index_project_repos_statuses_on_project_id_and_repo_id" ON "project_repository_statuses" ("project_id", "repository_id"); diff --git a/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql b/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql deleted file mode 100644 index b91431b28ba2ed2ce9fcf13a72938aad46330f04..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table billing_subscriptions - add column kind text, - add column stripe_current_period_start bigint, - add column stripe_current_period_end bigint; diff --git a/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql b/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql deleted file mode 100644 index 34a159cf65a2a92aa21b75daa62c9a0d91023bcc..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column trial_started_at timestamp without time zone; diff --git a/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql b/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql deleted file mode 100644 index c37fed224229d99403868f462a23fe811b27fda6..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table project_repositories - add column head_commit_details varchar; diff --git a/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql b/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql deleted file mode 100644 index 86e35c9202b9184796a8c3d2463f20f89766ec8c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_preferences - add column model_request_overages_enabled bool not null default false, - add column model_request_overages_spend_limit_in_cents integer not null default 0; diff --git a/crates/collab/migrations/20250530175450_add_channel_order.sql b/crates/collab/migrations/20250530175450_add_channel_order.sql deleted file mode 100644 index 977a4611cdb75d0e53c8d1c132290f9da7469dc5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250530175450_add_channel_order.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Add channel_order column to channels table with default value -ALTER TABLE channels ADD COLUMN channel_order INTEGER NOT NULL DEFAULT 1; - --- Update channel_order for existing channels using ROW_NUMBER for deterministic ordering -UPDATE channels -SET channel_order = ( - SELECT ROW_NUMBER() OVER ( - PARTITION BY parent_path - ORDER BY name, id - ) - FROM channels c2 - WHERE c2.id = channels.id -); - --- Create index for efficient ordering queries -CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order"); diff --git a/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql b/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql deleted file mode 100644 index 73876e89652deea8bbebf354b99cb2d792894130..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table project_collaborators - add column committer_name varchar; -alter table project_collaborators - add column committer_email varchar; diff --git a/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql b/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql deleted file mode 100644 index 8455a82f9ee6b5fdf8cfba3da08c880018061a43..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table extension_versions -add column provides_debug_adapters bool not null default false diff --git a/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql b/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql deleted file mode 100644 index 3c399924b96891d490792fb36b61a034f8dce97f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table extension_versions -add column provides_agent_servers bool not null default false diff --git a/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql b/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql deleted file mode 100644 index 6d898c481199f4770ab7df5ce66c08e2fdf42423..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql +++ /dev/null @@ -1,25 +0,0 @@ -DELETE FROM project_repositories -WHERE project_id NOT IN (SELECT id FROM projects); - -ALTER TABLE project_repositories - ADD CONSTRAINT fk_project_repositories_project_id - FOREIGN KEY (project_id) - REFERENCES projects (id) - ON DELETE CASCADE - NOT VALID; - -ALTER TABLE project_repositories - VALIDATE CONSTRAINT fk_project_repositories_project_id; - -DELETE FROM project_repository_statuses -WHERE project_id NOT IN (SELECT id FROM projects); - -ALTER TABLE project_repository_statuses - ADD CONSTRAINT fk_project_repository_statuses_project_id - FOREIGN KEY (project_id) - REFERENCES projects (id) - ON DELETE CASCADE - NOT VALID; - -ALTER TABLE project_repository_statuses - VALIDATE CONSTRAINT fk_project_repository_statuses_project_id; diff --git a/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql b/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql deleted file mode 100644 index ae0ffe24f6322196358225ff4159df9d1cfa6298..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE access_tokens DROP CONSTRAINT access_tokens_user_id_fkey; -ALTER TABLE access_tokens ADD CONSTRAINT access_tokens_user_id_fkey - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/crates/collab/migrations/20250804080620_language_server_capabilities.sql b/crates/collab/migrations/20250804080620_language_server_capabilities.sql deleted file mode 100644 index f74f094ed25d488720f2f85f30b6762f83647b02..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250804080620_language_server_capabilities.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE language_servers - ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}'; - -ALTER TABLE language_servers - ALTER COLUMN capabilities DROP DEFAULT; diff --git a/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql b/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql deleted file mode 100644 index e372723d6d5f5e822a2e437cfac4b95bc2023998..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table users -alter column admin set not null; diff --git a/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql b/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql deleted file mode 100644 index ea5e4de52a829413030bb5e206f5c7401381adcf..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column orb_customer_id text; diff --git a/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql b/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql deleted file mode 100644 index f51a33ed30d7fb88bc9dc6c82e7217c7e4634b28..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql +++ /dev/null @@ -1 +0,0 @@ -drop table rate_buckets; diff --git a/crates/collab/migrations/20250818192156_add_git_merge_message.sql b/crates/collab/migrations/20250818192156_add_git_merge_message.sql deleted file mode 100644 index 335ea2f82493082e0e20d7762b5282696dc50224..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250818192156_add_git_merge_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "project_repositories" ADD COLUMN "merge_message" VARCHAR; diff --git a/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql b/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql deleted file mode 100644 index 317f6a7653e3d1762f74e795a17d2f99b3831201..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions - add column orb_subscription_id text; diff --git a/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql b/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql deleted file mode 100644 index cf3b79da60be98da8dd78a2bcb01f7532be7fc59..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_subscriptions - alter column stripe_subscription_id drop not null, - alter column stripe_subscription_status drop not null; diff --git a/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql b/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql deleted file mode 100644 index 89a42ab82bd97f487a426ef1fa0a08aa5b0c8396..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table billing_subscriptions - add column orb_subscription_status text, - add column orb_current_billing_period_start_date timestamp without time zone, - add column orb_current_billing_period_end_date timestamp without time zone; diff --git a/crates/collab/migrations/20250827084812_worktree_in_servers.sql b/crates/collab/migrations/20250827084812_worktree_in_servers.sql deleted file mode 100644 index d4c6ffbbcccb2d2f23654cfc287b45bb8ea20508..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250827084812_worktree_in_servers.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE language_servers - ADD COLUMN worktree_id BIGINT; diff --git a/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql b/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql deleted file mode 100644 index 56144237421d49fa68545f9689bdb1688603739a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions - add column orb_cancellation_date timestamp without time zone; diff --git a/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql b/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql deleted file mode 100644 index 2de05740410f5d13f7cd510a85af24dd2ff171b6..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column orb_portal_url text; diff --git a/crates/collab/migrations/20250916173002_add_path_style_to_project.sql b/crates/collab/migrations/20250916173002_add_path_style_to_project.sql deleted file mode 100644 index b1244818f14403d38af577be4b14b1a8a765e07b..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250916173002_add_path_style_to_project.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE projects ADD COLUMN windows_paths BOOLEAN DEFAULT FALSE; diff --git a/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql b/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql deleted file mode 100644 index ccae01e2833fedd530f290c55d2852c33de6957c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_subscriptions - add column token_spend_in_cents integer, - add column token_spend_in_cents_updated_at timestamp without time zone; diff --git a/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql b/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql deleted file mode 100644 index 5b4207aeea500595c66508fa88a20662bc5693c1..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "is_hidden" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20251110214057_drop_channel_messages.sql b/crates/collab/migrations/20251110214057_drop_channel_messages.sql deleted file mode 100644 index 468534542fbb7cee04aee985bfe2143f30d219ad..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251110214057_drop_channel_messages.sql +++ /dev/null @@ -1,3 +0,0 @@ -drop table observed_channel_messages; -drop table channel_message_mentions; -drop table channel_messages; diff --git a/crates/collab/migrations/20251111161644_drop_embeddings.sql b/crates/collab/migrations/20251111161644_drop_embeddings.sql deleted file mode 100644 index 80f42c7d2c88b258ef8cc63757694a7e229643c7..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251111161644_drop_embeddings.sql +++ /dev/null @@ -1 +0,0 @@ -drop table embeddings; diff --git a/crates/collab/migrations/20251117215316_add_external_id_to_billing_customers.sql b/crates/collab/migrations/20251117215316_add_external_id_to_billing_customers.sql deleted file mode 100644 index 6add45d4ad5d83cea6d86d2edc1e06613bd25560..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251117215316_add_external_id_to_billing_customers.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table billing_customers - add column external_id text; - -create unique index uix_billing_customers_on_external_id on billing_customers (external_id); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..ed9c9d16dbdf2fbe2e69134f407ee5365236161b --- /dev/null +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -0,0 +1,899 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; + +CREATE TABLE public.access_tokens ( + id integer NOT NULL, + user_id integer, + hash character varying(128), + impersonated_user_id integer +); + +CREATE SEQUENCE public.access_tokens_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.access_tokens_id_seq OWNED BY public.access_tokens.id; + +CREATE TABLE public.breakpoints ( + id integer NOT NULL, + project_id integer NOT NULL, + "position" integer NOT NULL, + log_message text, + worktree_id bigint NOT NULL, + path text NOT NULL, + kind character varying NOT NULL +); + +CREATE SEQUENCE public.breakpoints_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.breakpoints_id_seq OWNED BY public.breakpoints.id; + +CREATE TABLE public.buffer_operations ( + buffer_id integer NOT NULL, + epoch integer NOT NULL, + replica_id integer NOT NULL, + lamport_timestamp integer NOT NULL, + value bytea NOT NULL +); + +CREATE TABLE public.buffer_snapshots ( + buffer_id integer NOT NULL, + epoch integer NOT NULL, + text text NOT NULL, + operation_serialization_version integer NOT NULL +); + +CREATE TABLE public.buffers ( + id integer NOT NULL, + channel_id integer NOT NULL, + epoch integer DEFAULT 0 NOT NULL, + latest_operation_epoch integer, + latest_operation_lamport_timestamp integer, + latest_operation_replica_id integer +); + +CREATE SEQUENCE public.buffers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.buffers_id_seq OWNED BY public.buffers.id; + +CREATE TABLE public.channel_buffer_collaborators ( + id integer NOT NULL, + channel_id integer NOT NULL, + connection_id integer NOT NULL, + connection_server_id integer NOT NULL, + connection_lost boolean DEFAULT false NOT NULL, + user_id integer NOT NULL, + replica_id integer NOT NULL +); + +CREATE SEQUENCE public.channel_buffer_collaborators_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_buffer_collaborators_id_seq OWNED BY public.channel_buffer_collaborators.id; + +CREATE TABLE public.channel_chat_participants ( + id integer NOT NULL, + user_id integer NOT NULL, + channel_id integer NOT NULL, + connection_id integer NOT NULL, + connection_server_id integer NOT NULL +); + +CREATE SEQUENCE public.channel_chat_participants_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_chat_participants_id_seq OWNED BY public.channel_chat_participants.id; + +CREATE TABLE public.channel_members ( + id integer NOT NULL, + channel_id integer NOT NULL, + user_id integer NOT NULL, + accepted boolean DEFAULT false NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + role text NOT NULL +); + +CREATE SEQUENCE public.channel_members_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_members_id_seq OWNED BY public.channel_members.id; + +CREATE TABLE public.channels ( + id integer NOT NULL, + name character varying NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + visibility text DEFAULT 'members'::text NOT NULL, + parent_path text NOT NULL, + requires_zed_cla boolean DEFAULT false NOT NULL, + channel_order integer DEFAULT 1 NOT NULL +); + +CREATE SEQUENCE public.channels_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channels_id_seq OWNED BY public.channels.id; + +CREATE TABLE public.contacts ( + id integer NOT NULL, + user_id_a integer NOT NULL, + user_id_b integer NOT NULL, + a_to_b boolean NOT NULL, + should_notify boolean NOT NULL, + accepted boolean NOT NULL +); + +CREATE SEQUENCE public.contacts_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.contacts_id_seq OWNED BY public.contacts.id; + +CREATE TABLE public.contributors ( + user_id integer NOT NULL, + signed_at timestamp without time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.extension_versions ( + extension_id integer NOT NULL, + version text NOT NULL, + published_at timestamp without time zone DEFAULT now() NOT NULL, + authors text NOT NULL, + repository text NOT NULL, + description text NOT NULL, + download_count bigint DEFAULT 0 NOT NULL, + schema_version integer DEFAULT 0 NOT NULL, + wasm_api_version text, + provides_themes boolean DEFAULT false NOT NULL, + provides_icon_themes boolean DEFAULT false NOT NULL, + provides_languages boolean DEFAULT false NOT NULL, + provides_grammars boolean DEFAULT false NOT NULL, + provides_language_servers boolean DEFAULT false NOT NULL, + provides_context_servers boolean DEFAULT false NOT NULL, + provides_slash_commands boolean DEFAULT false NOT NULL, + provides_indexed_docs_providers boolean DEFAULT false NOT NULL, + provides_snippets boolean DEFAULT false NOT NULL, + provides_debug_adapters boolean DEFAULT false NOT NULL, + provides_agent_servers boolean DEFAULT false NOT NULL +); + +CREATE TABLE public.extensions ( + id integer NOT NULL, + name text NOT NULL, + external_id text NOT NULL, + latest_version text NOT NULL, + total_download_count bigint DEFAULT 0 NOT NULL +); + +CREATE SEQUENCE public.extensions_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.extensions_id_seq OWNED BY public.extensions.id; + +CREATE TABLE public.feature_flags ( + id integer NOT NULL, + flag character varying(255) NOT NULL, + enabled_for_all boolean DEFAULT false NOT NULL +); + +CREATE SEQUENCE public.feature_flags_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.feature_flags_id_seq OWNED BY public.feature_flags.id; + +CREATE TABLE public.followers ( + id integer NOT NULL, + room_id integer NOT NULL, + project_id integer NOT NULL, + leader_connection_server_id integer NOT NULL, + leader_connection_id integer NOT NULL, + follower_connection_server_id integer NOT NULL, + follower_connection_id integer NOT NULL +); + +CREATE SEQUENCE public.followers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.followers_id_seq OWNED BY public.followers.id; + +CREATE TABLE public.language_servers ( + project_id integer NOT NULL, + id bigint NOT NULL, + name character varying NOT NULL, + capabilities text NOT NULL, + worktree_id bigint +); + +CREATE TABLE public.notification_kinds ( + id integer NOT NULL, + name character varying NOT NULL +); + +CREATE SEQUENCE public.notification_kinds_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.notification_kinds_id_seq OWNED BY public.notification_kinds.id; + +CREATE TABLE public.notifications ( + id integer NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + recipient_id integer NOT NULL, + kind integer NOT NULL, + entity_id integer, + content text, + is_read boolean DEFAULT false NOT NULL, + response boolean +); + +CREATE SEQUENCE public.notifications_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.notifications_id_seq OWNED BY public.notifications.id; + +CREATE TABLE public.observed_buffer_edits ( + user_id integer NOT NULL, + buffer_id integer NOT NULL, + epoch integer NOT NULL, + lamport_timestamp integer NOT NULL, + replica_id integer NOT NULL +); + +CREATE TABLE public.project_collaborators ( + id integer NOT NULL, + project_id integer NOT NULL, + connection_id integer NOT NULL, + user_id integer NOT NULL, + replica_id integer NOT NULL, + is_host boolean NOT NULL, + connection_server_id integer NOT NULL, + committer_name character varying, + committer_email character varying +); + +CREATE SEQUENCE public.project_collaborators_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.project_collaborators_id_seq OWNED BY public.project_collaborators.id; + +CREATE TABLE public.project_repositories ( + project_id integer NOT NULL, + abs_path character varying, + id bigint NOT NULL, + legacy_worktree_id bigint, + entry_ids character varying, + branch character varying, + scan_id bigint NOT NULL, + is_deleted boolean NOT NULL, + current_merge_conflicts character varying, + branch_summary character varying, + head_commit_details character varying, + merge_message character varying +); + +CREATE TABLE public.project_repository_statuses ( + project_id integer NOT NULL, + repository_id bigint NOT NULL, + repo_path character varying NOT NULL, + status bigint NOT NULL, + status_kind integer NOT NULL, + first_status integer, + second_status integer, + scan_id bigint NOT NULL, + is_deleted boolean NOT NULL +); + +CREATE TABLE public.projects ( + id integer NOT NULL, + host_user_id integer, + unregistered boolean DEFAULT false NOT NULL, + room_id integer, + host_connection_id integer, + host_connection_server_id integer, + windows_paths boolean DEFAULT false +); + +CREATE SEQUENCE public.projects_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.projects_id_seq OWNED BY public.projects.id; + +CREATE TABLE public.room_participants ( + id integer NOT NULL, + room_id integer NOT NULL, + user_id integer NOT NULL, + answering_connection_id integer, + location_kind integer, + location_project_id integer, + initial_project_id integer, + calling_user_id integer NOT NULL, + calling_connection_id integer NOT NULL, + answering_connection_lost boolean DEFAULT false NOT NULL, + answering_connection_server_id integer, + calling_connection_server_id integer, + participant_index integer, + role text +); + +CREATE SEQUENCE public.room_participants_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.room_participants_id_seq OWNED BY public.room_participants.id; + +CREATE TABLE public.rooms ( + id integer NOT NULL, + live_kit_room character varying NOT NULL, + channel_id integer +); + +CREATE SEQUENCE public.rooms_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.rooms_id_seq OWNED BY public.rooms.id; + +CREATE TABLE public.servers ( + id integer NOT NULL, + environment character varying NOT NULL +); + +CREATE SEQUENCE public.servers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.servers_id_seq OWNED BY public.servers.id; + +CREATE TABLE public.user_features ( + user_id integer NOT NULL, + feature_id integer NOT NULL +); + +CREATE TABLE public.users ( + id integer NOT NULL, + github_login character varying, + admin boolean NOT NULL, + email_address character varying(255) DEFAULT NULL::character varying, + invite_code character varying(64), + invite_count integer DEFAULT 0 NOT NULL, + inviter_id integer, + connected_once boolean DEFAULT false NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + github_user_id integer NOT NULL, + metrics_id uuid DEFAULT gen_random_uuid() NOT NULL, + accepted_tos_at timestamp without time zone, + github_user_created_at timestamp without time zone, + custom_llm_monthly_allowance_in_cents integer, + name text +); + +CREATE SEQUENCE public.users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + +CREATE TABLE public.worktree_diagnostic_summaries ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + path character varying NOT NULL, + language_server_id bigint NOT NULL, + error_count integer NOT NULL, + warning_count integer NOT NULL +); + +CREATE TABLE public.worktree_entries ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + id bigint NOT NULL, + is_dir boolean NOT NULL, + path character varying NOT NULL, + inode bigint NOT NULL, + mtime_seconds bigint NOT NULL, + mtime_nanos integer NOT NULL, + is_symlink boolean DEFAULT false NOT NULL, + is_ignored boolean NOT NULL, + scan_id bigint, + is_deleted boolean, + git_status bigint, + is_external boolean DEFAULT false NOT NULL, + is_fifo boolean DEFAULT false NOT NULL, + canonical_path text, + is_hidden boolean DEFAULT false NOT NULL +); + +CREATE TABLE public.worktree_settings_files ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + path character varying NOT NULL, + content text NOT NULL, + kind character varying +); + +CREATE TABLE public.worktrees ( + project_id integer NOT NULL, + id bigint NOT NULL, + root_name character varying NOT NULL, + abs_path character varying NOT NULL, + visible boolean NOT NULL, + scan_id bigint NOT NULL, + is_complete boolean DEFAULT false NOT NULL, + completed_scan_id bigint +); + +ALTER TABLE ONLY public.access_tokens ALTER COLUMN id SET DEFAULT nextval('public.access_tokens_id_seq'::regclass); + +ALTER TABLE ONLY public.breakpoints ALTER COLUMN id SET DEFAULT nextval('public.breakpoints_id_seq'::regclass); + +ALTER TABLE ONLY public.buffers ALTER COLUMN id SET DEFAULT nextval('public.buffers_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_buffer_collaborators ALTER COLUMN id SET DEFAULT nextval('public.channel_buffer_collaborators_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_chat_participants ALTER COLUMN id SET DEFAULT nextval('public.channel_chat_participants_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_members ALTER COLUMN id SET DEFAULT nextval('public.channel_members_id_seq'::regclass); + +ALTER TABLE ONLY public.channels ALTER COLUMN id SET DEFAULT nextval('public.channels_id_seq'::regclass); + +ALTER TABLE ONLY public.contacts ALTER COLUMN id SET DEFAULT nextval('public.contacts_id_seq'::regclass); + +ALTER TABLE ONLY public.extensions ALTER COLUMN id SET DEFAULT nextval('public.extensions_id_seq'::regclass); + +ALTER TABLE ONLY public.feature_flags ALTER COLUMN id SET DEFAULT nextval('public.feature_flags_id_seq'::regclass); + +ALTER TABLE ONLY public.followers ALTER COLUMN id SET DEFAULT nextval('public.followers_id_seq'::regclass); + +ALTER TABLE ONLY public.notification_kinds ALTER COLUMN id SET DEFAULT nextval('public.notification_kinds_id_seq'::regclass); + +ALTER TABLE ONLY public.notifications ALTER COLUMN id SET DEFAULT nextval('public.notifications_id_seq'::regclass); + +ALTER TABLE ONLY public.project_collaborators ALTER COLUMN id SET DEFAULT nextval('public.project_collaborators_id_seq'::regclass); + +ALTER TABLE ONLY public.projects ALTER COLUMN id SET DEFAULT nextval('public.projects_id_seq'::regclass); + +ALTER TABLE ONLY public.room_participants ALTER COLUMN id SET DEFAULT nextval('public.room_participants_id_seq'::regclass); + +ALTER TABLE ONLY public.rooms ALTER COLUMN id SET DEFAULT nextval('public.rooms_id_seq'::regclass); + +ALTER TABLE ONLY public.servers ALTER COLUMN id SET DEFAULT nextval('public.servers_id_seq'::regclass); + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + +ALTER TABLE ONLY public.access_tokens + ADD CONSTRAINT access_tokens_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.breakpoints + ADD CONSTRAINT breakpoints_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.buffer_operations + ADD CONSTRAINT buffer_operations_pkey PRIMARY KEY (buffer_id, epoch, lamport_timestamp, replica_id); + +ALTER TABLE ONLY public.buffer_snapshots + ADD CONSTRAINT buffer_snapshots_pkey PRIMARY KEY (buffer_id, epoch); + +ALTER TABLE ONLY public.buffers + ADD CONSTRAINT buffers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channels + ADD CONSTRAINT channels_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.contributors + ADD CONSTRAINT contributors_pkey PRIMARY KEY (user_id); + +ALTER TABLE ONLY public.extension_versions + ADD CONSTRAINT extension_versions_pkey PRIMARY KEY (extension_id, version); + +ALTER TABLE ONLY public.extensions + ADD CONSTRAINT extensions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.feature_flags + ADD CONSTRAINT feature_flags_flag_key UNIQUE (flag); + +ALTER TABLE ONLY public.feature_flags + ADD CONSTRAINT feature_flags_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.language_servers + ADD CONSTRAINT language_servers_pkey PRIMARY KEY (project_id, id); + +ALTER TABLE ONLY public.notification_kinds + ADD CONSTRAINT notification_kinds_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_pkey PRIMARY KEY (user_id, buffer_id); + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.project_repositories + ADD CONSTRAINT project_repositories_pkey PRIMARY KEY (project_id, id); + +ALTER TABLE ONLY public.project_repository_statuses + ADD CONSTRAINT project_repository_statuses_pkey PRIMARY KEY (project_id, repository_id, repo_path); + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.rooms + ADD CONSTRAINT rooms_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.servers + ADD CONSTRAINT servers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_pkey PRIMARY KEY (user_id, feature_id); + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.worktree_diagnostic_summaries + ADD CONSTRAINT worktree_diagnostic_summaries_pkey PRIMARY KEY (project_id, worktree_id, path); + +ALTER TABLE ONLY public.worktree_entries + ADD CONSTRAINT worktree_entries_pkey PRIMARY KEY (project_id, worktree_id, id); + +ALTER TABLE ONLY public.worktree_settings_files + ADD CONSTRAINT worktree_settings_files_pkey PRIMARY KEY (project_id, worktree_id, path); + +ALTER TABLE ONLY public.worktrees + ADD CONSTRAINT worktrees_pkey PRIMARY KEY (project_id, id); + +CREATE INDEX index_access_tokens_user_id ON public.access_tokens USING btree (user_id); + +CREATE INDEX index_breakpoints_on_project_id ON public.breakpoints USING btree (project_id); + +CREATE INDEX index_buffers_on_channel_id ON public.buffers USING btree (channel_id); + +CREATE INDEX index_channel_buffer_collaborators_on_channel_id ON public.channel_buffer_collaborators USING btree (channel_id); + +CREATE UNIQUE INDEX index_channel_buffer_collaborators_on_channel_id_and_replica_id ON public.channel_buffer_collaborators USING btree (channel_id, replica_id); + +CREATE UNIQUE INDEX index_channel_buffer_collaborators_on_channel_id_connection_id_ ON public.channel_buffer_collaborators USING btree (channel_id, connection_id, connection_server_id); + +CREATE INDEX index_channel_buffer_collaborators_on_connection_id ON public.channel_buffer_collaborators USING btree (connection_id); + +CREATE INDEX index_channel_buffer_collaborators_on_connection_server_id ON public.channel_buffer_collaborators USING btree (connection_server_id); + +CREATE INDEX index_channel_chat_participants_on_channel_id ON public.channel_chat_participants USING btree (channel_id); + +CREATE UNIQUE INDEX index_channel_members_on_channel_id_and_user_id ON public.channel_members USING btree (channel_id, user_id); + +CREATE INDEX index_channels_on_parent_path ON public.channels USING btree (parent_path text_pattern_ops); + +CREATE INDEX index_channels_on_parent_path_and_order ON public.channels USING btree (parent_path, channel_order); + +CREATE INDEX index_contacts_user_id_b ON public.contacts USING btree (user_id_b); + +CREATE UNIQUE INDEX index_contacts_user_ids ON public.contacts USING btree (user_id_a, user_id_b); + +CREATE UNIQUE INDEX index_extensions_external_id ON public.extensions USING btree (external_id); + +CREATE INDEX index_extensions_total_download_count ON public.extensions USING btree (total_download_count); + +CREATE UNIQUE INDEX index_feature_flags ON public.feature_flags USING btree (id); + +CREATE UNIQUE INDEX index_followers_on_project_id_and_leader_connection_server_id_a ON public.followers USING btree (project_id, leader_connection_server_id, leader_connection_id, follower_connection_server_id, follower_connection_id); + +CREATE INDEX index_followers_on_room_id ON public.followers USING btree (room_id); + +CREATE UNIQUE INDEX index_invite_code_users ON public.users USING btree (invite_code); + +CREATE INDEX index_language_servers_on_project_id ON public.language_servers USING btree (project_id); + +CREATE UNIQUE INDEX index_notification_kinds_on_name ON public.notification_kinds USING btree (name); + +CREATE INDEX index_notifications_on_recipient_id_is_read_kind_entity_id ON public.notifications USING btree (recipient_id, is_read, kind, entity_id); + +CREATE UNIQUE INDEX index_observed_buffer_user_and_buffer_id ON public.observed_buffer_edits USING btree (user_id, buffer_id); + +CREATE INDEX index_project_collaborators_on_connection_id ON public.project_collaborators USING btree (connection_id); + +CREATE INDEX index_project_collaborators_on_connection_server_id ON public.project_collaborators USING btree (connection_server_id); + +CREATE INDEX index_project_collaborators_on_project_id ON public.project_collaborators USING btree (project_id); + +CREATE UNIQUE INDEX index_project_collaborators_on_project_id_and_replica_id ON public.project_collaborators USING btree (project_id, replica_id); + +CREATE UNIQUE INDEX index_project_collaborators_on_project_id_connection_id_and_ser ON public.project_collaborators USING btree (project_id, connection_id, connection_server_id); + +CREATE INDEX index_project_repos_statuses_on_project_id ON public.project_repository_statuses USING btree (project_id); + +CREATE INDEX index_project_repos_statuses_on_project_id_and_repo_id ON public.project_repository_statuses USING btree (project_id, repository_id); + +CREATE INDEX index_project_repositories_on_project_id ON public.project_repositories USING btree (project_id); + +CREATE INDEX index_projects_on_host_connection_id_and_host_connection_server ON public.projects USING btree (host_connection_id, host_connection_server_id); + +CREATE INDEX index_projects_on_host_connection_server_id ON public.projects USING btree (host_connection_server_id); + +CREATE INDEX index_room_participants_on_answering_connection_id ON public.room_participants USING btree (answering_connection_id); + +CREATE UNIQUE INDEX index_room_participants_on_answering_connection_id_and_answerin ON public.room_participants USING btree (answering_connection_id, answering_connection_server_id); + +CREATE INDEX index_room_participants_on_answering_connection_server_id ON public.room_participants USING btree (answering_connection_server_id); + +CREATE INDEX index_room_participants_on_calling_connection_server_id ON public.room_participants USING btree (calling_connection_server_id); + +CREATE INDEX index_room_participants_on_room_id ON public.room_participants USING btree (room_id); + +CREATE UNIQUE INDEX index_room_participants_on_user_id ON public.room_participants USING btree (user_id); + +CREATE UNIQUE INDEX index_rooms_on_channel_id ON public.rooms USING btree (channel_id); + +CREATE INDEX index_settings_files_on_project_id ON public.worktree_settings_files USING btree (project_id); + +CREATE INDEX index_settings_files_on_project_id_and_wt_id ON public.worktree_settings_files USING btree (project_id, worktree_id); + +CREATE INDEX index_user_features_on_feature_id ON public.user_features USING btree (feature_id); + +CREATE INDEX index_user_features_on_user_id ON public.user_features USING btree (user_id); + +CREATE UNIQUE INDEX index_user_features_user_id_and_feature_id ON public.user_features USING btree (user_id, feature_id); + +CREATE UNIQUE INDEX index_users_github_login ON public.users USING btree (github_login); + +CREATE INDEX index_users_on_email_address ON public.users USING btree (email_address); + +CREATE INDEX index_worktree_diagnostic_summaries_on_project_id ON public.worktree_diagnostic_summaries USING btree (project_id); + +CREATE INDEX index_worktree_diagnostic_summaries_on_project_id_and_worktree_ ON public.worktree_diagnostic_summaries USING btree (project_id, worktree_id); + +CREATE INDEX index_worktree_entries_on_project_id ON public.worktree_entries USING btree (project_id); + +CREATE INDEX index_worktree_entries_on_project_id_and_worktree_id ON public.worktree_entries USING btree (project_id, worktree_id); + +CREATE INDEX index_worktrees_on_project_id ON public.worktrees USING btree (project_id); + +CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name public.gin_trgm_ops); + +CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops); + +CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text)); + +CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id); + +ALTER TABLE ONLY public.access_tokens + ADD CONSTRAINT access_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.breakpoints + ADD CONSTRAINT breakpoints_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffer_operations + ADD CONSTRAINT buffer_operations_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffer_snapshots + ADD CONSTRAINT buffer_snapshots_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffers + ADD CONSTRAINT buffers_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_user_id_a_fkey FOREIGN KEY (user_id_a) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contributors + ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.extension_versions + ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id); + +ALTER TABLE ONLY public.project_repositories + ADD CONSTRAINT fk_project_repositories_project_id FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_repository_statuses + ADD CONSTRAINT fk_project_repository_statuses_project_id FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_follower_connection_server_id_fkey FOREIGN KEY (follower_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_leader_connection_server_id_fkey FOREIGN KEY (leader_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.language_servers + ADD CONSTRAINT language_servers_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_kind_fkey FOREIGN KEY (kind) REFERENCES public.notification_kinds(id); + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_recipient_id_fkey FOREIGN KEY (recipient_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_host_connection_server_id_fkey FOREIGN KEY (host_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_host_user_id_fkey FOREIGN KEY (host_user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_answering_connection_server_id_fkey FOREIGN KEY (answering_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_calling_connection_server_id_fkey FOREIGN KEY (calling_connection_server_id) REFERENCES public.servers(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_calling_user_id_fkey FOREIGN KEY (calling_user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.rooms + ADD CONSTRAINT rooms_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_feature_id_fkey FOREIGN KEY (feature_id) REFERENCES public.feature_flags(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_inviter_id_fkey FOREIGN KEY (inviter_id) REFERENCES public.users(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.worktree_diagnostic_summaries + ADD CONSTRAINT worktree_diagnostic_summaries_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktree_entries + ADD CONSTRAINT worktree_entries_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktree_settings_files + ADD CONSTRAINT worktree_settings_files_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktrees + ADD CONSTRAINT worktrees_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; diff --git a/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql b/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql deleted file mode 100644 index b81ab7567f22ae750c9aad6357cbf995a75c47c4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql +++ /dev/null @@ -1,19 +0,0 @@ -create table if not exists providers ( - id serial primary key, - name text not null -); - -create unique index uix_providers_on_name on providers (name); - -create table if not exists models ( - id serial primary key, - provider_id integer not null references providers (id) on delete cascade, - name text not null, - max_requests_per_minute integer not null, - max_tokens_per_minute integer not null, - max_tokens_per_day integer not null -); - -create unique index uix_models_on_provider_id_name on models (provider_id, name); -create index ix_models_on_provider_id on models (provider_id); -create index ix_models_on_name on models (name); diff --git a/crates/collab/migrations_llm/20240806213401_create_usages.sql b/crates/collab/migrations_llm/20240806213401_create_usages.sql deleted file mode 100644 index da2245d4b9ac20fb6dddcb892221d1ff773cd156..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240806213401_create_usages.sql +++ /dev/null @@ -1,19 +0,0 @@ -create table usage_measures ( - id serial primary key, - name text not null -); - -create unique index uix_usage_measures_on_name on usage_measures (name); - -create table if not exists usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - measure_id integer not null references usage_measures (id) on delete cascade, - timestamp timestamp without time zone not null, - buckets bigint[] not null -); - -create index ix_usages_on_user_id on usages (user_id); -create index ix_usages_on_model_id on usages (model_id); -create unique index uix_usages_on_user_id_model_id_measure_id on usages (user_id, model_id, measure_id); diff --git a/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql b/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql deleted file mode 100644 index f1def8209a742743a6c708365afc05f7ab911e18..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE models - ALTER COLUMN max_requests_per_minute TYPE bigint, - ALTER COLUMN max_tokens_per_minute TYPE bigint, - ALTER COLUMN max_tokens_per_day TYPE bigint; diff --git a/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql b/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql deleted file mode 100644 index d9ffe2f9f29c10acc9aaa6dc48389f92298c404f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE models - ADD COLUMN price_per_million_input_tokens integer NOT NULL DEFAULT 0, - ADD COLUMN price_per_million_output_tokens integer NOT NULL DEFAULT 0; diff --git a/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql b/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql deleted file mode 100644 index a50feb2e3f41cd8e25265049a09140bdb04ea0d3..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql +++ /dev/null @@ -1 +0,0 @@ -alter table usages add column is_staff boolean not null default false; diff --git a/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql b/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql deleted file mode 100644 index 42047433e564d0f463a11f119ab4950d1d2d1254..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql +++ /dev/null @@ -1,9 +0,0 @@ -create table lifetime_usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - input_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create unique index uix_lifetime_usages_on_user_id_model_id on lifetime_usages (user_id, model_id); diff --git a/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql b/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql deleted file mode 100644 index c30e58a6dd8bccd882a97b55b91e190950890aac..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql +++ /dev/null @@ -1,7 +0,0 @@ -create table revoked_access_tokens ( - id serial primary key, - jti text not null, - revoked_at timestamp without time zone not null default now() -); - -create unique index uix_revoked_access_tokens_on_jti on revoked_access_tokens (jti); diff --git a/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql b/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql deleted file mode 100644 index 855e46ab0224dc4e20e0fb8634a3e678d584433e..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql +++ /dev/null @@ -1,11 +0,0 @@ -alter table models - add column price_per_million_cache_creation_input_tokens integer not null default 0, - add column price_per_million_cache_read_input_tokens integer not null default 0; - -alter table usages - add column cache_creation_input_tokens_this_month bigint not null default 0, - add column cache_read_input_tokens_this_month bigint not null default 0; - -alter table lifetime_usages - add column cache_creation_input_tokens bigint not null default 0, - add column cache_read_input_tokens bigint not null default 0; diff --git a/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql b/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql deleted file mode 100644 index c204451b7538d8fefb41e3f2962433b552b91229..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table usages - drop column cache_creation_input_tokens_this_month, - drop column cache_read_input_tokens_this_month; diff --git a/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql b/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql deleted file mode 100644 index 2733552a3a16f2754ba1c191e42ab4548f67848c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql +++ /dev/null @@ -1,13 +0,0 @@ -create table monthly_usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - month integer not null, - year integer not null, - input_tokens bigint not null default 0, - cache_creation_input_tokens bigint not null default 0, - cache_read_input_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create unique index uix_monthly_usages_on_user_id_model_id_month_year on monthly_usages (user_id, model_id, month, year); diff --git a/crates/collab/migrations_llm/20241010151249_create_billing_events.sql b/crates/collab/migrations_llm/20241010151249_create_billing_events.sql deleted file mode 100644 index 74a270872e5f664096d90179359e78a5a2298812..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20241010151249_create_billing_events.sql +++ /dev/null @@ -1,12 +0,0 @@ -create table billing_events ( - id serial primary key, - idempotency_key uuid not null default gen_random_uuid(), - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - input_tokens bigint not null default 0, - input_cache_creation_tokens bigint not null default 0, - input_cache_read_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create index uix_billing_events_on_user_id_model_id on billing_events (user_id, model_id); diff --git a/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql b/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql deleted file mode 100644 index e5c50d8385745c602f01b2bb7daa9ba8d3580eaa..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table models - add column max_input_tokens_per_minute bigint not null default 0, - add column max_output_tokens_per_minute bigint not null default 0; diff --git a/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql b/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql deleted file mode 100644 index b3873710580b39cdb9c8fe8a2bb176839b5163b4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql +++ /dev/null @@ -1,10 +0,0 @@ -create table subscription_usages ( - id serial primary key, - user_id integer not null, - period_start_at timestamp without time zone not null, - period_end_at timestamp without time zone not null, - model_requests int not null default 0, - edit_predictions int not null default 0 -); - -create unique index uix_subscription_usages_on_user_id_start_at_end_at on subscription_usages (user_id, period_start_at, period_end_at); diff --git a/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql b/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql deleted file mode 100644 index 8d54c8b87ca820bd8aa46c7bf18ccd50ccf52807..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table subscription_usages - add column plan text not null; - -create index ix_subscription_usages_on_plan on subscription_usages (plan); diff --git a/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql b/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql deleted file mode 100644 index ded918e18385dbfe9f916bc0bdb49a3a6bde153f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table subscription_usage_meters ( - id serial primary key, - subscription_usage_id integer not null references subscription_usages (id) on delete cascade, - model_id integer not null references models (id) on delete cascade, - requests integer not null default 0 -); - -create unique index uix_subscription_usage_meters_on_subscription_usage_model on subscription_usage_meters (subscription_usage_id, model_id); diff --git a/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql b/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql deleted file mode 100644 index 9d63e299f56e0475a4016b2a69a0779f09cc8b4d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql +++ /dev/null @@ -1,6 +0,0 @@ -alter table subscription_usage_meters - add column mode text not null default 'normal'; - -drop index uix_subscription_usage_meters_on_subscription_usage_model; - -create unique index uix_subscription_usage_meters_on_subscription_usage_model_mode on subscription_usage_meters (subscription_usage_id, model_id, mode); diff --git a/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql b/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql deleted file mode 100644 index 59169d3c3ec722fd7dfcdde97f1ed3f34e5c51fd..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql +++ /dev/null @@ -1,23 +0,0 @@ -create table subscription_usages_v2 ( - id uuid primary key, - user_id integer not null, - period_start_at timestamp without time zone not null, - period_end_at timestamp without time zone not null, - plan text not null, - model_requests int not null default 0, - edit_predictions int not null default 0 -); - -create unique index uix_subscription_usages_v2_on_user_id_start_at_end_at on subscription_usages_v2 (user_id, period_start_at, period_end_at); - -create index ix_subscription_usages_v2_on_plan on subscription_usages_v2 (plan); - -create table subscription_usage_meters_v2 ( - id uuid primary key, - subscription_usage_id uuid not null references subscription_usages_v2 (id) on delete cascade, - model_id integer not null references models (id) on delete cascade, - mode text not null, - requests integer not null default 0 -); - -create unique index uix_subscription_usage_meters_v2_on_usage_model_mode on subscription_usage_meters_v2 (subscription_usage_id, model_id, mode); diff --git a/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql b/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql deleted file mode 100644 index f06b152d7ba9a38419facb18fd620899a63b083d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql +++ /dev/null @@ -1,2 +0,0 @@ -drop table subscription_usage_meters; -drop table subscription_usages; diff --git a/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql b/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql deleted file mode 100644 index 5f03f50d0b3e17acf3aabd433df9ef317172039a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql +++ /dev/null @@ -1,2 +0,0 @@ -drop table monthly_usages; -drop table lifetime_usages; diff --git a/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql b/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql deleted file mode 100644 index 36b79266b6adc448e99d6bb3fa1c88b9ee9604f5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql +++ /dev/null @@ -1 +0,0 @@ -drop table billing_events; diff --git a/crates/collab/src/api/contributors.rs b/crates/collab/src/api/contributors.rs index 8cfef0ad7e717614e23c3cf9d04852c976f1f55f..ce318b15295ebe5c777597a6d3c6106e57af8e05 100644 --- a/crates/collab/src/api/contributors.rs +++ b/crates/collab/src/api/contributors.rs @@ -54,6 +54,26 @@ async fn check_is_contributor( ) -> Result> { let params = params.into_contributor_selector()?; + if CopilotSweAgentBot::is_copilot_bot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + CopilotSweAgentBot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + + if Dependabot::is_dependabot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + Dependabot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + if RenovateBot::is_renovate_bot(¶ms) { return Ok(Json(CheckIsContributorResponse { signed_at: Some( @@ -64,6 +84,16 @@ async fn check_is_contributor( })); } + if ZedZippyBot::is_zed_zippy_bot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + ZedZippyBot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + Ok(Json(CheckIsContributorResponse { signed_at: app .db @@ -73,6 +103,71 @@ async fn check_is_contributor( })) } +/// The Copilot bot GitHub user (`copilot-swe-agent[bot]`). +/// +/// https://api.github.com/users/copilot-swe-agent[bot] +struct CopilotSweAgentBot; + +impl CopilotSweAgentBot { + const LOGIN: &'static str = "copilot-swe-agent[bot]"; + const USER_ID: i32 = 198982749; + /// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot + /// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases. + const NAME_ALIAS: &'static str = "copilot"; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2025-02-12T20:26:08Z") + .expect("failed to parse 'created_at' for 'copilot-swe-agent[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Copilot bot user. + fn is_copilot_bot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => { + github_login == Self::LOGIN || github_login == Self::NAME_ALIAS + } + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + +/// The Dependabot bot GitHub user (`dependabot[bot]`). +/// +/// https://api.github.com/users/dependabot[bot] +struct Dependabot; + +impl Dependabot { + const LOGIN: &'static str = "dependabot[bot]"; + const USER_ID: i32 = 49699333; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2019-04-16T22:34:25Z") + .expect("failed to parse 'created_at' for 'dependabot[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Dependabot bot user. + fn is_dependabot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + /// The Renovate bot GitHub user (`renovate[bot]`). /// /// https://api.github.com/users/renovate[bot] @@ -103,6 +198,36 @@ impl RenovateBot { } } +/// The Zed Zippy bot GitHub user (`zed-zippy[bot]`). +/// +/// https://api.github.com/users/zed-zippy[bot] +struct ZedZippyBot; + +impl ZedZippyBot { + const LOGIN: &'static str = "zed-zippy[bot]"; + const USER_ID: i32 = 234243425; + + /// Returns the `created_at` timestamp for the Zed Zippy bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2025-09-24T17:00:11Z") + .expect("failed to parse 'created_at' for 'zed-zippy[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Zed Zippy bot user. + fn is_zed_zippy_bot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + #[derive(Debug, Deserialize)] struct AddContributorBody { github_user_id: i32, diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 51a0ef83323ec70675283d2fdec7ca1ad791b12d..6f1d8b884d15041eadaa9073a5bd99e5ed352502 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -362,6 +362,8 @@ impl Database { entry_ids: ActiveValue::set("[]".into()), head_commit_details: ActiveValue::set(None), merge_message: ActiveValue::set(None), + remote_upstream_url: ActiveValue::set(None), + remote_origin_url: ActiveValue::set(None), } }), ) @@ -511,6 +513,8 @@ impl Database { serde_json::to_string(&update.current_merge_conflicts).unwrap(), )), merge_message: ActiveValue::set(update.merge_message.clone()), + remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()), + remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()), }) .on_conflict( OnConflict::columns([ @@ -1005,6 +1009,8 @@ impl Database { is_last_update: true, merge_message: db_repository_entry.merge_message, stash_entries: Vec::new(), + remote_upstream_url: db_repository_entry.remote_upstream_url.clone(), + remote_origin_url: db_repository_entry.remote_origin_url.clone(), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index f020b99b5f1030cfe9391498512258e6db249bac..eafb5cac44a510bf4ced0434a9b4adfadff0ebbc 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -796,6 +796,8 @@ impl Database { is_last_update: true, merge_message: db_repository.merge_message, stash_entries: Vec::new(), + remote_upstream_url: db_repository.remote_upstream_url.clone(), + remote_origin_url: db_repository.remote_origin_url.clone(), }); } } diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index 0220955824af30f489afe32f9695af3dbb52cdc9..c179539e4bbc07fe070f3089f912b4c0b4fbf167 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -22,7 +22,6 @@ pub mod project_repository_statuses; pub mod room; pub mod room_participant; pub mod server; -pub mod signup; pub mod user; pub mod worktree; pub mod worktree_diagnostic_summary; diff --git a/crates/collab/src/db/tables/project_repository.rs b/crates/collab/src/db/tables/project_repository.rs index eb653ecee37d48ce79e26450eb85d87dec411c1e..190ae8d79c54bb78daef4a1568ec75683eb0b0f2 100644 --- a/crates/collab/src/db/tables/project_repository.rs +++ b/crates/collab/src/db/tables/project_repository.rs @@ -22,6 +22,8 @@ pub struct Model { pub branch_summary: Option, // A JSON object representing the current Head commit values pub head_commit_details: Option, + pub remote_upstream_url: Option, + pub remote_origin_url: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/db/tables/signup.rs b/crates/collab/src/db/tables/signup.rs deleted file mode 100644 index 79d9f0580c13f981daf30283b1a9e8902ea0b995..0000000000000000000000000000000000000000 --- a/crates/collab/src/db/tables/signup.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::db::{SignupId, UserId}; -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "signups")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: SignupId, - pub email_address: String, - pub email_confirmation_code: String, - pub email_confirmation_sent: bool, - pub created_at: DateTime, - pub device_id: Option, - pub user_id: Option, - pub inviting_user_id: Option, - pub platform_mac: bool, - pub platform_linux: bool, - pub platform_windows: bool, - pub platform_unknown: bool, - pub editor_features: Option>, - pub programming_languages: Option>, - pub added_to_mailing_list: bool, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 7aed2ebc2dd16f31cde4116a70377b40b1cb8b2f..10a32691ed36b4db9502c63bd510df6ffe1fe5b6 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -3,22 +3,21 @@ mod channel_tests; mod contributor_tests; mod db_tests; mod extension_tests; +mod migrations; -use crate::migrations::run_database_migrations; +use std::sync::Arc; +use std::sync::atomic::{AtomicI32, Ordering::SeqCst}; +use std::time::Duration; -use super::*; use gpui::BackgroundExecutor; use parking_lot::Mutex; use rand::prelude::*; use sea_orm::ConnectionTrait; use sqlx::migrate::MigrateDatabase; -use std::{ - sync::{ - Arc, - atomic::{AtomicI32, Ordering::SeqCst}, - }, - time::Duration, -}; + +use self::migrations::run_database_migrations; + +use super::*; pub struct TestDb { pub db: Option>, diff --git a/crates/collab/src/migrations.rs b/crates/collab/src/db/tests/migrations.rs similarity index 100% rename from crates/collab/src/migrations.rs rename to crates/collab/src/db/tests/migrations.rs diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 14573e94b0b535b1644510e28dfc906b1a2c420e..08f7e61c020ca9ea23be62636e381f9abedf7cf0 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -3,8 +3,6 @@ pub mod auth; pub mod db; pub mod env; pub mod executor; -pub mod llm; -pub mod migrations; pub mod rpc; pub mod seed; diff --git a/crates/collab/src/llm.rs b/crates/collab/src/llm.rs deleted file mode 100644 index dec10232bdb000acef9def25cad519ceb213956b..0000000000000000000000000000000000000000 --- a/crates/collab/src/llm.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod db; diff --git a/crates/collab/src/llm/db.rs b/crates/collab/src/llm/db.rs deleted file mode 100644 index b15d5a42b5f183831b34552beba3f616d3a7c3f0..0000000000000000000000000000000000000000 --- a/crates/collab/src/llm/db.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::future::Future; -use std::sync::Arc; - -use anyhow::Context; -pub use sea_orm::ConnectOptions; -use sea_orm::{DatabaseConnection, DatabaseTransaction, IsolationLevel, TransactionTrait}; - -use crate::Result; -use crate::db::TransactionHandle; -use crate::executor::Executor; - -/// The database for the LLM service. -pub struct LlmDatabase { - options: ConnectOptions, - pool: DatabaseConnection, - #[allow(unused)] - executor: Executor, - #[cfg(test)] - runtime: Option, -} - -impl LlmDatabase { - /// Connects to the database with the given options - pub async fn new(options: ConnectOptions, executor: Executor) -> Result { - sqlx::any::install_default_drivers(); - Ok(Self { - options: options.clone(), - pool: sea_orm::Database::connect(options).await?, - executor, - #[cfg(test)] - runtime: None, - }) - } - - pub fn options(&self) -> &ConnectOptions { - &self.options - } - - pub async fn transaction(&self, f: F) -> Result - where - F: Send + Fn(TransactionHandle) -> Fut, - Fut: Send + Future>, - { - let body = async { - let (tx, result) = self.with_transaction(&f).await?; - match result { - Ok(result) => match tx.commit().await.map_err(Into::into) { - Ok(()) => Ok(result), - Err(error) => Err(error), - }, - Err(error) => { - tx.rollback().await?; - Err(error) - } - } - }; - - self.run(body).await - } - - async fn with_transaction(&self, f: &F) -> Result<(DatabaseTransaction, Result)> - where - F: Send + Fn(TransactionHandle) -> Fut, - Fut: Send + Future>, - { - let tx = self - .pool - .begin_with_config(Some(IsolationLevel::ReadCommitted), None) - .await?; - - let mut tx = Arc::new(Some(tx)); - let result = f(TransactionHandle(tx.clone())).await; - let tx = Arc::get_mut(&mut tx) - .and_then(|tx| tx.take()) - .context("couldn't complete transaction because it's still in use")?; - - Ok((tx, result)) - } - - async fn run(&self, future: F) -> Result - where - F: Future>, - { - #[cfg(test)] - { - if let Executor::Deterministic(executor) = &self.executor { - executor.simulate_random_delay().await; - } - - self.runtime.as_ref().unwrap().block_on(future) - } - - #[cfg(not(test))] - { - future.await - } - } -} diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 08047c56e55c016f3fd2b34d0935fb33a61b5dad..030158c94d640ef8a9024a8b783685bac7d0dcdb 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -1,4 +1,4 @@ -use anyhow::{Context as _, anyhow}; +use anyhow::anyhow; use axum::headers::HeaderMapExt; use axum::{ Extension, Router, @@ -9,8 +9,6 @@ use axum::{ use collab::ServiceMode; use collab::api::CloudflareIpCountryHeader; -use collab::llm::db::LlmDatabase; -use collab::migrations::run_database_migrations; use collab::{ AppState, Config, Result, api::fetch_extensions_from_blob_store_periodically, db, env, executor::Executor, @@ -19,7 +17,6 @@ use db::Database; use std::{ env::args, net::{SocketAddr, TcpListener}, - path::Path, sync::Arc, time::Duration, }; @@ -49,10 +46,6 @@ async fn main() -> Result<()> { Some("version") => { println!("collab v{} ({})", VERSION, REVISION.unwrap_or("unknown")); } - Some("migrate") => { - let config = envy::from_env::().expect("error loading config"); - setup_app_database(&config).await?; - } Some("seed") => { let config = envy::from_env::().expect("error loading config"); let db_options = db::ConnectOptions::new(config.database_url.clone()); @@ -69,7 +62,7 @@ async fn main() -> Result<()> { Some("all") => ServiceMode::All, _ => { return Err(anyhow!( - "usage: collab >" + "usage: collab >" ))?; } }; @@ -90,7 +83,6 @@ async fn main() -> Result<()> { if mode.is_collab() || mode.is_api() { setup_app_database(&config).await?; - setup_llm_database(&config).await?; let state = AppState::new(config, Executor::Production).await?; @@ -211,25 +203,6 @@ async fn setup_app_database(config: &Config) -> Result<()> { let db_options = db::ConnectOptions::new(config.database_url.clone()); let mut db = Database::new(db_options).await?; - let migrations_path = config.migrations_path.as_deref().unwrap_or_else(|| { - #[cfg(feature = "sqlite")] - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations.sqlite"); - #[cfg(not(feature = "sqlite"))] - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); - - Path::new(default_migrations) - }); - - let migrations = run_database_migrations(db.options(), migrations_path).await?; - for (migration, duration) in migrations { - log::info!( - "Migrated {} {} {:?}", - migration.version, - migration.description, - duration - ); - } - db.initialize_notification_kinds().await?; if config.seed_path.is_some() { @@ -239,37 +212,6 @@ async fn setup_app_database(config: &Config) -> Result<()> { Ok(()) } -async fn setup_llm_database(config: &Config) -> Result<()> { - let database_url = config - .llm_database_url - .as_ref() - .context("missing LLM_DATABASE_URL")?; - - let db_options = db::ConnectOptions::new(database_url.clone()); - let db = LlmDatabase::new(db_options, Executor::Production).await?; - - let migrations_path = config - .llm_database_migrations_path - .as_deref() - .unwrap_or_else(|| { - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations_llm"); - - Path::new(default_migrations) - }); - - let migrations = run_database_migrations(db.options(), migrations_path).await?; - for (migration, duration) in migrations { - log::info!( - "Migrated {} {} {:?}", - migration.version, - migration.description, - duration - ); - } - - Ok(()) -} - async fn handle_root(Extension(mode): Extension) -> String { format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown")) } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index aa77ba25bfb687b6c5cb0da84e14c843f8a2a3bc..9511087af8887a3c799357d06050ce48431b38a6 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -469,6 +469,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 7d07360b8042ed54a9f19a82a2876e448e8a14a4..3785ee0b7abaeddeac5c9acb1718407ab5bd54f2 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use call::Room; use client::ChannelId; use gpui::{Entity, TestAppContext}; @@ -18,7 +16,6 @@ mod randomized_test_helpers; mod remote_editing_collaboration_tests; mod test_server; -use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; pub use randomized_test_helpers::{ RandomizedTest, TestError, UserTestPlan, run_randomized_test, save_randomized_test_plan, }; @@ -51,17 +48,3 @@ fn room_participants(room: &Entity, cx: &mut TestAppContext) -> RoomPartic fn channel_id(room: &Entity, cx: &mut TestAppContext) -> Option { cx.read(|cx| room.read(cx).channel_id()) } - -fn rust_lang() -> Arc { - Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) -} diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 33f07bfb388763875565bc9e37bda363f02600f0..4e6cdb0e79aba494bd01137cc262a097a084217e 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -1,14 +1,12 @@ -use crate::{ - rpc::RECONNECT_TIMEOUT, - tests::{TestServer, rust_lang}, -}; +use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; use call::ActiveCall; use editor::{ DocumentColorsRenderMode, Editor, FETCH_COLORS_DEBOUNCE_TIMEOUT, MultiBufferOffset, RowInfo, SelectionEffects, actions::{ - ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, - ExpandMacroRecursively, MoveToEnd, Redo, Rename, SelectAll, ToggleCodeActions, Undo, + ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, CopyFileLocation, + CopyFileName, CopyFileNameWithoutExtension, ExpandMacroRecursively, MoveToEnd, Redo, + Rename, SelectAll, ToggleCodeActions, Undo, }, test::{ editor_test_context::{AssertionContextManager, EditorTestContext}, @@ -22,8 +20,9 @@ use gpui::{ App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext, }; use indoc::indoc; -use language::FakeLspAdapter; +use language::{FakeLspAdapter, rust_lang}; use lsp::LSP_REQUEST_TIMEOUT; +use pretty_assertions::assert_eq; use project::{ ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT, lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro}, @@ -313,6 +312,49 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu "Rust", FakeLspAdapter { capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + assert_eq!( + params.text_document_position.position, + lsp::Position::new(0, 14), + ); + + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first_method(…)".into(), + detail: Some("fn(&mut self, B) -> C".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "first_method($1)".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + lsp::CompletionItem { + label: "second_method(…)".into(), + detail: Some("fn(&mut self, C) -> D".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "second_method()".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + ]))) + }, + ); + })), ..FakeLspAdapter::default() }, ), @@ -321,6 +363,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu FakeLspAdapter { name: "fake-analyzer", capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |_, _| async move { Ok(None) }, + ); + })), ..FakeLspAdapter::default() }, ), @@ -374,6 +421,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu let fake_language_server = fake_language_servers[0].next().await.unwrap(); let second_fake_language_server = fake_language_servers[1].next().await.unwrap(); cx_a.background_executor.run_until_parked(); + cx_b.background_executor.run_until_parked(); buffer_b.read_with(cx_b, |buffer, _| { assert!(!buffer.completion_triggers().is_empty()) @@ -388,58 +436,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu }); cx_b.focus(&editor_b); - // Receive a completion request as the host's language server. - // Return some completions from the host's language server. - cx_a.executor().start_waiting(); - fake_language_server - .set_request_handler::(|params, _| async move { - assert_eq!( - params.text_document_position.text_document.uri, - lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), - ); - assert_eq!( - params.text_document_position.position, - lsp::Position::new(0, 14), - ); - - Ok(Some(lsp::CompletionResponse::Array(vec![ - lsp::CompletionItem { - label: "first_method(…)".into(), - detail: Some("fn(&mut self, B) -> C".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "first_method($1)".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - lsp::CompletionItem { - label: "second_method(…)".into(), - detail: Some("fn(&mut self, C) -> D".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "second_method()".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - ]))) - }) - .next() - .await - .unwrap(); - second_fake_language_server - .set_request_handler::(|_, _| async move { Ok(None) }) - .next() - .await - .unwrap(); - cx_a.executor().finish_waiting(); + // Allow the completion request to propagate from guest to host to LSP. + cx_b.background_executor.run_until_parked(); + cx_a.background_executor.run_until_parked(); // Open the buffer on the host. let buffer_a = project_a @@ -485,6 +484,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu // The additional edit is applied. cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); buffer_a.read_with(cx_a, |buffer, _| { assert_eq!( @@ -642,13 +642,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ), })), insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }); - cx_b.executor().run_until_parked(); - // Await both language server responses first_lsp_completion.next().await.unwrap(); second_lsp_completion.next().await.unwrap(); @@ -1580,7 +1578,10 @@ async fn test_share_project( buffer_a.read_with(cx_a, |buffer, _| { buffer .snapshot() - .selections_in_range(text::Anchor::MIN..text::Anchor::MAX, false) + .selections_in_range( + text::Anchor::min_max_range_for_buffer(buffer.remote_id()), + false, + ) .count() == 1 }); @@ -1621,7 +1622,10 @@ async fn test_share_project( buffer_a.read_with(cx_a, |buffer, _| { buffer .snapshot() - .selections_in_range(text::Anchor::MIN..text::Anchor::MAX, false) + .selections_in_range( + text::Anchor::min_max_range_for_buffer(buffer.remote_id()), + false, + ) .count() == 0 }); @@ -3185,13 +3189,12 @@ async fn test_lsp_pull_diagnostics( .collect::>(); let expected_messages = [ expected_pull_diagnostic_lib_message, - // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. - // expected_push_diagnostic_lib_message, + expected_push_diagnostic_lib_message, ]; assert_eq!( all_diagnostics.len(), - 1, - "Expected pull diagnostics, but got: {all_diagnostics:?}" + 2, + "Expected pull and push diagnostics, but got: {all_diagnostics:?}" ); for diagnostic in all_diagnostics { assert!( @@ -3251,14 +3254,15 @@ async fn test_lsp_pull_diagnostics( .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); let expected_messages = [ - expected_workspace_pull_diagnostics_lib_message, - // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. - // expected_push_diagnostic_lib_message, + // Despite workspace diagnostics provided, + // the currently open file's diagnostics should be preferred, as LSP suggests. + expected_pull_diagnostic_lib_message, + expected_push_diagnostic_lib_message, ]; assert_eq!( all_diagnostics.len(), - 1, - "Expected pull diagnostics, but got: {all_diagnostics:?}" + 2, + "Expected pull and push diagnostics, but got: {all_diagnostics:?}" ); for diagnostic in all_diagnostics { assert!( @@ -3371,8 +3375,9 @@ async fn test_lsp_pull_diagnostics( "Another workspace diagnostics pull should happen after the diagnostics refresh server request" ); { - assert!( - diagnostics_pulls_result_ids.lock().await.len() == diagnostic_pulls_result_ids, + assert_eq!( + diagnostics_pulls_result_ids.lock().await.len(), + diagnostic_pulls_result_ids, "Pulls should not happen hence no extra ids should appear" ); assert!( @@ -3390,7 +3395,7 @@ async fn test_lsp_pull_diagnostics( expected_pull_diagnostic_lib_message, expected_push_diagnostic_lib_message, ]; - assert_eq!(all_diagnostics.len(), 1); + assert_eq!(all_diagnostics.len(), 2); for diagnostic in &all_diagnostics { assert!( expected_messages.contains(&diagnostic.diagnostic.message.as_str()), @@ -3509,7 +3514,6 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA .into_iter() .map(|(sha, message)| (sha.parse().unwrap(), message.into())) .collect(), - remote_url: Some("git@github.com:zed-industries/zed.git".to_string()), }; client_a.fs().set_blame_for_repo( Path::new(path!("/my-repo/.git")), @@ -3594,10 +3598,6 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA for (idx, (buffer, entry)) in entries.iter().flatten().enumerate() { let details = blame.details_for_entry(*buffer, entry).unwrap(); assert_eq!(details.message, format!("message for idx-{}", idx)); - assert_eq!( - details.permalink.unwrap().to_string(), - format!("https://github.com/zed-industries/zed/commit/{}", entry.sha) - ); } }); }); @@ -4269,6 +4269,288 @@ async fn test_client_can_query_lsp_ext(cx_a: &mut TestAppContext, cx_b: &mut Tes }); } +#[gpui::test] +async fn test_copy_file_name_without_extension( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": indoc! {" + fn main() { + println!(\"Hello, world!\"); + } + "}, + } + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let editor_a = workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some("main".to_string()) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some("main".to_string()) + ); +} + +#[gpui::test] +async fn test_copy_file_name(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": indoc! {" + fn main() { + println!(\"Hello, world!\"); + } + "}, + } + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let editor_a = workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.copy_file_name(&CopyFileName, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some("main.rs".to_string()) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.copy_file_name(&CopyFileName, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some("main.rs".to_string()) + ); +} + +#[gpui::test] +async fn test_copy_file_location(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": indoc! {" + fn main() { + println!(\"Hello, world!\"); + } + "}, + } + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let editor_a = workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(16)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2", path!("src/main.rs"))) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(16)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2", path!("src/main.rs"))) + ); +} + #[track_caller] fn tab_undo_assert( cx_a: &mut EditorTestContext, diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index fcda8688d427f3e6b937f00edc7c3586dfdbef36..391e7355ea196dfe25d363472918837ea817f450 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2,7 +2,7 @@ use crate::{ rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, tests::{ RoomParticipants, TestClient, TestServer, channel_id, following_tests::join_channel, - room_participants, rust_lang, + room_participants, }, }; use anyhow::{Result, anyhow}; @@ -26,7 +26,7 @@ use language::{ Diagnostic, DiagnosticEntry, DiagnosticSourceKind, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope, language_settings::{Formatter, FormatterList}, - tree_sitter_rust, tree_sitter_typescript, + rust_lang, tree_sitter_rust, tree_sitter_typescript, }; use lsp::{LanguageServerId, OneOf}; use parking_lot::Mutex; diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 04403de9fa0883e9d738f3d96b9b2acdf1d66967..5342b0bbd4b11afb24ccbaa6d4bf17df036cec76 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -4,6 +4,7 @@ use collections::{HashMap, HashSet}; use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling}; use debugger_ui::debugger_panel::DebugPanel; +use editor::{Editor, EditorMode, MultiBuffer}; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs as _, RemoveOptions}; use futures::StreamExt as _; @@ -12,22 +13,30 @@ use http_client::BlockedHttpClient; use language::{ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, language_settings::{Formatter, FormatterList, language_settings}, - tree_sitter_typescript, + rust_lang, tree_sitter_typescript, }; use node_runtime::NodeRuntime; use project::{ ProjectPath, debugger::session::ThreadId, lsp_store::{FormatTrigger, LspFormatTarget}, + trusted_worktrees::{PathTrust, TrustedWorktrees}, }; use remote::RemoteClient; use remote_server::{HeadlessAppState, HeadlessProject}; use rpc::proto; use serde_json::json; -use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore}; +use settings::{ + InlayHintSettingsContent, LanguageServerFormatterSpecifier, PrettierSettingsContent, + SettingsStore, +}; use std::{ path::Path, - sync::{Arc, atomic::AtomicUsize}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, }; use task::TcpArgumentsTemplate; use util::{path, rel_path::rel_path}; @@ -90,13 +99,14 @@ async fn test_sharing_an_ssh_remote_project( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, worktree_id) = client_a - .build_ssh_project(path!("/code/project1"), client_ssh, cx_a) + .build_ssh_project(path!("/code/project1"), client_ssh, false, cx_a) .await; // While the SSH worktree is being scanned, user A shares the remote project. @@ -250,13 +260,14 @@ async fn test_ssh_collaboration_git_branches( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, _) = client_a - .build_ssh_project("/project", client_ssh, cx_a) + .build_ssh_project("/project", client_ssh, false, cx_a) .await; // While the SSH worktree is being scanned, user A shares the remote project. @@ -454,13 +465,14 @@ async fn test_ssh_collaboration_formatting_with_prettier( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, worktree_id) = client_a - .build_ssh_project(path!("/project"), client_ssh, cx_a) + .build_ssh_project(path!("/project"), client_ssh, false, cx_a) .await; // While the SSH worktree is being scanned, user A shares the remote project. @@ -615,6 +627,7 @@ async fn test_remote_server_debugger( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); @@ -627,7 +640,7 @@ async fn test_remote_server_debugger( command_palette_hooks::init(cx); }); let (project_a, _) = client_a - .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a) + .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a) .await; let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a); @@ -723,6 +736,7 @@ async fn test_slow_adapter_startup_retries( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); @@ -735,7 +749,7 @@ async fn test_slow_adapter_startup_retries( command_palette_hooks::init(cx); }); let (project_a, _) = client_a - .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a) + .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a) .await; let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a); @@ -838,3 +852,261 @@ async fn test_slow_adapter_startup_retries( shutdown_session.await.unwrap(); } + +#[gpui::test] +async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &mut TestAppContext) { + use project::trusted_worktrees::RemoteHostLocation; + + cx_a.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + project::trusted_worktrees::init(HashMap::default(), None, None, cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + project::trusted_worktrees::init(HashMap::default(), None, None, cx); + }); + + let mut server = TestServer::start(cx_a.executor().clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + + let server_name = "override-rust-analyzer"; + let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0)); + + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); + let remote_fs = FakeFs::new(server_cx.executor()); + remote_fs + .insert_tree( + path!("/projects"), + json!({ + "project_a": { + ".zed": { + "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"# + }, + "main.rs": "fn main() {}" + }, + "project_b": { "lib.rs": "pub fn lib() {}" } + }), + ) + .await; + + server_cx.update(HeadlessProject::init); + let remote_http_client = Arc::new(BlockedHttpClient); + let node = NodeRuntime::unavailable(); + let languages = Arc::new(LanguageRegistry::new(server_cx.executor())); + languages.add(rust_lang()); + + let capabilities = lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + let mut fake_language_servers = languages.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: server_name, + capabilities: capabilities.clone(), + initializer: Some(Box::new({ + let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); + move |fake_server| { + let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); + fake_server.set_request_handler::( + move |_params, _| { + lsp_inlay_hint_request_count.fetch_add(1, Ordering::Release); + async move { + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 0), + label: lsp::InlayHintLabel::String("hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + + let _headless_project = server_cx.new(|cx| { + HeadlessProject::new( + HeadlessAppState { + session: server_ssh, + fs: remote_fs.clone(), + http_client: remote_http_client, + node_runtime: node, + languages, + extension_host_proxy: Arc::new(ExtensionHostProxy::new()), + }, + true, + cx, + ) + }); + + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; + let (project_a, worktree_id_a) = client_a + .build_ssh_project(path!("/projects/project_a"), client_ssh.clone(), true, cx_a) + .await; + + cx_a.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + let language_settings = &mut settings.project.all_languages.defaults; + language_settings.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + }); + }); + + project_a + .update(cx_a, |project, cx| { + project.languages().add(rust_lang()); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: server_name, + capabilities, + ..FakeLspAdapter::default() + }, + ); + project.find_or_create_worktree(path!("/projects/project_b"), true, cx) + }) + .await + .unwrap(); + + cx_a.run_until_parked(); + + let worktree_ids = project_a.read_with(cx_a, |project, cx| { + project + .worktrees(cx) + .map(|wt| wt.read(cx).id()) + .collect::>() + }); + assert_eq!(worktree_ids.len(), 2); + + let remote_host = project_a.read_with(cx_a, |project, cx| { + project + .remote_connection_options(cx) + .map(RemoteHostLocation::from) + }); + + let trusted_worktrees = + cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist")); + + let can_trust_a = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx)); + assert!(!can_trust_a, "project_a should be restricted initially"); + assert!(!can_trust_b, "project_b should be restricted initially"); + + let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store()); + let has_restricted = trusted_worktrees.read_with(cx_a, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!(has_restricted, "should have restricted worktrees"); + + let buffer_before_approval = project_a + .update(cx_a, |project, cx| { + project.open_buffer((worktree_id_a, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, cx_a) = cx_a.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)), + Some(project_a.clone()), + window, + cx, + ) + }); + cx_a.run_until_parked(); + let fake_language_server = fake_language_servers.next(); + + cx_a.read(|cx| { + let file = buffer_before_approval.read(cx).file(); + assert_eq!( + language_settings(Some("Rust".into()), file, cx).language_servers, + ["...".to_string()], + "remote .zed/settings.json must not sync before trust approval" + ) + }); + + editor.update_in(cx_a, |editor, window, cx| { + editor.handle_input("1", window, cx); + }); + cx_a.run_until_parked(); + cx_a.executor().advance_clock(Duration::from_secs(1)); + assert_eq!( + lsp_inlay_hint_request_count.load(Ordering::Acquire), + 0, + "inlay hints must not be queried before trust approval" + ); + + trusted_worktrees.update(cx_a, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]), + remote_host.clone(), + cx, + ); + }); + cx_a.run_until_parked(); + + cx_a.read(|cx| { + let file = buffer_before_approval.read(cx).file(); + assert_eq!( + language_settings(Some("Rust".into()), file, cx).language_servers, + ["override-rust-analyzer".to_string()], + "remote .zed/settings.json should sync after trust approval" + ) + }); + let _fake_language_server = fake_language_server.await.unwrap(); + editor.update_in(cx_a, |editor, window, cx| { + editor.handle_input("1", window, cx); + }); + cx_a.run_until_parked(); + cx_a.executor().advance_clock(Duration::from_secs(1)); + assert!( + lsp_inlay_hint_request_count.load(Ordering::Acquire) > 0, + "inlay hints should be queried after trust approval" + ); + + let can_trust_a = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx)); + assert!(can_trust_a, "project_a should be trusted after trust()"); + assert!(!can_trust_b, "project_b should still be restricted"); + + trusted_worktrees.update(cx_a, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]), + remote_host.clone(), + cx, + ); + }); + + let can_trust_a = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx)); + assert!(can_trust_a, "project_a should remain trusted"); + assert!(can_trust_b, "project_b should now be trusted"); + + let has_restricted_after = trusted_worktrees.read_with(cx_a, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!( + !has_restricted_after, + "should have no restricted worktrees after trusting both" + ); +} diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 959d54cf0864ccddf7273cca0276d18d4f59308b..3abbd1a014b556db02e70b42c239729100f17eb8 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -761,6 +761,7 @@ impl TestClient { &self, root_path: impl AsRef, ssh: Entity, + init_worktree_trust: bool, cx: &mut TestAppContext, ) -> (Entity, WorktreeId) { let project = cx.update(|cx| { @@ -771,6 +772,7 @@ impl TestClient { self.app_state.user_store.clone(), self.app_state.languages.clone(), self.app_state.fs.clone(), + init_worktree_trust, cx, ) }); @@ -839,6 +841,7 @@ impl TestClient { self.app_state.languages.clone(), self.app_state.fs.clone(), None, + false, cx, ) }) diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 483597359e091eb166df51bbe3c9fa9448ee3d4f..8959c6ccbe88d1f3f78fb29009904244624d9999 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -541,7 +541,7 @@ impl Item for ChannelView { }) } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 618348307f1270e180faf4b1d061b9a942e39fa5..0ae4ff270bd672ca028d638484b9a23f5981de1a 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -37,7 +37,7 @@ use ui::{ }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ - Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, + CopyRoomId, Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyResultExt}, }; @@ -109,25 +109,37 @@ pub fn init(cx: &mut App) { }); // TODO: make it possible to bind this one to a held key for push to talk? // how to make "toggle_on_modifiers_press" contextual? - workspace.register_action(|_, _: &Mute, window, cx| { - let room = ActiveCall::global(cx).read(cx).room().cloned(); - if let Some(room) = room { - window.defer(cx, move |_window, cx| { - room.update(cx, |room, cx| room.toggle_mute(cx)) - }); - } - }); - workspace.register_action(|_, _: &Deafen, window, cx| { - let room = ActiveCall::global(cx).read(cx).room().cloned(); - if let Some(room) = room { - window.defer(cx, move |_window, cx| { - room.update(cx, |room, cx| room.toggle_deafen(cx)) - }); - } - }); + workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx)); + workspace.register_action(|_, _: &Deafen, _, cx| title_bar::collab::toggle_deafen(cx)); workspace.register_action(|_, _: &LeaveCall, window, cx| { CollabPanel::leave_call(window, cx); }); + workspace.register_action(|workspace, _: &CopyRoomId, window, cx| { + use workspace::notifications::{NotificationId, NotifyTaskExt as _}; + + struct RoomIdCopiedToast; + + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let romo_id_fut = room.read(cx).room_id(); + cx.spawn(async move |workspace, cx| { + let room_id = romo_id_fut.await.context("Failed to get livekit room")?; + workspace.update(cx, |workspace, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(room_id)); + workspace.show_toast( + workspace::Toast::new( + NotificationId::unique::(), + "Room ID copied to clipboard", + ) + .autohide(), + cx, + ); + }) + }) + .detach_and_notify_err(window, cx); + } else { + workspace.show_error(&"There’s no active call; join one first.", cx); + } + }); workspace.register_action(|workspace, _: &ShareProject, window, cx| { let project = workspace.project().clone(); println!("{project:?}"); @@ -1240,7 +1252,7 @@ impl CollabPanel { context_menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1412,7 +1424,7 @@ impl CollabPanel { context_menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1475,7 +1487,7 @@ impl CollabPanel { }) }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1509,9 +1521,9 @@ impl CollabPanel { if cx.stop_active_drag(window) { return; } else if self.take_editing_state(window, cx) { - window.focus(&self.filter_editor.focus_handle(cx)); + window.focus(&self.filter_editor.focus_handle(cx), cx); } else if !self.reset_filter_editor_text(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } if self.context_menu.is_some() { @@ -1814,7 +1826,7 @@ impl CollabPanel { }); self.update_entries(false, cx); self.select_channel_editor(); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); cx.notify(); } @@ -1839,7 +1851,7 @@ impl CollabPanel { }); self.update_entries(false, cx); self.select_channel_editor(); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); cx.notify(); } @@ -1888,7 +1900,7 @@ impl CollabPanel { editor.set_text(channel.name.clone(), window, cx); editor.select_all(&Default::default(), window, cx); }); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); self.update_entries(false, cx); self.select_channel_editor(); } diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 9d882562cab710f562145087e5c38474fda4808b..ae5b537f2c66dc273d504a70f2b75cb8bec0be20 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -642,7 +642,7 @@ impl ChannelModalDelegate { }); menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index f21c202721fa29644e17df499fcfb288a72dc492..bd86c10a8071896f0b24ea531d354c0e46114d48 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -8,6 +8,9 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[features] +test-support = ["db/test-support"] + [lib] path = "src/command_palette.rs" doctest = false diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index d971bca1f01e878d7517c1d13f525dfbf8e47afa..038b58ac5f4e90544232ccc8da55d0ca71ec28df 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -2,7 +2,7 @@ mod persistence; use std::{ cmp::{self, Reverse}, - collections::HashMap, + collections::{HashMap, VecDeque}, sync::Arc, time::Duration, }; @@ -19,6 +19,7 @@ use gpui::{ ParentElement, Render, Styled, Task, WeakEntity, Window, }; use persistence::COMMAND_PALETTE_HISTORY; +use picker::Direction; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; @@ -163,6 +164,7 @@ pub struct CommandPaletteDelegate { Task<()>, postage::dispatch::Receiver<(Vec, Vec, CommandInterceptResult)>, )>, + query_history: QueryHistory, } struct Command { @@ -170,6 +172,91 @@ struct Command { action: Box, } +#[derive(Default)] +struct QueryHistory { + history: Option>, + cursor: Option, + prefix: Option, +} + +impl QueryHistory { + fn history(&mut self) -> &mut VecDeque { + self.history.get_or_insert_with(|| { + COMMAND_PALETTE_HISTORY + .list_recent_queries() + .unwrap_or_default() + .into_iter() + .collect() + }) + } + + fn add(&mut self, query: String) { + if let Some(pos) = self.history().iter().position(|h| h == &query) { + self.history().remove(pos); + } + self.history().push_back(query); + self.cursor = None; + self.prefix = None; + } + + fn validate_cursor(&mut self, current_query: &str) -> Option { + if let Some(pos) = self.cursor { + if self.history().get(pos).map(|s| s.as_str()) != Some(current_query) { + self.cursor = None; + self.prefix = None; + } + } + self.cursor + } + + fn previous(&mut self, current_query: &str) -> Option<&str> { + if self.validate_cursor(current_query).is_none() { + self.prefix = Some(current_query.to_string()); + } + + let prefix = self.prefix.clone().unwrap_or_default(); + let start_index = self.cursor.unwrap_or(self.history().len()); + + for i in (0..start_index).rev() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn next(&mut self, current_query: &str) -> Option<&str> { + let selected = self.validate_cursor(current_query)?; + let prefix = self.prefix.clone().unwrap_or_default(); + + for i in (selected + 1)..self.history().len() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn reset_cursor(&mut self) { + self.cursor = None; + self.prefix = None; + } + + fn is_navigating(&self) -> bool { + self.cursor.is_some() + } +} + impl Clone for Command { fn clone(&self) -> Self { Self { @@ -196,6 +283,7 @@ impl CommandPaletteDelegate { previous_focus_handle, latest_query: String::new(), updating_matches: None, + query_history: Default::default(), } } @@ -271,6 +359,11 @@ impl CommandPaletteDelegate { // so we need to return an Option here self.commands.get(action_ix) } + + #[cfg(any(test, feature = "test-support"))] + pub fn seed_history(&mut self, queries: &[&str]) { + self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect()); + } } impl PickerDelegate for CommandPaletteDelegate { @@ -280,6 +373,38 @@ impl PickerDelegate for CommandPaletteDelegate { "Execute a command...".into() } + fn select_history( + &mut self, + direction: Direction, + query: &str, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + match direction { + Direction::Up => { + let should_use_history = + self.selected_ix == 0 || self.query_history.is_navigating(); + if should_use_history { + if let Some(query) = self.query_history.previous(query).map(|s| s.to_string()) { + return Some(query); + } + } + } + Direction::Down => { + if self.query_history.is_navigating() { + if let Some(query) = self.query_history.next(query).map(|s| s.to_string()) { + return Some(query); + } else { + let prefix = self.query_history.prefix.take().unwrap_or_default(); + self.query_history.reset_cursor(); + return Some(prefix); + } + } + } + } + None + } + fn match_count(&self) -> usize { self.matches.len() } @@ -439,6 +564,12 @@ impl PickerDelegate for CommandPaletteDelegate { self.dismissed(window, cx); return; } + + if !self.latest_query.is_empty() { + self.query_history.add(self.latest_query.clone()); + self.query_history.reset_cursor(); + } + let action_ix = self.matches[self.selected_ix].candidate_id; let command = self.commands.swap_remove(action_ix); telemetry::event!( @@ -457,7 +588,7 @@ impl PickerDelegate for CommandPaletteDelegate { }) .detach_and_log_err(cx); let action = command.action; - window.focus(&self.previous_focus_handle); + window.focus(&self.previous_focus_handle, cx); self.dismissed(window, cx); window.dispatch_action(action, cx); } @@ -588,7 +719,7 @@ mod tests { use super::*; use editor::Editor; use go_to_line::GoToLine; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use language::Point; use project::Project; use settings::KeymapFile; @@ -653,7 +784,7 @@ mod tests { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) }); cx.simulate_keystrokes("cmd-shift-p"); @@ -724,7 +855,7 @@ mod tests { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) }); // Test normalize (trimming whitespace and double colons) @@ -799,7 +930,9 @@ mod tests { "bindings": { "cmd-n": "workspace::NewFile", "enter": "menu::Confirm", - "cmd-shift-p": "command_palette::Toggle" + "cmd-shift-p": "command_palette::Toggle", + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" } } ]"#, @@ -808,4 +941,264 @@ mod tests { app_state }) } + + fn open_palette_with_history( + workspace: &Entity, + history: &[&str], + cx: &mut VisualTestContext, + ) -> Entity> { + cx.simulate_keystrokes("cmd-shift-p"); + cx.run_until_parked(); + + let palette = workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .unwrap() + .read(cx) + .picker + .clone() + }); + + palette.update(cx, |palette, _cx| { + palette.delegate.seed_history(history); + }); + + palette + } + + #[gpui::test] + async fn test_history_navigation_basic(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx); + + // Query should be empty initially + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + + // Press up - should load most recent query "select all" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should load "backspace" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Press down - should go back to "select all" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down again - should clear query (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } + + #[gpui::test] + async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace"], cx); + + // Press up to enter history mode + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Type something - should append to the history query + cx.simulate_input("x"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspacex"); + }); + } + + #[gpui::test] + async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx); + + // Open palette with a query that has multiple matches + cx.simulate_input("editor"); + cx.background_executor.run_until_parked(); + + // Should have multiple matches, selected_ix should be 0 + palette.read_with(cx, |palette, _| { + assert!(palette.delegate.matches.len() > 1); + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press down - should navigate to next suggestion (not history) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 1); + }); + + // Press up - should go back to first suggestion + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press up again at top - should enter history mode and show previous query + // that matches the "editor" prefix + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "editor: open"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history( + &workspace, + &["open file", "select all", "select line", "backspace"], + cx, + ); + + // Type "sel" as a prefix + cx.simulate_input("sel"); + cx.background_executor.run_until_parked(); + + // Press up - should get "select line" (most recent matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press up again - should get "select all" (next matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should stay at "select all" (no more matches for "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down - should go back to "select line" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press down again - should return to original prefix "sel" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "sel"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = + open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx); + + // Type "xyz" as a prefix that doesn't match anything + cx.simulate_input("xyz"); + cx.background_executor.run_until_parked(); + + // Press up - should stay at "xyz" (no matches) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "xyz"); + }); + } + + #[gpui::test] + async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx); + + // With empty query, press up - should get "gamma" (most recent) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press up - should get "beta" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press up - should get "alpha" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "alpha"); + }); + + // Press down - should get "beta" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press down - should get "gamma" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press down - should return to empty string (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } } diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index feaed72570d56f4895ff05eef891fc81c2e5e0b6..4556079b4f9c8e7a989f3e32eac6f7d084e67a4e 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -123,6 +123,16 @@ impl CommandPaletteDB { ORDER BY COUNT(1) DESC } } + + query! { + pub fn list_recent_queries() -> Result> { + SELECT user_query + FROM command_invocations + WHERE user_query != "" + GROUP BY user_query + ORDER BY MAX(last_invoked) ASC + } + } } #[cfg(test)] diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index f73e6a9bab011c5d675040d1ee3a05dfa708dc45..539b873c3527b5a01f1dfcf7b768f0758dc869b5 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -29,10 +29,12 @@ schemars.workspace = true serde_json.workspace = true serde.workspace = true settings.workspace = true +slotmap.workspace = true smol.workspace = true tempfile.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true +terminal.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index f891e96250f3334540aa859fe438c87297fc0100..605f24178916faa5173c32c28be6c80ee625cb6c 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -6,6 +6,7 @@ use parking_lot::Mutex; use postage::barrier; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, value::RawValue}; +use slotmap::SlotMap; use smol::channel; use std::{ fmt, @@ -50,7 +51,7 @@ pub(crate) struct Client { next_id: AtomicI32, outbound_tx: channel::Sender, name: Arc, - notification_handlers: Arc>>, + subscription_set: Arc>, response_handlers: Arc>>>, #[allow(clippy::type_complexity)] #[allow(dead_code)] @@ -191,21 +192,20 @@ impl Client { let (outbound_tx, outbound_rx) = channel::unbounded::(); let (output_done_tx, output_done_rx) = barrier::channel(); - let notification_handlers = - Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default())); + let subscription_set = Arc::new(Mutex::new(NotificationSubscriptionSet::default())); let response_handlers = Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default()))); let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default())); let receive_input_task = cx.spawn({ - let notification_handlers = notification_handlers.clone(); + let subscription_set = subscription_set.clone(); let response_handlers = response_handlers.clone(); let request_handlers = request_handlers.clone(); let transport = transport.clone(); async move |cx| { Self::handle_input( transport, - notification_handlers, + subscription_set, request_handlers, response_handlers, cx, @@ -236,7 +236,7 @@ impl Client { Ok(Self { server_id, - notification_handlers, + subscription_set, response_handlers, name: server_name, next_id: Default::default(), @@ -257,7 +257,7 @@ impl Client { /// to pending requests) and notifications (which trigger registered handlers). async fn handle_input( transport: Arc, - notification_handlers: Arc>>, + subscription_set: Arc>, request_handlers: Arc>>, response_handlers: Arc>>>, cx: &mut AsyncApp, @@ -282,10 +282,11 @@ impl Client { handler(Ok(message.to_string())); } } else if let Ok(notification) = serde_json::from_str::(&message) { - let mut notification_handlers = notification_handlers.lock(); - if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) { - handler(notification.params.unwrap_or(Value::Null), cx.clone()); - } + subscription_set.lock().notify( + ¬ification.method, + notification.params.unwrap_or(Value::Null), + cx, + ) } else { log::error!("Unhandled JSON from context_server: {}", message); } @@ -451,12 +452,18 @@ impl Client { Ok(()) } + #[must_use] pub fn on_notification( &self, method: &'static str, f: Box, - ) { - self.notification_handlers.lock().insert(method, f); + ) -> NotificationSubscription { + let mut notification_subscriptions = self.subscription_set.lock(); + + NotificationSubscription { + id: notification_subscriptions.add_handler(method, f), + set: self.subscription_set.clone(), + } } } @@ -485,3 +492,73 @@ impl fmt::Debug for Client { .finish_non_exhaustive() } } + +slotmap::new_key_type! { + struct NotificationSubscriptionId; +} + +#[derive(Default)] +pub struct NotificationSubscriptionSet { + // we have very few subscriptions at the moment + methods: Vec<(&'static str, Vec)>, + handlers: SlotMap, +} + +impl NotificationSubscriptionSet { + #[must_use] + fn add_handler( + &mut self, + method: &'static str, + handler: NotificationHandler, + ) -> NotificationSubscriptionId { + let id = self.handlers.insert(handler); + if let Some((_, handler_ids)) = self + .methods + .iter_mut() + .find(|(probe_method, _)| method == *probe_method) + { + debug_assert!( + handler_ids.len() < 20, + "Too many MCP handlers for {}. Consider using a different data structure.", + method + ); + + handler_ids.push(id); + } else { + self.methods.push((method, vec![id])); + }; + id + } + + fn notify(&mut self, method: &str, payload: Value, cx: &mut AsyncApp) { + let Some((_, handler_ids)) = self + .methods + .iter_mut() + .find(|(probe_method, _)| method == *probe_method) + else { + return; + }; + + for handler_id in handler_ids { + if let Some(handler) = self.handlers.get_mut(*handler_id) { + handler(payload.clone(), cx.clone()); + } + } + } +} + +pub struct NotificationSubscription { + id: NotificationSubscriptionId, + set: Arc>, +} + +impl Drop for NotificationSubscription { + fn drop(&mut self) { + let mut set = self.set.lock(); + set.handlers.remove(self.id); + set.methods.retain_mut(|(_, handler_ids)| { + handler_ids.retain(|id| *id != self.id); + !handler_ids.is_empty() + }); + } +} diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 553e845df87a2fec30b1afbffa05b970d5d672f6..92804549c69b01dd3729efb3a0b47905cd73d813 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -96,22 +96,6 @@ impl ContextServer { self.initialize(self.new_client(cx)?).await } - /// Starts the context server, making sure handlers are registered before initialization happens - pub async fn start_with_handlers( - &self, - notification_handlers: Vec<( - &'static str, - Box, - )>, - cx: &AsyncApp, - ) -> Result<()> { - let client = self.new_client(cx)?; - for (method, handler) in notification_handlers { - client.on_notification(method, handler); - } - self.initialize(client).await - } - fn new_client(&self, cx: &AsyncApp) -> Result { Ok(match &self.configuration { ContextServerTransport::Stdio(command, working_directory) => Client::stdio( diff --git a/crates/context_server/src/protocol.rs b/crates/context_server/src/protocol.rs index 5355f20f620b5bed76bf945e863fdb5cbcc2ff43..a218a8a3e0e6352997e4152214077cb3851317b3 100644 --- a/crates/context_server/src/protocol.rs +++ b/crates/context_server/src/protocol.rs @@ -12,7 +12,7 @@ use futures::channel::oneshot; use gpui::AsyncApp; use serde_json::Value; -use crate::client::Client; +use crate::client::{Client, NotificationSubscription}; use crate::types::{self, Notification, Request}; pub struct ModelContextProtocol { @@ -119,7 +119,7 @@ impl InitializedContextServerProtocol { &self, method: &'static str, f: Box, - ) { - self.inner.on_notification(method, f); + ) -> NotificationSubscription { + self.inner.on_notification(method, f) } } diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 83908b46829c4cfe3b536ecca1155c909ee424dd..e675770e9ee50df9993076e6d71c70befa118c4b 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -8,9 +8,12 @@ use futures::{ AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, }; use gpui::AsyncApp; +use settings::Settings as _; use smol::channel; use smol::process::Child; +use terminal::terminal_settings::TerminalSettings; use util::TryFutureExt as _; +use util::shell_builder::ShellBuilder; use crate::client::ModelContextServerBinary; use crate::transport::Transport; @@ -28,9 +31,12 @@ impl StdioTransport { working_directory: &Option, cx: &AsyncApp, ) -> Result { - let mut command = util::command::new_smol_command(&binary.executable); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let mut command = + builder.build_command(Some(binary.executable.display().to_string()), &binary.args); + command - .args(&binary.args) .envs(binary.env.unwrap_or_default()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) diff --git a/crates/context_server/src/types.rs b/crates/context_server/src/types.rs index 03aca4f3caf7995091bbc8e049494b324674a9d3..81a427a289347ad50bf6a11674c4c5867073a274 100644 --- a/crates/context_server/src/types.rs +++ b/crates/context_server/src/types.rs @@ -330,7 +330,7 @@ pub struct PromptMessage { pub content: MessageContent, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Role { User, diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 0d3b19c0c7bd264f8ed10e53289376055f833307..3a1706a7a679fbc14eafbeac953d842cda9f65c8 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -33,7 +33,7 @@ fs.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true -edit_prediction.workspace = true +edit_prediction_types.workspace = true language.workspace = true log.workspace = true lsp.workspace = true @@ -52,6 +52,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true itertools.workspace = true +url.workspace = true [target.'cfg(windows)'.dependencies] async-std = { version = "1.12.0", features = ["unstable"] } diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index ed18a199bf2c08c8c046a8ad3e7f945b1340643e..a6963296f5c0ce0395698d2952618123c103ff55 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1,10 +1,11 @@ pub mod copilot_chat; -mod copilot_completion_provider; +mod copilot_edit_prediction_delegate; pub mod copilot_responses; pub mod request; mod sign_in; -use crate::sign_in::initiate_sign_in_within_workspace; +use crate::request::NextEditSuggestions; +use crate::sign_in::initiate_sign_out; use ::fs::Fs; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; @@ -18,7 +19,7 @@ use http_client::HttpClient; use language::language_settings::CopilotSettings; use language::{ Anchor, Bias, Buffer, BufferSnapshot, Language, PointUtf16, ToPointUtf16, - language_settings::{EditPredictionProvider, all_language_settings, language_settings}, + language_settings::{EditPredictionProvider, all_language_settings}, point_from_lsp, point_to_lsp, }; use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName}; @@ -28,12 +29,10 @@ use project::DisableAiSettings; use request::StatusNotification; use semver::Version; use serde_json::json; -use settings::Settings; -use settings::SettingsStore; -use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace}; -use std::collections::hash_map::Entry; +use settings::{Settings, SettingsStore}; use std::{ any::TypeId, + collections::hash_map::Entry, env, ffi::OsString, mem, @@ -42,12 +41,14 @@ use std::{ sync::Arc, }; use sum_tree::Dimensions; -use util::rel_path::RelPath; use util::{ResultExt, fs::remove_matching}; use workspace::Workspace; -pub use crate::copilot_completion_provider::CopilotCompletionProvider; -pub use crate::sign_in::{CopilotCodeVerification, initiate_sign_in, reinstall_and_sign_in}; +pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate; +pub use crate::sign_in::{ + ConfigurationMode, ConfigurationView, CopilotCodeVerification, initiate_sign_in, + reinstall_and_sign_in, +}; actions!( copilot, @@ -98,21 +99,14 @@ pub fn init( .detach(); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { - workspace.register_action(|workspace, _: &SignIn, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); - } + workspace.register_action(|_, _: &SignIn, window, cx| { + initiate_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &Reinstall, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - } + workspace.register_action(|_, _: &Reinstall, window, cx| { + reinstall_and_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &SignOut, _window, cx| { - if let Some(copilot) = Copilot::global(cx) { - sign_out_within_workspace(workspace, copilot, cx); - } + workspace.register_action(|_, _: &SignOut, window, cx| { + initiate_sign_out(window, cx); }); }) .detach(); @@ -322,6 +316,15 @@ struct GlobalCopilot(Entity); impl Global for GlobalCopilot {} +/// Copilot's NextEditSuggestion response, with coordinates converted to Anchors. +struct CopilotEditPrediction { + buffer: Entity, + range: Range, + text: String, + command: Option, + snapshot: BufferSnapshot, +} + impl Copilot { pub fn global(cx: &App) -> Option> { cx.try_global::() @@ -375,7 +378,7 @@ impl Copilot { } } - fn start_copilot( + pub fn start_copilot( &mut self, check_edit_prediction_provider: bool, awaiting_sign_in_after_start: bool, @@ -563,6 +566,14 @@ impl Copilot { let server = start_language_server.await; this.update(cx, |this, cx| { cx.notify(); + + if env::var("ZED_FORCE_COPILOT_ERROR").is_ok() { + this.server = CopilotServer::Error( + "Forced error for testing (ZED_FORCE_COPILOT_ERROR)".into(), + ); + return; + } + match server { Ok((server, status)) => { this.server = CopilotServer::Running(RunningCopilotServer { @@ -584,7 +595,17 @@ impl Copilot { .ok(); } - pub(crate) fn sign_in(&mut self, cx: &mut Context) -> Task> { + pub fn is_authenticated(&self) -> bool { + return matches!( + self.server, + CopilotServer::Running(RunningCopilotServer { + sign_in_status: SignInStatus::Authorized, + .. + }) + ); + } + + pub fn sign_in(&mut self, cx: &mut Context) -> Task> { if let CopilotServer::Running(server) = &mut self.server { let task = match &server.sign_in_status { SignInStatus::Authorized => Task::ready(Ok(())).shared(), @@ -807,7 +828,7 @@ impl Copilot { .ok(); } language::BufferEvent::FileHandleChanged - | language::BufferEvent::LanguageChanged => { + | language::BufferEvent::LanguageChanged(_) => { let new_language_id = id_for_language(buffer.read(cx).language()); let Ok(new_uri) = uri_for_buffer(&buffer, cx) else { return Ok(()); @@ -862,101 +883,19 @@ impl Copilot { } } - pub fn completions( - &mut self, - buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - T: ToPointUtf16, - { - self.request_completions::(buffer, position, cx) - } - - pub fn completions_cycling( + pub(crate) fn completions( &mut self, buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - T: ToPointUtf16, - { - self.request_completions::(buffer, position, cx) - } - - pub fn accept_completion( - &mut self, - completion: &Completion, + position: Anchor, cx: &mut Context, - ) -> Task> { - let server = match self.server.as_authenticated() { - Ok(server) => server, - Err(error) => return Task::ready(Err(error)), - }; - let request = - server - .lsp - .request::(request::NotifyAcceptedParams { - uuid: completion.uuid.clone(), - }); - cx.background_spawn(async move { - request - .await - .into_response() - .context("copilot: notify accepted")?; - Ok(()) - }) - } - - pub fn discard_completions( - &mut self, - completions: &[Completion], - cx: &mut Context, - ) -> Task> { - let server = match self.server.as_authenticated() { - Ok(server) => server, - Err(_) => return Task::ready(Ok(())), - }; - let request = - server - .lsp - .request::(request::NotifyRejectedParams { - uuids: completions - .iter() - .map(|completion| completion.uuid.clone()) - .collect(), - }); - cx.background_spawn(async move { - request - .await - .into_response() - .context("copilot: notify rejected")?; - Ok(()) - }) - } - - fn request_completions( - &mut self, - buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - R: 'static - + lsp::request::Request< - Params = request::GetCompletionsParams, - Result = request::GetCompletionsResult, - >, - T: ToPointUtf16, - { + ) -> Task>> { self.register_buffer(buffer, cx); let server = match self.server.as_authenticated() { Ok(server) => server, Err(error) => return Task::ready(Err(error)), }; + let buffer_entity = buffer.clone(); let lsp = server.lsp.clone(); let registered_buffer = server .registered_buffers @@ -966,46 +905,31 @@ impl Copilot { let buffer = buffer.read(cx); let uri = registered_buffer.uri.clone(); let position = position.to_point_utf16(buffer); - let settings = language_settings( - buffer.language_at(position).map(|l| l.name()), - buffer.file(), - cx, - ); - let tab_size = settings.tab_size; - let hard_tabs = settings.hard_tabs; - let relative_path = buffer - .file() - .map_or(RelPath::empty().into(), |file| file.path().clone()); cx.background_spawn(async move { let (version, snapshot) = snapshot.await?; let result = lsp - .request::(request::GetCompletionsParams { - doc: request::GetCompletionsDocument { - uri, - tab_size: tab_size.into(), - indent_size: 1, - insert_spaces: !hard_tabs, - relative_path: relative_path.to_proto(), - position: point_to_lsp(position), - version: version.try_into().unwrap(), - }, + .request::(request::NextEditSuggestionsParams { + text_document: lsp::VersionedTextDocumentIdentifier { uri, version }, + position: point_to_lsp(position), }) .await .into_response() .context("copilot: get completions")?; let completions = result - .completions + .edits .into_iter() .map(|completion| { let start = snapshot .clip_point_utf16(point_from_lsp(completion.range.start), Bias::Left); let end = snapshot.clip_point_utf16(point_from_lsp(completion.range.end), Bias::Left); - Completion { - uuid: completion.uuid, + CopilotEditPrediction { + buffer: buffer_entity.clone(), range: snapshot.anchor_before(start)..snapshot.anchor_after(end), text: completion.text, + command: completion.command, + snapshot: snapshot.clone(), } }) .collect(); @@ -1013,6 +937,35 @@ impl Copilot { }) } + pub(crate) fn accept_completion( + &mut self, + completion: &CopilotEditPrediction, + cx: &mut Context, + ) -> Task> { + let server = match self.server.as_authenticated() { + Ok(server) => server, + Err(error) => return Task::ready(Err(error)), + }; + if let Some(command) = &completion.command { + let request = server + .lsp + .request::(lsp::ExecuteCommandParams { + command: command.command.clone(), + arguments: command.arguments.clone().unwrap_or_default(), + ..Default::default() + }); + cx.background_spawn(async move { + request + .await + .into_response() + .context("copilot: notify accepted")?; + Ok(()) + }) + } else { + Task::ready(Ok(())) + } + } + pub fn status(&self) -> Status { match &self.server { CopilotServer::Starting { task } => Status::Starting { task: task.clone() }, @@ -1235,7 +1188,10 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: .await; if should_install { node_runtime - .npm_install_packages(paths::copilot_dir(), &[(PACKAGE_NAME, &latest_version)]) + .npm_install_packages( + paths::copilot_dir(), + &[(PACKAGE_NAME, &latest_version.to_string())], + ) .await?; } @@ -1246,7 +1202,11 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: mod tests { use super::*; use gpui::TestAppContext; - use util::{path, paths::PathStyle, rel_path::rel_path}; + use util::{ + path, + paths::PathStyle, + rel_path::{RelPath, rel_path}, + }; #[gpui::test(iterations = 10)] async fn test_buffer_management(cx: &mut TestAppContext) { diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index d4051701f72331bf5fc25fcd634002f0206ba529..52a3631791ecaf4e1f7b2bc935be37816f2b25de 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -294,6 +294,10 @@ pub enum ChatMessage { content: ChatMessageContent, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_opaque: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_text: Option, }, User { content: ChatMessageContent, @@ -386,6 +390,8 @@ pub struct ResponseDelta { pub role: Option, #[serde(default)] pub tool_calls: Vec, + pub reasoning_opaque: Option, + pub reasoning_text: Option, } #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct ToolCallChunk { @@ -786,13 +792,13 @@ async fn stream_completion( is_user_initiated: bool, ) -> Result>> { let is_vision_request = request.messages.iter().any(|message| match message { - ChatMessage::User { content } - | ChatMessage::Assistant { content, .. } - | ChatMessage::Tool { content, .. } => { - matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. }))) - } - _ => false, - }); + ChatMessage::User { content } + | ChatMessage::Assistant { content, .. } + | ChatMessage::Tool { content, .. } => { + matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. }))) + } + _ => false, + }); let request_initiator = if is_user_initiated { "user" } else { "agent" }; diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs similarity index 74% rename from crates/copilot/src/copilot_completion_provider.rs rename to crates/copilot/src/copilot_edit_prediction_delegate.rs index e92f0c7d7dd7e51c4a8fdc19f34bd6eb4189c097..514e135cb4c34f6a1f49687fcd413113f78f9eae 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -1,53 +1,33 @@ -use crate::{Completion, Copilot}; +use crate::{Copilot, CopilotEditPrediction}; use anyhow::Result; -use edit_prediction::{Direction, EditPrediction, EditPredictionProvider}; -use gpui::{App, Context, Entity, EntityId, Task}; -use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings}; -use settings::Settings; -use std::{path::Path, time::Duration}; +use edit_prediction_types::{EditPrediction, EditPredictionDelegate, interpolate_edits}; +use gpui::{App, Context, Entity, Task}; +use language::{Anchor, Buffer, EditPreview, OffsetRangeExt}; +use std::{ops::Range, sync::Arc, time::Duration}; pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); -pub struct CopilotCompletionProvider { - cycled: bool, - buffer_id: Option, - completions: Vec, - active_completion_index: usize, - file_extension: Option, +pub struct CopilotEditPredictionDelegate { + completion: Option<(CopilotEditPrediction, EditPreview)>, pending_refresh: Option>>, - pending_cycling_refresh: Option>>, copilot: Entity, } -impl CopilotCompletionProvider { +impl CopilotEditPredictionDelegate { pub fn new(copilot: Entity) -> Self { Self { - cycled: false, - buffer_id: None, - completions: Vec::new(), - active_completion_index: 0, - file_extension: None, + completion: None, pending_refresh: None, - pending_cycling_refresh: None, copilot, } } - fn active_completion(&self) -> Option<&Completion> { - self.completions.get(self.active_completion_index) - } - - fn push_completion(&mut self, new_completion: Completion) { - for completion in &self.completions { - if completion.text == new_completion.text && completion.range == new_completion.range { - return; - } - } - self.completions.push(new_completion); + fn active_completion(&self) -> Option<&(CopilotEditPrediction, EditPreview)> { + self.completion.as_ref() } } -impl EditPredictionProvider for CopilotCompletionProvider { +impl EditPredictionDelegate for CopilotEditPredictionDelegate { fn name() -> &'static str { "copilot" } @@ -56,7 +36,7 @@ impl EditPredictionProvider for CopilotCompletionProvider { "Copilot" } - fn show_completions_in_menu() -> bool { + fn show_predictions_in_menu() -> bool { true } @@ -64,12 +44,8 @@ impl EditPredictionProvider for CopilotCompletionProvider { true } - fn supports_jump_to_edit() -> bool { - false - } - fn is_refreshing(&self, _cx: &App) -> bool { - self.pending_refresh.is_some() && self.completions.is_empty() + self.pending_refresh.is_some() && self.completion.is_none() } fn is_enabled( @@ -102,160 +78,96 @@ impl EditPredictionProvider for CopilotCompletionProvider { })? .await?; - this.update(cx, |this, cx| { - if !completions.is_empty() { - this.cycled = false; + if let Some(mut completion) = completions.into_iter().next() + && let Some(trimmed_completion) = cx + .update(|cx| trim_completion(&completion, cx)) + .ok() + .flatten() + { + let preview = buffer + .update(cx, |this, cx| { + this.preview_edits(Arc::from(std::slice::from_ref(&trimmed_completion)), cx) + })? + .await; + this.update(cx, |this, cx| { this.pending_refresh = None; - this.pending_cycling_refresh = None; - this.completions.clear(); - this.active_completion_index = 0; - this.buffer_id = Some(buffer.entity_id()); - this.file_extension = buffer.read(cx).file().and_then(|file| { - Some( - Path::new(file.file_name(cx)) - .extension()? - .to_str()? - .to_string(), - ) - }); - - for completion in completions { - this.push_completion(completion); - } + completion.range = trimmed_completion.0; + completion.text = trimmed_completion.1.to_string(); + this.completion = Some((completion, preview)); + cx.notify(); - } - })?; + })?; + } Ok(()) })); } - fn cycle( - &mut self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut Context, - ) { - if self.cycled { - match direction { - Direction::Prev => { - self.active_completion_index = if self.active_completion_index == 0 { - self.completions.len().saturating_sub(1) - } else { - self.active_completion_index - 1 - }; - } - Direction::Next => { - if self.completions.is_empty() { - self.active_completion_index = 0 - } else { - self.active_completion_index = - (self.active_completion_index + 1) % self.completions.len(); - } - } - } - - cx.notify(); - } else { - let copilot = self.copilot.clone(); - self.pending_cycling_refresh = Some(cx.spawn(async move |this, cx| { - let completions = copilot - .update(cx, |copilot, cx| { - copilot.completions_cycling(&buffer, cursor_position, cx) - })? - .await?; - - this.update(cx, |this, cx| { - this.cycled = true; - this.file_extension = buffer.read(cx).file().and_then(|file| { - Some( - Path::new(file.file_name(cx)) - .extension()? - .to_str()? - .to_string(), - ) - }); - for completion in completions { - this.push_completion(completion); - } - this.cycle(buffer, cursor_position, direction, cx); - })?; - - Ok(()) - })); - } - } - fn accept(&mut self, cx: &mut Context) { - if let Some(completion) = self.active_completion() { + if let Some((completion, _)) = self.active_completion() { self.copilot .update(cx, |copilot, cx| copilot.accept_completion(completion, cx)) .detach_and_log_err(cx); } } - fn discard(&mut self, cx: &mut Context) { - let settings = AllLanguageSettings::get_global(cx); - - let copilot_enabled = settings.show_edit_predictions(None, cx); - - if !copilot_enabled { - return; - } - - self.copilot - .update(cx, |copilot, cx| { - copilot.discard_completions(&self.completions, cx) - }) - .detach_and_log_err(cx); - } + fn discard(&mut self, _: &mut Context) {} fn suggest( &mut self, buffer: &Entity, - cursor_position: language::Anchor, + _: language::Anchor, cx: &mut Context, ) -> Option { let buffer_id = buffer.entity_id(); let buffer = buffer.read(cx); - let completion = self.active_completion()?; - if Some(buffer_id) != self.buffer_id + let (completion, edit_preview) = self.active_completion()?; + + if Some(buffer_id) != Some(completion.buffer.entity_id()) || !completion.range.start.is_valid(buffer) || !completion.range.end.is_valid(buffer) { return None; } + let edits = vec![( + completion.range.clone(), + Arc::from(completion.text.as_ref()), + )]; + let edits = interpolate_edits(&completion.snapshot, &buffer.snapshot(), &edits) + .filter(|edits| !edits.is_empty())?; + + Some(EditPrediction::Local { + id: None, + edits, + edit_preview: Some(edit_preview.clone()), + }) + } +} - let mut completion_range = completion.range.to_offset(buffer); - let prefix_len = common_prefix( - buffer.chars_for_range(completion_range.clone()), - completion.text.chars(), - ); - completion_range.start += prefix_len; - let suffix_len = common_prefix( - buffer.reversed_chars_for_range(completion_range.clone()), - completion.text[prefix_len..].chars().rev(), - ); - completion_range.end = completion_range.end.saturating_sub(suffix_len); - - if completion_range.is_empty() - && completion_range.start == cursor_position.to_offset(buffer) - { - let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len]; - if completion_text.trim().is_empty() { - None - } else { - let position = cursor_position.bias_right(buffer); - Some(EditPrediction::Local { - id: None, - edits: vec![(position..position, completion_text.into())], - edit_preview: None, - }) - } - } else { - None - } +fn trim_completion( + completion: &CopilotEditPrediction, + cx: &mut App, +) -> Option<(Range, Arc)> { + let buffer = completion.buffer.read(cx); + let mut completion_range = completion.range.to_offset(buffer); + let prefix_len = common_prefix( + buffer.chars_for_range(completion_range.clone()), + completion.text.chars(), + ); + completion_range.start += prefix_len; + let suffix_len = common_prefix( + buffer.reversed_chars_for_range(completion_range.clone()), + completion.text[prefix_len..].chars().rev(), + ); + completion_range.end = completion_range.end.saturating_sub(suffix_len); + let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len]; + if completion_text.trim().is_empty() { + None + } else { + let completion_range = + buffer.anchor_after(completion_range.start)..buffer.anchor_after(completion_range.end); + + Some((completion_range, Arc::from(completion_text))) } } @@ -269,6 +181,7 @@ fn common_prefix, T2: Iterator>(a: T1, b: #[cfg(test)] mod tests { use super::*; + use edit_prediction_types::EditPredictionGranularity; use editor::{ Editor, ExcerptRange, MultiBuffer, MultiBufferOffset, SelectionEffects, test::editor_lsp_test_context::EditorLspTestContext, @@ -281,6 +194,7 @@ mod tests { Point, language_settings::{CompletionSettingsContent, LspInsertMode, WordsCompletionMode}, }; + use lsp::Uri; use project::Project; use serde_json::json; use settings::{AllLanguageSettingsContent, SettingsStore}; @@ -314,7 +228,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -336,12 +250,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -382,12 +299,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -411,12 +331,15 @@ mod tests { // After debouncing, new Copilot completions should be requested. handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot2".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -478,45 +401,6 @@ mod tests { assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n"); }); - - // Reset the editor to verify how suggestions behave when tabbing on leading indentation. - cx.update_editor(|editor, window, cx| { - editor.set_text("fn foo() {\n \n}", window, cx); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([Point::new(1, 2)..Point::new(1, 2)]) - }); - }); - handle_copilot_completion_request( - &copilot_lsp, - vec![crate::request::Completion { - text: " let x = 4;".into(), - range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() - }], - vec![], - ); - - cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) - }); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - - // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion. - editor.tab(&Default::default(), window, cx); - assert!(editor.has_active_edit_prediction()); - assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - - // Using AcceptEditPrediction again accepts the suggestion. - editor.accept_edit_prediction(&Default::default(), window, cx); - assert!(!editor.has_active_edit_prediction()); - assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}"); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - }); } #[gpui::test(iterations = 10)] @@ -546,7 +430,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -569,25 +453,30 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); // Accepting the first word of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); // Accepting next word should accept the non-word and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); @@ -611,19 +500,22 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.123. copilot\n 456".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n"); assert_eq!( @@ -632,7 +524,7 @@ mod tests { ); // Accepting next word should accept the next word and copilot suggestion should still exist - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n"); assert_eq!( @@ -641,7 +533,7 @@ mod tests { ); // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n"); assert_eq!( @@ -670,7 +562,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -683,15 +575,18 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) + editor.show_edit_prediction(&Default::default(), window, cx) }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -700,15 +595,22 @@ mod tests { assert_eq!(editor.text(cx), "one\ntw\nthree\n"); editor.backspace(&Default::default(), window, cx); + }); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\nt\nthree\n"); editor.backspace(&Default::default(), window, cx); + }); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\n\nthree\n"); - // Deleting across the original suggestion range invalidates it. editor.backspace(&Default::default(), window, cx); assert!(!editor.has_active_edit_prediction()); @@ -750,10 +652,10 @@ mod tests { editor .update(cx, |editor, window, cx| { use gpui::Focusable; - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); }) .unwrap(); - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor .update(cx, |editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) @@ -762,19 +664,22 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "b = 2 + a".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); _ = editor.update(cx, |editor, window, cx| { // Ensure copilot suggestions are shown for the first excerpt. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(1, 5)..Point::new(1, 5)]) }); - editor.next_edit_prediction(&Default::default(), window, cx); + editor.show_edit_prediction(&Default::default(), window, cx); }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); _ = editor.update(cx, |editor, _, cx| { @@ -788,12 +693,15 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "d = 4 + c".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); _ = editor.update(cx, |editor, window, cx| { // Move to another excerpt, ensuring the suggestion gets cleared. @@ -848,7 +756,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -870,15 +778,18 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) + editor.show_edit_prediction(&Default::default(), window, cx) }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -900,12 +811,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -927,12 +841,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -997,10 +914,10 @@ mod tests { editor .update(cx, |editor, window, cx| { use gpui::Focusable; - window.focus(&editor.focus_handle(cx)) + window.focus(&editor.focus_handle(cx), cx) }) .unwrap(); - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor .update(cx, |editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) @@ -1008,16 +925,20 @@ mod tests { .unwrap(); let mut copilot_requests = copilot_lsp - .set_request_handler::( + .set_request_handler::( move |_params, _cx| async move { - Ok(crate::request::GetCompletionsResult { - completions: vec![crate::request::Completion { + Ok(crate::request::NextEditSuggestionsResult { + edits: vec![crate::request::NextEditSuggestion { text: "next line".into(), range: lsp::Range::new( lsp::Position::new(1, 0), lsp::Position::new(1, 0), ), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], }) }, @@ -1046,23 +967,14 @@ mod tests { fn handle_copilot_completion_request( lsp: &lsp::FakeLanguageServer, - completions: Vec, - completions_cycling: Vec, + completions: Vec, ) { - lsp.set_request_handler::(move |_params, _cx| { - let completions = completions.clone(); - async move { - Ok(crate::request::GetCompletionsResult { - completions: completions.clone(), - }) - } - }); - lsp.set_request_handler::( + lsp.set_request_handler::( move |_params, _cx| { - let completions_cycling = completions_cycling.clone(); + let completions = completions.clone(); async move { - Ok(crate::request::GetCompletionsResult { - completions: completions_cycling.clone(), + Ok(crate::request::NextEditSuggestionsResult { + edits: completions.clone(), }) } }, diff --git a/crates/copilot/src/copilot_responses.rs b/crates/copilot/src/copilot_responses.rs index 938577e224bcf4af440c3bd646cd1910ec1fbd13..2da2eb394b5fc5ba88c8dd3007df394a2dbc15bf 100644 --- a/crates/copilot/src/copilot_responses.rs +++ b/crates/copilot/src/copilot_responses.rs @@ -313,7 +313,8 @@ pub async fn stream_response( }; let is_streaming = request.stream; - let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; + let json = serde_json::to_string(&request)?; + let request = request_builder.body(AsyncBody::from(json))?; let mut response = client.send(request).await?; if !response.status().is_success() { diff --git a/crates/copilot/src/request.rs b/crates/copilot/src/request.rs index 85d6254dc060824a9b2686e8f53090fccb39980e..2f97fb72a42904b1fefdd3999f680fca12559ecd 100644 --- a/crates/copilot/src/request.rs +++ b/crates/copilot/src/request.rs @@ -1,3 +1,4 @@ +use lsp::VersionedTextDocumentIdentifier; use serde::{Deserialize, Serialize}; pub enum CheckStatus {} @@ -88,72 +89,6 @@ impl lsp::request::Request for SignOut { const METHOD: &'static str = "signOut"; } -pub enum GetCompletions {} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsParams { - pub doc: GetCompletionsDocument, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsDocument { - pub tab_size: u32, - pub indent_size: u32, - pub insert_spaces: bool, - pub uri: lsp::Uri, - pub relative_path: String, - pub position: lsp::Position, - pub version: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsResult { - pub completions: Vec, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Completion { - pub text: String, - pub position: lsp::Position, - pub uuid: String, - pub range: lsp::Range, - pub display_text: String, -} - -impl lsp::request::Request for GetCompletions { - type Params = GetCompletionsParams; - type Result = GetCompletionsResult; - const METHOD: &'static str = "getCompletions"; -} - -pub enum GetCompletionsCycling {} - -impl lsp::request::Request for GetCompletionsCycling { - type Params = GetCompletionsParams; - type Result = GetCompletionsResult; - const METHOD: &'static str = "getCompletionsCycling"; -} - -pub enum LogMessage {} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LogMessageParams { - pub level: u8, - pub message: String, - pub metadata_str: String, - pub extra: Vec, -} - -impl lsp::notification::Notification for LogMessage { - type Params = LogMessageParams; - const METHOD: &'static str = "LogMessage"; -} - pub enum StatusNotification {} #[derive(Debug, Serialize, Deserialize)] @@ -223,3 +158,36 @@ impl lsp::request::Request for NotifyRejected { type Result = String; const METHOD: &'static str = "notifyRejected"; } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestions; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestionsParams { + pub(crate) text_document: VersionedTextDocumentIdentifier, + pub(crate) position: lsp::Position, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestion { + pub text: String, + pub text_document: VersionedTextDocumentIdentifier, + pub range: lsp::Range, + pub command: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestionsResult { + pub edits: Vec, +} + +impl lsp::request::Request for NextEditSuggestions { + type Params = NextEditSuggestionsParams; + type Result = NextEditSuggestionsResult; + + const METHOD: &'static str = "textDocument/copilotInlineEdit"; +} diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index 464a114d4ea11bca5597a6a91fd831ade050baaa..4f71a34408e23f099d4d3c145d86af24e607e3c3 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -1,166 +1,159 @@ use crate::{Copilot, Status, request::PromptUserDeviceFlow}; +use anyhow::Context as _; use gpui::{ - Animation, AnimationExt, App, ClipboardItem, Context, DismissEvent, Element, Entity, - EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent, - ParentElement, Render, Styled, Subscription, Transformation, Window, div, percentage, svg, + App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled, + Subscription, Window, WindowBounds, WindowOptions, div, point, }; -use std::time::Duration; -use ui::{Button, Label, Vector, VectorName, prelude::*}; +use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; +use url::Url; use util::ResultExt as _; -use workspace::notifications::NotificationId; -use workspace::{ModalView, Toast, Workspace}; +use workspace::{Toast, Workspace, notifications::NotificationId}; const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot"; +const ERROR_LABEL: &str = + "Copilot had issues starting. You can try reinstalling it and signing in again."; struct CopilotStatusToast; pub fn initiate_sign_in(window: &mut Window, cx: &mut App) { + let is_reinstall = false; + initiate_sign_in_impl(is_reinstall, window, cx) +} + +pub fn initiate_sign_out(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; - let Some(workspace) = window.root::().flatten() else { - return; - }; - workspace.update(cx, |workspace, cx| { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx) - }); + + copilot_toast(Some("Signing out of Copilot…"), window, cx); + + let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); + window + .spawn(cx, async move |cx| match sign_out_task.await { + Ok(()) => { + cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx)) + } + Err(err) => cx.update(|window, cx| { + if let Some(workspace) = window.root::().flatten() { + workspace.update(cx, |workspace, cx| { + workspace.show_error(&err, cx); + }) + } else { + log::error!("{:?}", err); + } + }), + }) + .detach(); } pub fn reinstall_and_sign_in(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; + let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); + let is_reinstall = true; + initiate_sign_in_impl(is_reinstall, window, cx); +} + +fn open_copilot_code_verification_window(copilot: &Entity, window: &Window, cx: &mut App) { + let current_window_center = window.bounds().center(); + let height = px(450.); + let width = px(350.); + let window_bounds = WindowBounds::Windowed(gpui::bounds( + current_window_center - point(height / 2.0, width / 2.0), + gpui::size(height, width), + )); + cx.open_window( + WindowOptions { + kind: gpui::WindowKind::PopUp, + window_bounds: Some(window_bounds), + is_resizable: false, + is_movable: true, + titlebar: Some(gpui::TitlebarOptions { + appears_transparent: true, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)), + ) + .context("Failed to open Copilot code verification window") + .log_err(); +} + +fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) { + const NOTIFICATION_ID: NotificationId = NotificationId::unique::(); + let Some(workspace) = window.root::().flatten() else { return; }; - workspace.update(cx, |workspace, cx| { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - }); -} -pub fn reinstall_and_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - window: &mut Window, - cx: &mut Context, -) { - let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); - let is_reinstall = true; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); + workspace.update(cx, |workspace, cx| match message { + Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx), + None => workspace.dismiss_toast(&NOTIFICATION_ID, cx), + }); } -pub fn initiate_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - is_reinstall: bool, - window: &mut Window, - cx: &mut Context, -) { +pub fn initiate_sign_in_impl(is_reinstall: bool, window: &mut Window, cx: &mut App) { + let Some(copilot) = Copilot::global(cx) else { + return; + }; if matches!(copilot.read(cx).status(), Status::Disabled) { copilot.update(cx, |copilot, cx| copilot.start_copilot(false, true, cx)); } match copilot.read(cx).status() { Status::Starting { task } => { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - if is_reinstall { - "Copilot is reinstalling..." - } else { - "Copilot is starting..." - }, - ), + copilot_toast( + Some(if is_reinstall { + "Copilot is reinstalling…" + } else { + "Copilot is starting…" + }), + window, cx, ); - cx.spawn_in(window, async move |workspace, cx| { - task.await; - if let Some(copilot) = cx.update(|_window, cx| Copilot::global(cx)).ok().flatten() { - workspace - .update_in(cx, |workspace, window, cx| { - match copilot.read(cx).status() { - Status::Authorized => workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Copilot has started.", - ), - cx, - ), - _ => { - workspace.dismiss_toast( - &NotificationId::unique::(), - cx, - ); - copilot - .update(cx, |copilot, cx| copilot.sign_in(cx)) - .detach_and_log_err(cx); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); - } + window + .spawn(cx, async move |cx| { + task.await; + cx.update(|window, cx| { + let Some(copilot) = Copilot::global(cx) else { + return; + }; + match copilot.read(cx).status() { + Status::Authorized => { + copilot_toast(Some("Copilot has started."), window, cx) } - }) - .log_err(); - } - }) - .detach(); + _ => { + copilot_toast(None, window, cx); + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + open_copilot_code_verification_window(&copilot, window, cx); + } + } + }) + .log_err(); + }) + .detach(); } _ => { copilot .update(cx, |copilot, cx| copilot.sign_in(cx)) .detach(); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); + open_copilot_code_verification_window(&copilot, window, cx); } } } -pub fn sign_out_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - cx: &mut Context, -) { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signing out of Copilot...", - ), - cx, - ); - let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); - cx.spawn(async move |workspace, cx| match sign_out_task.await { - Ok(()) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signed out of Copilot.", - ), - cx, - ) - }) - .ok(); - } - Err(err) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_error(&err, cx); - }) - .ok(); - } - }) - .detach(); -} - pub struct CopilotCodeVerification { status: Status, connect_clicked: bool, focus_handle: FocusHandle, copilot: Entity, _subscription: Subscription, + sign_up_url: Option, } impl Focusable for CopilotCodeVerification { @@ -170,29 +163,44 @@ impl Focusable for CopilotCodeVerification { } impl EventEmitter for CopilotCodeVerification {} -impl ModalView for CopilotCodeVerification { - fn on_before_dismiss( - &mut self, - _: &mut Window, - cx: &mut Context, - ) -> workspace::DismissDecision { - self.copilot.update(cx, |copilot, cx| { - if matches!(copilot.status(), Status::SigningIn { .. }) { - copilot.sign_out(cx).detach_and_log_err(cx); + +impl CopilotCodeVerification { + pub fn new(copilot: &Entity, window: &mut Window, cx: &mut Context) -> Self { + window.on_window_should_close(cx, |window, cx| { + if let Some(this) = window.root::().flatten() { + this.update(cx, |this, cx| { + this.before_dismiss(cx); + }); } + true }); - workspace::DismissDecision::Dismiss(true) - } -} + cx.subscribe_in( + &cx.entity(), + window, + |this, _, _: &DismissEvent, window, cx| { + window.remove_window(); + this.before_dismiss(cx); + }, + ) + .detach(); -impl CopilotCodeVerification { - pub fn new(copilot: &Entity, cx: &mut Context) -> Self { let status = copilot.read(cx).status(); + // Determine sign-up URL based on verification_uri domain if available + let sign_up_url = if let Status::SigningIn { + prompt: Some(ref prompt), + } = status + { + // Extract domain from verification_uri to construct sign-up URL + Self::get_sign_up_url_from_verification(&prompt.verification_uri) + } else { + None + }; Self { status, connect_clicked: false, focus_handle: cx.focus_handle(), copilot: copilot.clone(), + sign_up_url, _subscription: cx.observe(copilot, |this, copilot, cx| { let status = copilot.read(cx).status(); match status { @@ -206,54 +214,74 @@ impl CopilotCodeVerification { } pub fn set_status(&mut self, status: Status, cx: &mut Context) { + // Update sign-up URL if we have a new verification URI + if let Status::SigningIn { + prompt: Some(ref prompt), + } = status + { + self.sign_up_url = Self::get_sign_up_url_from_verification(&prompt.verification_uri); + } self.status = status; cx.notify(); } + fn get_sign_up_url_from_verification(verification_uri: &str) -> Option { + // Extract domain from verification URI using url crate + if let Ok(url) = Url::parse(verification_uri) + && let Some(host) = url.host_str() + && !host.contains("github.com") + { + // For GHE, construct URL from domain + Some(format!("https://{}/features/copilot", host)) + } else { + None + } + } + fn render_device_code(data: &PromptUserDeviceFlow, cx: &mut Context) -> impl IntoElement { let copied = cx .read_from_clipboard() .map(|item| item.text().as_ref() == Some(&data.user_code)) .unwrap_or(false); - h_flex() - .w_full() - .p_1() - .border_1() - .border_muted(cx) - .rounded_sm() - .cursor_pointer() - .justify_between() - .on_mouse_down(gpui::MouseButton::Left, { + + ButtonLike::new("copy-button") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .p_1() + .justify_between() + .child(Label::new(data.user_code.clone())) + .child(Label::new(if copied { "Copied!" } else { "Copy" })), + ) + .on_click({ let user_code = data.user_code.clone(); move |_, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone())); window.refresh(); } }) - .child(div().flex_1().child(Label::new(data.user_code.clone()))) - .child(div().flex_none().px_1().child(Label::new(if copied { - "Copied!" - } else { - "Copy" - }))) } fn render_prompting_modal( connect_clicked: bool, data: &PromptUserDeviceFlow, - cx: &mut Context, ) -> impl Element { let connect_button_label = if connect_clicked { - "Waiting for connection..." + "Waiting for connection…" } else { "Connect to GitHub" }; + v_flex() .flex_1() - .gap_2() + .gap_2p5() .items_center() - .child(Headline::new("Use GitHub Copilot in Zed.").size(HeadlineSize::Large)) + .text_center() + .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large)) .child( Label::new("Using Copilot requires an active subscription on GitHub.") .color(Color::Muted), @@ -261,110 +289,154 @@ impl CopilotCodeVerification { .child(Self::render_device_code(data, cx)) .child( Label::new("Paste this code into GitHub after clicking the button below.") - .size(ui::LabelSize::Small), - ) - .child( - Button::new("connect-button", connect_button_label) - .on_click({ - let verification_uri = data.verification_uri.clone(); - cx.listener(move |this, _, _window, cx| { - cx.open_url(&verification_uri); - this.connect_clicked = true; - }) - }) - .full_width() - .style(ButtonStyle::Filled), + .color(Color::Muted), ) .child( - Button::new("copilot-enable-cancel-button", "Cancel") - .full_width() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })), + v_flex() + .w_full() + .gap_1() + .child( + Button::new("connect-button", connect_button_label) + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click({ + let verification_uri = data.verification_uri.clone(); + cx.listener(move |this, _, _window, cx| { + cx.open_url(&verification_uri); + this.connect_clicked = true; + }) + }), + ) + .child( + Button::new("copilot-enable-cancel-button", "Cancel") + .full_width() + .size(ButtonSize::Medium) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); + })), + ), ) } fn render_enabled_modal(cx: &mut Context) -> impl Element { v_flex() .gap_2() + .text_center() + .justify_center() .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) - .child(Label::new( - "You can update your settings or sign out from the Copilot menu in the status bar.", - )) + .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted)) .child( Button::new("copilot-enabled-done-button", "Done") .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } - fn render_unauthorized_modal(cx: &mut Context) -> impl Element { - v_flex() - .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large)) + fn render_unauthorized_modal(&self, cx: &mut Context) -> impl Element { + let sign_up_url = self + .sign_up_url + .as_deref() + .unwrap_or(COPILOT_SIGN_UP_URL) + .to_owned(); + let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription."; - .child(Label::new( - "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.", - ).color(Color::Warning)) + v_flex() + .gap_2() + .text_center() + .justify_center() + .child( + Headline::new("You must have an active GitHub Copilot subscription.") + .size(HeadlineSize::Large), + ) + .child(Label::new(description).color(Color::Warning)) .child( Button::new("copilot-subscribe-button", "Subscribe on GitHub") .full_width() - .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)), + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click(move |_, _, cx| cx.open_url(&sign_up_url)), ) .child( Button::new("copilot-subscribe-cancel-button", "Cancel") .full_width() + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } - fn render_loading(window: &mut Window, _: &mut Context) -> impl Element { - let loading_icon = svg() - .size_8() - .path(IconName::ArrowCircle.path()) - .text_color(window.text_style().color) - .with_animation( - "icon_circle_arrow", - Animation::new(Duration::from_secs(2)).repeat(), - |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))), - ); + fn render_error_modal(_cx: &mut Context) -> impl Element { + v_flex() + .gap_2() + .text_center() + .justify_center() + .child(Headline::new("An Error Happened").size(HeadlineSize::Large)) + .child(Label::new(ERROR_LABEL).color(Color::Muted)) + .child( + Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In") + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)), + ) + } - h_flex().justify_center().child(loading_icon) + fn before_dismiss( + &mut self, + cx: &mut Context<'_, CopilotCodeVerification>, + ) -> workspace::DismissDecision { + self.copilot.update(cx, |copilot, cx| { + if matches!(copilot.status(), Status::SigningIn { .. }) { + copilot.sign_out(cx).detach_and_log_err(cx); + } + }); + workspace::DismissDecision::Dismiss(true) } } impl Render for CopilotCodeVerification { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let prompt = match &self.status { - Status::SigningIn { prompt: None } => { - Self::render_loading(window, cx).into_any_element() - } + Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), Status::SigningIn { prompt: Some(prompt), } => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(), Status::Unauthorized => { self.connect_clicked = false; - Self::render_unauthorized_modal(cx).into_any_element() + self.render_unauthorized_modal(cx).into_any_element() } Status::Authorized => { self.connect_clicked = false; Self::render_enabled_modal(cx).into_any_element() } + Status::Error(..) => Self::render_error_modal(cx).into_any_element(), _ => div().into_any_element(), }; v_flex() - .id("copilot code verification") + .id("copilot_code_verification") .track_focus(&self.focus_handle(cx)) - .elevation_3(cx) - .w_96() - .items_center() - .p_4() + .size_full() + .px_4() + .py_8() .gap_2() + .items_center() + .justify_center() + .elevation_3(cx) .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| { - window.focus(&this.focus_handle); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); })) .child( Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.)) @@ -373,3 +445,243 @@ impl Render for CopilotCodeVerification { .child(prompt) } } + +pub struct ConfigurationView { + copilot_status: Option, + is_authenticated: fn(cx: &App) -> bool, + edit_prediction: bool, + _subscription: Option, +} + +pub enum ConfigurationMode { + Chat, + EditPrediction, +} + +impl ConfigurationView { + pub fn new( + is_authenticated: fn(cx: &App) -> bool, + mode: ConfigurationMode, + cx: &mut Context, + ) -> Self { + let copilot = Copilot::global(cx); + + Self { + copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()), + is_authenticated, + edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), + _subscription: copilot.as_ref().map(|copilot| { + cx.observe(copilot, |this, model, cx| { + this.copilot_status = Some(model.read(cx).status()); + cx.notify(); + }) + }), + } + } +} + +impl ConfigurationView { + fn is_starting(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Starting { .. })) + } + + fn is_signing_in(&self) -> bool { + matches!( + &self.copilot_status, + Some(Status::SigningIn { .. }) + | Some(Status::SignedOut { + awaiting_signing_in: true + }) + ) + } + + fn is_error(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Error(_))) + } + + fn has_no_status(&self) -> bool { + self.copilot_status.is_none() + } + + fn loading_message(&self) -> Option { + if self.is_starting() { + Some("Starting Copilot…".into()) + } else if self.is_signing_in() { + Some("Signing into Copilot…".into()) + } else { + None + } + } + + fn render_loading_button( + &self, + label: impl Into, + edit_prediction: bool, + ) -> impl IntoElement { + ButtonLike::new("loading_button") + .disabled(true) + .style(ButtonStyle::Outlined) + .when(edit_prediction, |this| this.size(ButtonSize::Medium)) + .child( + h_flex() + .w_full() + .gap_1() + .justify_center() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(4), + ) + .child(Label::new(label)), + ) + } + + fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Sign in to GitHub" + } else { + "Sign in to use GitHub Copilot" + }; + + Button::new("sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Github) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| initiate_sign_in(window, cx)) + } + + fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Reinstall and Sign in" + } else { + "Reinstall Copilot and Sign in" + }; + + Button::new("reinstall_and_sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)) + } + + fn render_for_edit_prediction(&self) -> impl IntoElement { + let container = |description: SharedString, action: AnyElement| { + h_flex() + .pt_2p5() + .w_full() + .justify_between() + .child( + v_flex() + .w_full() + .max_w_1_2() + .child(Label::new("Authenticate To Use")) + .child( + Label::new(description) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child(action) + }; + + let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into(); + let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into(); + + if let Some(msg) = self.loading_message() { + container( + start_label, + self.render_loading_button(msg, true).into_any_element(), + ) + .into_any_element() + } else if self.is_error() { + container( + ERROR_LABEL.into(), + self.render_reinstall_button(true).into_any_element(), + ) + .into_any_element() + } else if self.has_no_status() { + container( + no_status_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } else { + container( + start_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } + } + + fn render_for_chat(&self) -> impl IntoElement { + let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; + let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider."; + + if let Some(msg) = self.loading_message() { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_loading_button(msg, false)) + .into_any_element() + } else if self.is_error() { + v_flex() + .gap_2() + .child(Label::new(ERROR_LABEL)) + .child(self.render_reinstall_button(false)) + .into_any_element() + } else if self.has_no_status() { + v_flex() + .gap_2() + .child(Label::new(no_status_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } else { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_authenticated = self.is_authenticated; + + if is_authenticated(cx) { + return ConfiguredApiCard::new("Authorized") + .button_label("Sign Out") + .on_click(|_, window, cx| { + initiate_sign_out(window, cx); + }) + .into_any_element(); + } + + if self.edit_prediction { + self.render_for_edit_prediction().into_any_element() + } else { + self.render_for_chat().into_any_element() + } + } +} diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index 3f85039e9ea3bce8e702991461adec4a931d3e4a..bd1c1121848e34349b5cd58c0fa033d380fa791b 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -23,6 +23,9 @@ zstd.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + [lints] workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index baf0bcde3b0769c4fc6cf958c86e181cda615683..4c601c393004beca1d5e550e1eeae7f126751448 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -3,6 +3,8 @@ use log::info; use minidumper::{Client, LoopAction, MinidumpBinary}; use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use serde::{Deserialize, Serialize}; + +#[cfg(not(target_os = "windows"))] use smol::process::Command; #[cfg(target_os = "macos")] @@ -70,11 +72,16 @@ pub async fn init(crash_init: InitCrashHandler) { // used by the crash handler isn't destroyed correctly which causes it to stay on the file // system and block further attempts to initialize crash handlers with that socket path. let socket_name = paths::temp_dir().join(format!("zed-crash-handler-{zed_pid}")); + #[cfg(not(target_os = "windows"))] let _crash_handler = Command::new(exe) .arg("--crash-handler") .arg(&socket_name) .spawn() .expect("unable to spawn server process"); + + #[cfg(target_os = "windows")] + spawn_crash_handler_windows(&exe, &socket_name); + #[cfg(target_os = "linux")] let server_pid = _crash_handler.id(); info!("spawning crash handler process"); @@ -342,6 +349,57 @@ pub fn panic_hook(info: &PanicHookInfo) { } } +#[cfg(target_os = "windows")] +fn spawn_crash_handler_windows(exe: &Path, socket_name: &Path) { + use std::ffi::OsStr; + use std::iter::once; + use std::os::windows::ffi::OsStrExt; + use windows::Win32::System::Threading::{ + CreateProcessW, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_FORCEOFFFEEDBACK, + STARTUPINFOW, + }; + use windows::core::PWSTR; + + let mut command_line: Vec = OsStr::new(&format!( + "\"{}\" --crash-handler \"{}\"", + exe.display(), + socket_name.display() + )) + .encode_wide() + .chain(once(0)) + .collect(); + + let mut startup_info = STARTUPINFOW::default(); + startup_info.cb = std::mem::size_of::() as u32; + + // By default, Windows enables a "busy" cursor when a GUI application is launched. + // This cursor is disabled once the application starts processing window messages. + // Since the crash handler process doesn't process messages, this "busy" cursor stays enabled for a long time. + // Disable the cursor feedback to prevent this from happening. + startup_info.dwFlags = STARTF_FORCEOFFFEEDBACK; + + let mut process_info = PROCESS_INFORMATION::default(); + + unsafe { + CreateProcessW( + None, + Some(PWSTR(command_line.as_mut_ptr())), + None, + None, + false, + PROCESS_CREATION_FLAGS(0), + None, + None, + &startup_info, + &mut process_info, + ) + .expect("unable to spawn server process"); + + windows::Win32::Foundation::CloseHandle(process_info.hProcess).ok(); + windows::Win32::Foundation::CloseHandle(process_info.hThread).ok(); + } +} + pub fn crash_server(socket: &Path) { let Ok(mut server) = minidumper::Server::with_name(socket) else { log::info!("Couldn't create socket, there may already be a running crash server"); diff --git a/crates/dap_adapters/src/go.rs b/crates/dap_adapters/src/go.rs index 323ca094934fc93466451246f4bc69f34ded4891..d3253d5fe250f7228ebddec15a691ac650a19c89 100644 --- a/crates/dap_adapters/src/go.rs +++ b/crates/dap_adapters/src/go.rs @@ -366,7 +366,7 @@ impl DebugAdapter for GoDebugAdapter { dap::DebugRequest::Attach(attach_config) => { json!({ "request": "attach", - "mode": "debug", + "mode": "local", "processId": attach_config.process_id, }) } diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index 2f84193ac9343cac9ff1cf52eb648bad1cd77896..a45e16dc32180e1e4ed3b2ec01c92ad07ffc5e93 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -870,7 +870,7 @@ impl DebugAdapter for PythonDebugAdapter { .active_toolchain( delegate.worktree_id(), base_path.into_arc(), - language::LanguageName::new(Self::LANGUAGE_NAME), + language::LanguageName::new_static(Self::LANGUAGE_NAME), cx, ) .await diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index 4c994ad7eb749dcb5828daa83bad34a579f9f14c..317ce8b4c65e441f1fc4041706989532aa150204 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -998,7 +998,11 @@ impl Item for DapLogView { None } - fn as_searchable(&self, handle: &Entity) -> Option> { + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { Some(Box::new(handle.clone())) } } @@ -1013,11 +1017,13 @@ impl SearchableItem for DapLogView { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |e, cx| e.update_matches(matches, window, cx)) + self.editor.update(cx, |e, cx| { + e.update_matches(matches, active_match_index, window, cx) + }) } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index 325bcc300ae637ab46c36b7a3e7875e197f7d3d2..fb79b1b0790b28d7204774720bf9c413cfed64e6 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -37,6 +37,7 @@ dap_adapters = { workspace = true, optional = true } db.workspace = true debugger_tools.workspace = true editor.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true @@ -82,6 +83,7 @@ dap_adapters = { workspace = true, features = ["test-support"] } debugger_tools = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } tree-sitter-go.workspace = true unindent.workspace = true diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 64146169f53cfe44c3bdcb59b93e78d0f9223abd..6e537ae0c6e1db7418596cf48b51ca22df30be57 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -317,7 +317,7 @@ impl PickerDelegate for AttachModalDelegate { let candidate = self.candidates.get(hit.candidate_id)?; Some( - ListItem::new(SharedString::from(format!("process-entry-{ix}"))) + ListItem::new(format!("process-entry-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) @@ -327,7 +327,7 @@ impl PickerDelegate for AttachModalDelegate { .child(Label::new(format!("{} {}", candidate.name, candidate.pid))) .child( div() - .id(SharedString::from(format!("process-entry-{ix}-command"))) + .id(format!("process-entry-{ix}-command")) .tooltip(Tooltip::text( candidate .command diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 3890fa6326329d0d72aa6f81c6b94e7c2f364d34..35ce80d3f64e362735c1c020363dbbfc2703a101 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -15,10 +15,11 @@ use dap::adapters::DebugAdapterName; use dap::{DapRegistry, StartDebuggingRequestArguments}; use dap::{client::SessionId, debugger_settings::DebuggerSettings}; use editor::{Editor, MultiBufferOffset, ToPoint}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; use gpui::{ - Action, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task, - WeakEntity, anchored, deferred, + Action, App, AsyncWindowContext, ClipboardItem, Context, Corner, DismissEvent, Entity, + EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, + Subscription, Task, WeakEntity, anchored, deferred, }; use itertools::Itertools as _; @@ -31,7 +32,9 @@ use settings::Settings; use std::sync::{Arc, LazyLock}; use task::{DebugScenario, TaskContext}; use tree_sitter::{Query, StreamingIterator as _}; -use ui::{ContextMenu, Divider, PopoverMenuHandle, Tab, Tooltip, prelude::*}; +use ui::{ + ContextMenu, Divider, PopoverMenu, PopoverMenuHandle, SplitButton, Tab, Tooltip, prelude::*, +}; use util::rel_path::RelPath; use util::{ResultExt, debug_panic, maybe}; use workspace::SplitDirection; @@ -42,6 +45,12 @@ use workspace::{ }; use zed_actions::ToggleFocus; +pub struct DebuggerHistoryFeatureFlag; + +impl FeatureFlag for DebuggerHistoryFeatureFlag { + const NAME: &'static str = "debugger-history"; +} + const DEBUG_PANEL_KEY: &str = "DebugPanel"; pub struct DebugPanel { @@ -284,7 +293,7 @@ impl DebugPanel { } }); - session.update(cx, |session, _| match &mut session.mode { + session.update(cx, |session, _| match &mut session.state { SessionState::Booting(state_task) => { *state_task = Some(boot_task); } @@ -568,7 +577,7 @@ impl DebugPanel { menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { this.context_menu.take(); cx.notify(); @@ -662,6 +671,12 @@ impl DebugPanel { ) }; + let thread_status = active_session + .as_ref() + .map(|session| session.read(cx).running_state()) + .and_then(|state| state.read(cx).thread_status(cx)) + .unwrap_or(project::debugger::session::ThreadStatus::Exited); + Some( div.w_full() .py_1() @@ -679,10 +694,6 @@ impl DebugPanel { .as_ref() .map(|session| session.read(cx).running_state()), |this, running_state| { - let thread_status = - running_state.read(cx).thread_status(cx).unwrap_or( - project::debugger::session::ThreadStatus::Exited, - ); let capabilities = running_state.read(cx).capabilities(cx); let supports_detach = running_state.read(cx).session().read(cx).is_attached(); @@ -740,7 +751,7 @@ impl DebugPanel { } }) .child( - IconButton::new("debug-step-over", IconName::ArrowRight) + IconButton::new("step-over", IconName::DebugStepOver) .icon_size(IconSize::Small) .on_click(window.listener_for( running_state, @@ -762,32 +773,29 @@ impl DebugPanel { }), ) .child( - IconButton::new( - "debug-step-into", - IconName::ArrowDownRight, - ) - .icon_size(IconSize::Small) - .on_click(window.listener_for( - running_state, - |this, _, _window, cx| { - this.step_in(cx); - }, - )) - .disabled(thread_status != ThreadStatus::Stopped) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Step In", - &StepInto, - &focus_handle, - cx, - ) - } - }), + IconButton::new("step-into", IconName::DebugStepInto) + .icon_size(IconSize::Small) + .on_click(window.listener_for( + running_state, + |this, _, _window, cx| { + this.step_in(cx); + }, + )) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Step In", + &StepInto, + &focus_handle, + cx, + ) + } + }), ) .child( - IconButton::new("debug-step-out", IconName::ArrowUpRight) + IconButton::new("step-out", IconName::DebugStepOut) .icon_size(IconSize::Small) .on_click(window.listener_for( running_state, @@ -874,36 +882,53 @@ impl DebugPanel { } }), ) + .when(supports_detach, |div| { + div.child( + IconButton::new( + "debug-disconnect", + IconName::DebugDetach, + ) + .disabled( + thread_status != ThreadStatus::Stopped + && thread_status != ThreadStatus::Running, + ) + .icon_size(IconSize::Small) + .on_click(window.listener_for( + running_state, + |this, _, _, cx| { + this.detach_client(cx); + }, + )) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Detach", + &Detach, + &focus_handle, + cx, + ) + } + }), + ) + }) .when( - supports_detach, - |div| { - div.child( - IconButton::new( - "debug-disconnect", - IconName::DebugDetach, - ) - .disabled( - thread_status != ThreadStatus::Stopped - && thread_status != ThreadStatus::Running, + cx.has_flag::(), + |this| { + this.child(Divider::vertical()).child( + SplitButton::new( + self.render_history_button( + &running_state, + thread_status, + window, + ), + self.render_history_toggle_button( + thread_status, + &running_state, + ) + .into_any_element(), ) - .icon_size(IconSize::Small) - .on_click(window.listener_for( - running_state, - |this, _, _, cx| { - this.detach_client(cx); - }, - )) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Detach", - &Detach, - &focus_handle, - cx, - ) - } - }), + .style(ui::SplitButtonStyle::Outlined), ) }, ) @@ -1027,7 +1052,7 @@ impl DebugPanel { cx: &mut Context, ) { debug_assert!(self.sessions_with_children.contains_key(&session_item)); - session_item.focus_handle(cx).focus(window); + session_item.focus_handle(cx).focus(window, cx); session_item.update(cx, |this, cx| { this.running_state().update(cx, |this, cx| { this.go_to_selected_stack_frame(window, cx); @@ -1320,6 +1345,97 @@ impl DebugPanel { }); } } + + fn render_history_button( + &self, + running_state: &Entity, + thread_status: ThreadStatus, + window: &mut Window, + ) -> IconButton { + IconButton::new("debug-back-in-history", IconName::HistoryRerun) + .icon_size(IconSize::Small) + .on_click(window.listener_for(running_state, |this, _, _window, cx| { + this.session().update(cx, |session, cx| { + let ix = session + .active_snapshot_index() + .unwrap_or_else(|| session.historic_snapshots().len()); + + session.select_historic_snapshot(Some(ix.saturating_sub(1)), cx); + }) + })) + .disabled( + thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, + ) + } + + fn render_history_toggle_button( + &self, + thread_status: ThreadStatus, + running_state: &Entity, + ) -> impl IntoElement { + PopoverMenu::new("debug-back-in-history-menu") + .trigger( + ui::ButtonLike::new_rounded_right("debug-back-in-history-menu-trigger") + .layer(ui::ElevationIndex::ModalSurface) + .size(ui::ButtonSize::None) + .child( + div() + .px_1() + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), + ) + .disabled( + thread_status == ThreadStatus::Running + || thread_status == ThreadStatus::Stepping, + ), + ) + .menu({ + let running_state = running_state.clone(); + move |window, cx| { + let handler = + |ix: Option, running_state: Entity, cx: &mut App| { + running_state.update(cx, |state, cx| { + state.session().update(cx, |session, cx| { + session.select_historic_snapshot(ix, cx); + }) + }) + }; + + let running_state = running_state.clone(); + Some(ContextMenu::build( + window, + cx, + move |mut context_menu, _window, cx| { + let history = running_state + .read(cx) + .session() + .read(cx) + .historic_snapshots(); + + context_menu = context_menu.entry("Current State", None, { + let running_state = running_state.clone(); + move |_window, cx| { + handler(None, running_state.clone(), cx); + } + }); + context_menu = context_menu.separator(); + + for (ix, _) in history.iter().enumerate().rev() { + context_menu = + context_menu.entry(format!("history-{}", ix + 1), None, { + let running_state = running_state.clone(); + move |_window, cx| { + handler(Some(ix), running_state.clone(), cx); + } + }); + } + + context_menu + }, + )) + } + }) + .anchor(Corner::TopRight) + } } async fn register_session_inner( @@ -1441,7 +1557,7 @@ impl Panel for DebugPanel { self.sessions_with_children.keys().for_each(|session_item| { session_item.update(cx, |item, cx| { item.running_state() - .update(cx, |state, _| state.invert_axies()) + .update(cx, |state, cx| state.invert_axies(cx)) }) }); } diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index a9abb50bb68851334285b05064176e0347474014..bd5a7cda4a21a3d3fd0ac132d6ba2e7aace68722 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -387,7 +387,7 @@ pub fn init(cx: &mut App) { window.on_action( TypeId::of::(), move |_, _, window, cx| { - maybe!({ + let status = maybe!({ let text = editor .update(cx, |editor, cx| { let range = editor @@ -411,7 +411,13 @@ pub fn init(cx: &mut App) { state.session().update(cx, |session, cx| { session - .evaluate(text, None, stack_id, None, cx) + .evaluate( + text, + Some(dap::EvaluateArgumentsContext::Repl), + stack_id, + None, + cx, + ) .detach(); }); }); @@ -419,6 +425,9 @@ pub fn init(cx: &mut App) { Some(()) }); + if status.is_some() { + cx.stop_propagation(); + } }, ); }) diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 40187cef9cc55cb4192a3cea773f42dca15a2571..68e391562b57d530a21624b0626173eeb7a67c16 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -574,7 +574,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch => NewProcessMode::Task, }; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); })) .on_action( cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| { @@ -585,7 +585,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch => NewProcessMode::Attach, }; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); }), ) .child( @@ -602,7 +602,7 @@ impl Render for NewProcessModal { NewProcessMode::Task.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Task; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -611,7 +611,7 @@ impl Render for NewProcessModal { NewProcessMode::Debug.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Debug; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -629,7 +629,7 @@ impl Render for NewProcessModal { cx, ); } - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -638,7 +638,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Launch; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -840,17 +840,17 @@ impl ConfigureMode { } } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); } fn on_tab_prev( &mut self, _: &menu::SelectPrevious, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) { - window.focus_prev(); + window.focus_prev(cx); } fn render( @@ -881,7 +881,6 @@ impl ConfigureMode { .label("Stop on Entry") .label_position(SwitchLabelPosition::Start) .label_size(LabelSize::Default) - .color(ui::SwitchColor::Accent) .on_click({ let this = cx.weak_entity(); move |state, _, cx| { @@ -924,7 +923,7 @@ impl AttachMode { window, cx, ); - window.focus(&modal.focus_handle(cx)); + window.focus(&modal.focus_handle(cx), cx); modal }); @@ -1023,7 +1022,7 @@ impl DebugDelegate { Some(TaskSourceKind::Lsp { language_name, .. }) => { Some(format!("LSP: {language_name}")) } - Some(TaskSourceKind::Language { name }) => Some(format!("Lang: {name}")), + Some(TaskSourceKind::Language { name }) => Some(format!("Language: {name}")), _ => context.clone().and_then(|ctx| { ctx.task_context .task_variables @@ -1520,7 +1519,7 @@ impl PickerDelegate for DebugDelegate { }); Some( - ListItem::new(SharedString::from(format!("debug-scenario-selection-{ix}"))) + ListItem::new(format!("debug-scenario-selection-{ix}")) .inset(true) .start_slot::(icon) .spacing(ListItemSpacing::Sparse) diff --git a/crates/debugger_ui/src/onboarding_modal.rs b/crates/debugger_ui/src/onboarding_modal.rs index 18205209983421691046e8a9d93eb6de32cd4563..b6f1ab944183c4f44d2bc5f6855731abb65ce1f7 100644 --- a/crates/debugger_ui/src/onboarding_modal.rs +++ b/crates/debugger_ui/src/onboarding_modal.rs @@ -83,8 +83,8 @@ impl Render for DebuggerOnboardingModal { debugger_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index b82f839edee82f884c1419d44a2344c39c8abd0d..422207d3cbf4880e0c8e3c02e01dbe373800ea62 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -286,10 +286,10 @@ impl Item for SubView { impl Render for SubView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .id(SharedString::from(format!( + .id(format!( "subview-container-{}", self.kind.to_shared_string() - ))) + )) .on_hover(cx.listener(|this, hovered, _, cx| { this.hovered = *hovered; cx.notify(); @@ -348,7 +348,7 @@ pub(crate) fn new_debugger_pane( debug_assert!(_previous_subscription.is_none()); running .panes - .split(&this_pane, &new_pane, split_direction)?; + .split(&this_pane, &new_pane, split_direction, cx)?; anyhow::Ok(new_pane) }) }) @@ -484,10 +484,7 @@ pub(crate) fn new_debugger_pane( let deemphasized = !pane.has_focus(window, cx); let item_ = item.boxed_clone(); div() - .id(SharedString::from(format!( - "debugger_tab_{}", - item.item_id().as_u64() - ))) + .id(format!("debugger_tab_{}", item.item_id().as_u64())) .p_1() .rounded_md() .cursor_pointer() @@ -607,7 +604,7 @@ impl DebugTerminal { let focus_handle = cx.focus_handle(); let focus_subscription = cx.on_focus(&focus_handle, window, |this, window, cx| { if let Some(terminal) = this.terminal.as_ref() { - terminal.focus_handle(cx).focus(window); + terminal.focus_handle(cx).focus(window, cx); } }); @@ -1465,7 +1462,7 @@ impl RunningState { this.serialize_layout(window, cx); match event { Event::Remove { .. } => { - let _did_find_pane = this.panes.remove(source_pane).is_ok(); + let _did_find_pane = this.panes.remove(source_pane, cx).is_ok(); debug_assert!(_did_find_pane); cx.notify(); } @@ -1743,7 +1740,7 @@ impl RunningState { let is_building = self.session.update(cx, |session, cx| { session.shutdown(cx).detach(); - matches!(session.mode, session::SessionState::Booting(_)) + matches!(session.state, session::SessionState::Booting(_)) }); if is_building { @@ -1892,9 +1889,9 @@ impl RunningState { Member::Axis(group_root) } - pub(crate) fn invert_axies(&mut self) { + pub(crate) fn invert_axies(&mut self, cx: &mut App) { self.dock_axis = self.dock_axis.invert(); - self.panes.invert_axies(); + self.panes.invert_axies(cx); } } diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index ca50f67c9236d19a9f04f327091eb383ab72e122..f154757429a2bbfe153ee40c2c513dd06f05aa03 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -310,7 +310,7 @@ impl BreakpointList { fn dismiss(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { if self.input.focus_handle(cx).contains_focused(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else if self.strip_mode.is_some() { self.strip_mode.take(); cx.notify(); @@ -364,9 +364,9 @@ impl BreakpointList { } } } - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else { - handle.focus(window); + handle.focus(window, cx); } return; @@ -575,7 +575,7 @@ impl BreakpointList { ) .with_horizontal_sizing_behavior(gpui::ListHorizontalSizingBehavior::Unconstrained) .with_width_from_item(self.max_width_index) - .track_scroll(self.scroll_handle.clone()) + .track_scroll(&self.scroll_handle) .flex_1() } @@ -627,7 +627,7 @@ impl BreakpointList { .on_click({ let focus_handle = focus_handle.clone(); move |_, window, cx| { - focus_handle.focus(window); + focus_handle.focus(window, cx); window.dispatch_action(ToggleEnableBreakpoint.boxed_clone(), cx) } }), @@ -654,7 +654,7 @@ impl BreakpointList { ) .on_click({ move |_, window, cx| { - focus_handle.focus(window); + focus_handle.focus(window, cx); window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx) } }), @@ -776,7 +776,7 @@ impl Render for BreakpointList { .child(self.render_list(cx)) .custom_scrollbars( ui::Scrollbars::new(ScrollAxes::Both) - .tracked_scroll_handle(self.scroll_handle.clone()) + .tracked_scroll_handle(&self.scroll_handle) .with_track_along(ScrollAxes::Both, cx.theme().colors().panel_background) .tracked_entity(cx.entity_id()), window, diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 2939079f256d4c2742e514f002a4c9fe5e58b49a..040953bff6e8f0efa6045c1629c964ac98929547 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -18,14 +18,14 @@ use gpui::{ use language::{Anchor, Buffer, CharScopeContext, CodeLabel, TextBufferSnapshot, ToOffset}; use menu::{Confirm, SelectNext, SelectPrevious}; use project::{ - Completion, CompletionDisplayOptions, CompletionResponse, + CompletionDisplayOptions, CompletionResponse, debugger::session::{CompletionsQuery, OutputToken, Session}, lsp_store::CompletionDocumentation, search_history::{SearchHistory, SearchHistoryCursor}, }; use settings::Settings; use std::fmt::Write; -use std::{cell::RefCell, ops::Range, rc::Rc, usize}; +use std::{ops::Range, rc::Rc, usize}; use theme::{Theme, ThemeSettings}; use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*}; use util::ResultExt; @@ -105,7 +105,7 @@ impl Console { cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events), cx.on_focus(&focus_handle, window, |console, window, cx| { if console.is_running(cx) { - console.query_bar.focus_handle(cx).focus(window); + console.query_bar.focus_handle(cx).focus(window, cx); } }), ]; @@ -252,10 +252,11 @@ impl Console { let start_offset = range.start; let range = buffer.anchor_after(MultiBufferOffset(range.start)) ..buffer.anchor_before(MultiBufferOffset(range.end)); + let color_fn = color_fetcher(color); console.highlight_background_key::( start_offset, &[range], - color_fetcher(color), + move |_, theme| color_fn(theme), cx, ); } @@ -553,24 +554,12 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { } } - fn apply_additional_edits_for_completion( - &self, - _buffer: Entity, - _completions: Rc>>, - _completion_index: usize, - _push_to_history: bool, - _cx: &mut Context, - ) -> gpui::Task>> { - Task::ready(Ok(None)) - } - fn is_completion_trigger( &self, buffer: &Entity, position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool { let mut chars = text.chars(); @@ -581,9 +570,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { }; let snapshot = buffer.read(cx).snapshot(); - if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input { - return false; - } let classifier = snapshot .char_classifier_at(position) diff --git a/crates/debugger_ui/src/session/running/loaded_source_list.rs b/crates/debugger_ui/src/session/running/loaded_source_list.rs index 921ebd8b5f5bdfe8a3c8a8f7bb1625bd1ffad7fb..e55fad336b5ee6dfbee1cb0c90ea3d19f561a2ba 100644 --- a/crates/debugger_ui/src/session/running/loaded_source_list.rs +++ b/crates/debugger_ui/src/session/running/loaded_source_list.rs @@ -17,7 +17,9 @@ impl LoadedSourceList { let list = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); let _subscription = cx.subscribe(&session, |this, _, event, cx| match event { - SessionEvent::Stopped(_) | SessionEvent::LoadedSources => { + SessionEvent::Stopped(_) + | SessionEvent::HistoricSnapshotSelected + | SessionEvent::LoadedSources => { this.invalidate = true; cx.notify(); } diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 8670beb0f5f93f68a6052b868a866e22b82c92fd..f10e5179e37f87be0e27985b557fcb63cf089a42 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -229,7 +229,7 @@ impl MemoryView { rows }, ) - .track_scroll(view_state.scroll_handle) + .track_scroll(&view_state.scroll_handle) .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) .on_scroll_wheel(cx.listener(|this, evt: &ScrollWheelEvent, window, _| { let mut view_state = this.view_state(); @@ -403,7 +403,7 @@ impl MemoryView { this.set_placeholder_text("Write to Selected Memory Range", window, cx); }); self.is_writing_memory = true; - self.query_editor.focus_handle(cx).focus(window); + self.query_editor.focus_handle(cx).focus(window, cx); } else { self.query_editor.update(cx, |this, cx| { this.clear(window, cx); @@ -921,7 +921,7 @@ impl Render for MemoryView { })) .custom_scrollbars( ui::Scrollbars::new(ui::ScrollAxes::Both) - .tracked_scroll_handle(self.view_state_handle.clone()) + .tracked_scroll_handle(&self.view_state_handle) .with_track_along( ui::ScrollAxes::Both, cx.theme().colors().panel_background, diff --git a/crates/debugger_ui/src/session/running/module_list.rs b/crates/debugger_ui/src/session/running/module_list.rs index 545d8392745c636b805cfc1e0743170635ef8abe..7d0228fc6851185d10a3a237257d6244d5a90c76 100644 --- a/crates/debugger_ui/src/session/running/module_list.rs +++ b/crates/debugger_ui/src/session/running/module_list.rs @@ -32,7 +32,9 @@ impl ModuleList { let focus_handle = cx.focus_handle(); let _subscription = cx.subscribe(&session, |this, _, event, cx| match event { - SessionEvent::Stopped(_) | SessionEvent::Modules => { + SessionEvent::Stopped(_) + | SessionEvent::HistoricSnapshotSelected + | SessionEvent::Modules => { if this._rebuild_task.is_some() { this.schedule_rebuild(cx); } @@ -253,7 +255,7 @@ impl ModuleList { range.map(|ix| this.render_entry(ix, cx)).collect() }), ) - .track_scroll(self.scroll_handle.clone()) + .track_scroll(&self.scroll_handle) .size_full() } } @@ -279,6 +281,6 @@ impl Render for ModuleList { .size_full() .p_1() .child(self.render_list(window, cx)) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) } } diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index a8fabd327a3de630ff884899fe7af1167932618c..4dffb57a792cb5a5ed6bcc8003a8fa6f3b9af9de 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -4,6 +4,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow}; use dap::StackFrameId; +use dap::adapters::DebugAdapterName; use db::kvp::KEY_VALUE_STORE; use gpui::{ Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, @@ -20,7 +21,7 @@ use project::debugger::breakpoint_store::ActiveStackFrame; use project::debugger::session::{Session, SessionEvent, StackFrame, ThreadStatus}; use project::{ProjectItem, ProjectPath}; use ui::{Tooltip, WithScrollbar, prelude::*}; -use workspace::{ItemHandle, Workspace}; +use workspace::{ItemHandle, Workspace, WorkspaceId}; use super::RunningState; @@ -58,6 +59,14 @@ impl From for String { } } +pub(crate) fn stack_frame_filter_key( + adapter_name: &DebugAdapterName, + workspace_id: WorkspaceId, +) -> String { + let database_id: i64 = workspace_id.into(); + format!("stack-frame-list-filter-{}-{}", adapter_name.0, database_id) +} + pub struct StackFrameList { focus_handle: FocusHandle, _subscription: Subscription, @@ -97,7 +106,9 @@ impl StackFrameList { SessionEvent::Threads => { this.schedule_refresh(false, window, cx); } - SessionEvent::Stopped(..) | SessionEvent::StackTrace => { + SessionEvent::Stopped(..) + | SessionEvent::StackTrace + | SessionEvent::HistoricSnapshotSelected => { this.schedule_refresh(true, window, cx); } _ => {} @@ -105,14 +116,18 @@ impl StackFrameList { let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); - let list_filter = KEY_VALUE_STORE - .read_kvp(&format!( - "stack-frame-list-filter-{}", - session.read(cx).adapter().0 - )) + let list_filter = workspace + .read_with(cx, |workspace, _| workspace.database_id()) .ok() .flatten() - .map(StackFrameFilter::from_str_or_default) + .and_then(|database_id| { + let key = stack_frame_filter_key(&session.read(cx).adapter(), database_id); + KEY_VALUE_STORE + .read_kvp(&key) + .ok() + .flatten() + .map(StackFrameFilter::from_str_or_default) + }) .unwrap_or(StackFrameFilter::All); let mut this = Self { @@ -225,7 +240,6 @@ impl StackFrameList { } this.update_in(cx, |this, window, cx| { this.build_entries(select_first, window, cx); - cx.notify(); }) .ok(); }) @@ -806,15 +820,8 @@ impl StackFrameList { .ok() .flatten() { - let database_id: i64 = database_id.into(); - let save_task = KEY_VALUE_STORE.write_kvp( - format!( - "stack-frame-list-filter-{}-{}", - self.session.read(cx).adapter().0, - database_id, - ), - self.list_filter.into(), - ); + let key = stack_frame_filter_key(&self.session.read(cx).adapter(), database_id); + let save_task = KEY_VALUE_STORE.write_kvp(key, self.list_filter.into()); cx.background_spawn(save_task).detach(); } @@ -913,7 +920,7 @@ impl Render for StackFrameList { ) }) .child(self.render_list(window, cx)) - .vertical_scrollbar_for(self.list_state.clone(), window, cx) + .vertical_scrollbar_for(&self.list_state, window, cx) } } diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 7d736aace58ab1b27ccab5690cf24d4cff9a47f6..8329a6baf04061cc33e8130a4e6b3a33b35267b6 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -217,6 +217,12 @@ impl VariableList { let _subscriptions = vec![ cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events), cx.subscribe(&session, |this, _, event, cx| match event { + SessionEvent::HistoricSnapshotSelected => { + this.selection.take(); + this.edited_path.take(); + this.selected_stack_frame_id.take(); + this.build_entries(cx); + } SessionEvent::Stopped(_) => { this.selection.take(); this.edited_path.take(); @@ -225,7 +231,6 @@ impl VariableList { SessionEvent::Variables | SessionEvent::Watchers => { this.build_entries(cx); } - _ => {} }), cx.on_focus_out(&focus_handle, window, |this, _, _, cx| { @@ -524,7 +529,7 @@ impl VariableList { fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { self.edited_path.take(); - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); cx.notify(); } @@ -1062,7 +1067,7 @@ impl VariableList { editor.select_all(&editor::actions::SelectAll, window, cx); editor }); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); editor } @@ -1557,7 +1562,7 @@ impl Render for VariableList { this.render_entries(range, window, cx) }), ) - .track_scroll(self.list_handle.clone()) + .track_scroll(&self.list_handle) .with_width_from_item(self.max_width_index) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) .with_horizontal_sizing_behavior(gpui::ListHorizontalSizingBehavior::Unconstrained) @@ -1574,10 +1579,10 @@ impl Render for VariableList { ) .with_priority(1) })) - // .vertical_scrollbar_for(self.list_handle.clone(), window, cx) + // .vertical_scrollbar_for(&self.list_handle, window, cx) .custom_scrollbars( ui::Scrollbars::new(ScrollAxes::Both) - .tracked_scroll_handle(self.list_handle.clone()) + .tracked_scroll_handle(&self.list_handle) .with_track_along(ScrollAxes::Both, cx.theme().colors().panel_background) .tracked_entity(cx.entity_id()), window, diff --git a/crates/debugger_ui/src/stack_trace_view.rs b/crates/debugger_ui/src/stack_trace_view.rs index cdbd9aaff0cff250fdc3e5091ffa7dcabc70861a..70b88d203e4ff8017127eee2ad6ff0a81df74c69 100644 --- a/crates/debugger_ui/src/stack_trace_view.rs +++ b/crates/debugger_ui/src/stack_trace_view.rs @@ -428,7 +428,7 @@ impl Item for StackTraceView { } } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } diff --git a/crates/debugger_ui/src/tests/inline_values.rs b/crates/debugger_ui/src/tests/inline_values.rs index 801e6d43623b50d69ea3ce297c274c2d7e5a8b14..379bc4c98f5341b089b5936ed8571da5a6280723 100644 --- a/crates/debugger_ui/src/tests/inline_values.rs +++ b/crates/debugger_ui/src/tests/inline_values.rs @@ -4,7 +4,7 @@ use dap::{Scope, StackFrame, Variable, requests::Variables}; use editor::{Editor, EditorMode, MultiBuffer}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; use language::{ - Language, LanguageConfig, LanguageMatcher, tree_sitter_python, tree_sitter_rust, + Language, LanguageConfig, LanguageMatcher, rust_lang, tree_sitter_python, tree_sitter_typescript, }; use project::{FakeFs, Project}; @@ -224,7 +224,7 @@ fn main() { .unwrap(); buffer.update(cx, |buffer, cx| { - buffer.set_language(Some(Arc::new(rust_lang())), cx); + buffer.set_language(Some(rust_lang()), cx); }); let (editor, cx) = cx.add_window_view(|window, cx| { @@ -1521,23 +1521,6 @@ fn main() { }); } -fn rust_lang() -> Language { - let debug_variables_query = include_str!("../../../languages/src/rust/debugger.scm"); - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_debug_variables_query(debug_variables_query) - .unwrap() -} - #[gpui::test] async fn test_python_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx); @@ -1859,21 +1842,23 @@ fn python_lang() -> Language { .unwrap() } -fn go_lang() -> Language { +fn go_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/go/debugger.scm"); - Language::new( - LanguageConfig { - name: "Go".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["go".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "Go".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["go".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_go::LANGUAGE.into()), + Some(tree_sitter_go::LANGUAGE.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } /// Test utility function for inline values testing @@ -1891,7 +1876,7 @@ async fn test_inline_values_util( before: &str, after: &str, active_debug_line: Option, - language: Language, + language: Arc, executor: BackgroundExecutor, cx: &mut TestAppContext, ) { @@ -2091,7 +2076,7 @@ async fn test_inline_values_util( .unwrap(); buffer.update(cx, |buffer, cx| { - buffer.set_language(Some(Arc::new(language)), cx); + buffer.set_language(Some(language), cx); }); let (editor, cx) = cx.add_window_view(|window, cx| { @@ -2276,55 +2261,61 @@ fn main() { .await; } -fn javascript_lang() -> Language { +fn javascript_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/javascript/debugger.scm"); - Language::new( - LanguageConfig { - name: "JavaScript".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["js".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "JavaScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["js".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } -fn typescript_lang() -> Language { +fn typescript_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/typescript/debugger.scm"); - Language::new( - LanguageConfig { - name: "TypeScript".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["ts".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } -fn tsx_lang() -> Language { +fn tsx_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/tsx/debugger.scm"); - Language::new( - LanguageConfig { - name: "TSX".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["tsx".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } #[gpui::test] diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 05e638e2321bb6fcb4504a8bc8c81123f1b09a33..445d5a01d9f062501c889a9d5aa920de6f037914 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1,12 +1,15 @@ use crate::{ debugger_panel::DebugPanel, - session::running::stack_frame_list::{StackFrameEntry, StackFrameFilter}, + session::running::stack_frame_list::{ + StackFrameEntry, StackFrameFilter, stack_frame_filter_key, + }, tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session}, }; use dap::{ StackFrame, requests::{Scopes, StackTrace, Threads}, }; +use db::kvp::KEY_VALUE_STORE; use editor::{Editor, ToPoint as _}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; use project::{FakeFs, Project}; @@ -1085,3 +1088,180 @@ async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppC ); }); } + +#[gpui::test] +async fn test_stack_frame_filter_persistence( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": "function main() { console.log('hello'); }", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + workspace + .update(cx, |workspace, _, _| { + workspace.set_random_database_id(); + }) + .unwrap(); + + let threads_response = dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }; + + let stack_trace_response = dap::StackTraceResponse { + stack_frames: vec![StackFrame { + id: 1, + name: "main".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }], + total_frames: None, + }; + + let stopped_event = dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + }; + + let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + let adapter_name = session.update(cx, |session, _| session.adapter()); + + client.on_request::({ + let threads_response = threads_response.clone(); + move |_, _| Ok(threads_response.clone()) + }); + + client.on_request::(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] })); + + client.on_request::({ + let stack_trace_response = stack_trace_response.clone(); + move |_, _| Ok(stack_trace_response.clone()) + }); + + client + .fake_event(dap::messages::Events::Stopped(stopped_event.clone())) + .await; + + cx.run_until_parked(); + + let stack_frame_list = + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()) + }); + + stack_frame_list.update(cx, |stack_frame_list, _cx| { + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::All, + "Initial filter should be All" + ); + }); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames, + "Filter should be OnlyUserFrames after toggle" + ); + }); + + cx.run_until_parked(); + + let workspace_id = workspace + .update(cx, |workspace, _window, _cx| workspace.database_id()) + .ok() + .flatten() + .expect("workspace id has to be some for this test to work properly"); + + let key = stack_frame_filter_key(&adapter_name, workspace_id); + let stored_value = KEY_VALUE_STORE.read_kvp(&key).unwrap(); + assert_eq!( + stored_value, + Some(StackFrameFilter::OnlyUserFrames.into()), + "Filter should be persisted in KVP store with key: {}", + key + ); + + client + .fake_event(dap::messages::Events::Terminated(None)) + .await; + cx.run_until_parked(); + + let session2 = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client2 = session2.update(cx, |session, _| session.adapter_client().unwrap()); + + client2.on_request::({ + let threads_response = threads_response.clone(); + move |_, _| Ok(threads_response.clone()) + }); + + client2.on_request::(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] })); + + client2.on_request::({ + let stack_trace_response = stack_trace_response.clone(); + move |_, _| Ok(stack_trace_response.clone()) + }); + + client2 + .fake_event(dap::messages::Events::Stopped(stopped_event.clone())) + .await; + + cx.run_until_parked(); + + let stack_frame_list2 = + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()) + }); + + stack_frame_list2.update(cx, |stack_frame_list, _cx| { + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames, + "Filter should be restored from KVP store in new session" + ); + }); +} diff --git a/crates/deepseek/src/deepseek.rs b/crates/deepseek/src/deepseek.rs index 64a1cbe5d96354260c2bf84a43ed70be7336aa7a..636258a5a132ce79cb5d15b1aaa25d6e4d3af643 100644 --- a/crates/deepseek/src/deepseek.rs +++ b/crates/deepseek/src/deepseek.rs @@ -103,8 +103,9 @@ impl Model { pub fn max_output_tokens(&self) -> Option { match self { - Self::Chat => Some(8_192), - Self::Reasoner => Some(64_000), + // Their API treats this max against the context window, which means we hit the limit a lot + // Using the default value of None in the API instead + Self::Chat | Self::Reasoner => None, Self::Custom { max_output_tokens, .. } => *max_output_tokens, @@ -155,6 +156,8 @@ pub enum RequestMessage { content: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_content: Option, }, User { content: String, diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index 0fd8783dd514f8da3c53d41dcb6f8e9004ae501c..ba10f6fbdabf05a095a7fed7c6ae682d4dc177c7 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -6,7 +6,7 @@ use crate::{ use anyhow::Result; use collections::HashMap; use editor::{ - Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, multibuffer_context_lines, }; @@ -175,7 +175,7 @@ impl BufferDiagnosticsEditor { // `BufferDiagnosticsEditor` instance. EditorEvent::Focused => { if buffer_diagnostics_editor.multibuffer.read(cx).is_empty() { - window.focus(&buffer_diagnostics_editor.focus_handle); + window.focus(&buffer_diagnostics_editor.focus_handle, cx); } } EditorEvent::Blurred => { @@ -517,7 +517,7 @@ impl BufferDiagnosticsEditor { .editor .read(cx) .focus_handle(cx) - .focus(window); + .focus(window, cx); } } } @@ -617,7 +617,7 @@ impl BufferDiagnosticsEditor { // not empty, focus on the editor instead, which will allow the user to // start interacting and editing the buffer's contents. if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.focus_handle(cx).focus(window) + self.editor.focus_handle(cx).focus(window, cx) } } @@ -701,8 +701,12 @@ impl Item for BufferDiagnosticsEditor { }); } - fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if EditorSettings::get_global(cx).toolbar.breadcrumbs { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 2636b1aadc9708ff6832a5baa212277672dd305f..521752ff1959fccc12b74857e342ff33a0444f3f 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -284,7 +284,7 @@ impl DiagnosticBlock { if range.context.overlaps(&diagnostic.range, &snapshot) { Self::jump_to( editor, - Anchor::range_in_buffer(excerpt_id, buffer_id, diagnostic.range), + Anchor::range_in_buffer(excerpt_id, diagnostic.range), window, cx, ); @@ -315,6 +315,6 @@ impl DiagnosticBlock { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges([range.start..range.start]); }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } } diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index b24a63b830b93cdbe14e2329abe524f6523cbbd6..d85eb07f68619e15bfe44d26282db3a3e49df4f3 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -12,7 +12,7 @@ use buffer_diagnostics::BufferDiagnosticsEditor; use collections::{BTreeSet, HashMap, HashSet}; use diagnostic_renderer::DiagnosticBlock; use editor::{ - Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, multibuffer_context_lines, }; @@ -243,7 +243,7 @@ impl ProjectDiagnosticsEditor { match event { EditorEvent::Focused => { if this.multibuffer.read(cx).is_empty() { - window.focus(&this.focus_handle); + window.focus(&this.focus_handle, cx); } } EditorEvent::Blurred => this.close_diagnosticless_buffers(cx, false), @@ -308,7 +308,7 @@ impl ProjectDiagnosticsEditor { .selections .all_anchors(&snapshot) .iter() - .filter_map(|anchor| anchor.start.buffer_id) + .filter_map(|anchor| anchor.start.text_anchor.buffer_id) .collect::>() }); for buffer_id in buffer_ids { @@ -434,7 +434,7 @@ impl ProjectDiagnosticsEditor { fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.focus_handle(cx).focus(window) + self.editor.focus_handle(cx).focus(window, cx) } } @@ -650,7 +650,7 @@ impl ProjectDiagnosticsEditor { }) }); if this.focus_handle.is_focused(window) { - this.editor.read(cx).focus_handle(cx).focus(window); + this.editor.read(cx).focus_handle(cx).focus(window, cx); } } @@ -890,12 +890,16 @@ impl Item for ProjectDiagnosticsEditor { } } - fn as_searchable(&self, _: &Entity) -> Option> { + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } - fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if EditorSettings::get_global(cx).toolbar.breadcrumbs { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { @@ -1045,54 +1049,47 @@ async fn heuristic_syntactic_expand( let node_range = node_start..node_end; let row_count = node_end.row - node_start.row + 1; let mut ancestor_range = None; - let reached_outline_node = cx.background_executor().scoped({ - let node_range = node_range.clone(); - let outline_range = outline_range.clone(); - let ancestor_range = &mut ancestor_range; - |scope| { - scope.spawn(async move { - // Stop if we've exceeded the row count or reached an outline node. Then, find the interval - // of node children which contains the query range. For example, this allows just returning - // the header of a declaration rather than the entire declaration. - if row_count > max_row_count || outline_range == Some(node_range.clone()) { - let mut cursor = node.walk(); - let mut included_child_start = None; - let mut included_child_end = None; - let mut previous_end = node_start; - if cursor.goto_first_child() { - loop { - let child_node = cursor.node(); - let child_range = - previous_end..Point::from_ts_point(child_node.end_position()); - if included_child_start.is_none() - && child_range.contains(&input_range.start) - { - included_child_start = Some(child_range.start); - } - if child_range.contains(&input_range.end) { - included_child_end = Some(child_range.end); - } - previous_end = child_range.end; - if !cursor.goto_next_sibling() { - break; - } + cx.background_executor() + .await_on_background(async { + // Stop if we've exceeded the row count or reached an outline node. Then, find the interval + // of node children which contains the query range. For example, this allows just returning + // the header of a declaration rather than the entire declaration. + if row_count > max_row_count || outline_range == Some(node_range.clone()) { + let mut cursor = node.walk(); + let mut included_child_start = None; + let mut included_child_end = None; + let mut previous_end = node_start; + if cursor.goto_first_child() { + loop { + let child_node = cursor.node(); + let child_range = + previous_end..Point::from_ts_point(child_node.end_position()); + if included_child_start.is_none() + && child_range.contains(&input_range.start) + { + included_child_start = Some(child_range.start); } - } - let end = included_child_end.unwrap_or(node_range.end); - if let Some(start) = included_child_start { - let row_count = end.row - start.row; - if row_count < max_row_count { - *ancestor_range = - Some(Some(RangeInclusive::new(start.row, end.row))); - return; + if child_range.contains(&input_range.end) { + included_child_end = Some(child_range.end); + } + previous_end = child_range.end; + if !cursor.goto_next_sibling() { + break; } } - *ancestor_range = Some(None); } - }) - } - }); - reached_outline_node.await; + let end = included_child_end.unwrap_or(node_range.end); + if let Some(start) = included_child_start { + let row_count = end.row - start.row; + if row_count < max_row_count { + ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); + return; + } + } + ancestor_range = Some(None); + } + }) + .await; if let Some(node) = ancestor_range { return node; } diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index e71f9ae3f3f6fcff790db27fb1e377f0d1c20e40..07da23899956822f7577118ae85b6338b4cefae7 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -7,8 +7,6 @@ license = "GPL-3.0-or-later" [dependencies] anyhow.workspace = true -command_palette.workspace = true -gpui.workspace = true # We are specifically pinning this version of mdbook, as later versions introduce issues with double-nested subdirectories. # Ask @maxdeviant about this before bumping. mdbook = "= 0.4.40" @@ -17,7 +15,6 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true util.workspace = true -zed.workspace = true zlog.workspace = true task.workspace = true theme.workspace = true @@ -27,4 +24,4 @@ workspace = true [[bin]] name = "docs_preprocessor" -path = "src/main.rs" +path = "src/main.rs" \ No newline at end of file diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index b614a8251139413f4b316937db1d4e3c0d551df6..d90dcc10db9fbd8d27a968094ea8d733a79b7e80 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -22,16 +22,13 @@ static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") }); -static ALL_ACTIONS: LazyLock> = LazyLock::new(dump_all_gpui_actions); +static ALL_ACTIONS: LazyLock> = LazyLock::new(load_all_actions); const FRONT_MATTER_COMMENT: &str = ""; fn main() -> Result<()> { zlog::init(); zlog::init_output_stderr(); - // call a zed:: function so everything in `zed` crate is linked and - // all actions in the actual app are registered - zed::stdout_is_a_pty(); let args = std::env::args().skip(1).collect::>(); match args.get(0).map(String::as_str) { @@ -72,8 +69,8 @@ enum PreprocessorError { impl PreprocessorError { fn new_for_not_found_action(action_name: String) -> Self { for action in &*ALL_ACTIONS { - for alias in action.deprecated_aliases { - if alias == &action_name { + for alias in &action.deprecated_aliases { + if alias == action_name.as_str() { return PreprocessorError::DeprecatedActionUsed { used: action_name, should_be: action.name.to_string(), @@ -214,7 +211,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet{}", name); }; format!("{}", &action.human_name) }) @@ -257,11 +256,19 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet Option<&ActionDef> { ALL_ACTIONS - .binary_search_by(|action| action.name.cmp(name)) + .binary_search_by(|action| action.name.as_str().cmp(name)) .ok() .map(|index| &ALL_ACTIONS[index]) } +fn actions_available() -> bool { + !ALL_ACTIONS.is_empty() +} + +fn is_missing_action(name: &str) -> bool { + actions_available() && find_action_by_name(name).is_none() +} + fn find_binding(os: &str, action: &str) -> Option { let keymap = match os { "macos" => &KEYMAP_MACOS, @@ -384,18 +391,13 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
, _>>()
-                            .context("Failed to parse keystroke")?;
+                    for (_keystrokes, action) in section.bindings() {
                         if let Some((action_name, _)) = settings::KeymapFile::parse_action(action)
                             .map_err(|err| anyhow::format_err!(err))
                             .context("Failed to parse action")?
                         {
                             anyhow::ensure!(
-                                find_action_by_name(action_name).is_some(),
+                                !is_missing_action(action_name),
                                 "Action not found: {}",
                                 action_name
                             );
@@ -491,27 +493,35 @@ where
     });
 }
 
-#[derive(Debug, serde::Serialize)]
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
 struct ActionDef {
-    name: &'static str,
+    name: String,
     human_name: String,
-    deprecated_aliases: &'static [&'static str],
-    docs: Option<&'static str>,
+    deprecated_aliases: Vec,
+    #[serde(rename = "documentation")]
+    docs: Option,
 }
 
-fn dump_all_gpui_actions() -> Vec {
-    let mut actions = gpui::generate_list_of_all_registered_actions()
-        .map(|action| ActionDef {
-            name: action.name,
-            human_name: command_palette::humanize_action_name(action.name),
-            deprecated_aliases: action.deprecated_aliases,
-            docs: action.documentation,
-        })
-        .collect::>();
-
-    actions.sort_by_key(|a| a.name);
-
-    actions
+fn load_all_actions() -> Vec {
+    let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
+    match std::fs::read_to_string(asset_path) {
+        Ok(content) => {
+            let mut actions: Vec =
+                serde_json::from_str(&content).expect("Failed to parse actions.json");
+            actions.sort_by(|a, b| a.name.cmp(&b.name));
+            actions
+        }
+        Err(err) => {
+            if std::env::var("CI").is_ok() {
+                panic!("actions.json not found at {}: {}", asset_path, err);
+            }
+            eprintln!(
+                "Warning: actions.json not found, action validation will be skipped: {}",
+                err
+            );
+            Vec::new()
+        }
+    }
 }
 
 fn handle_postprocessing() -> Result<()> {
@@ -647,7 +657,7 @@ fn generate_big_table_of_actions() -> String {
     let mut output = String::new();
 
     let mut actions_sorted = actions.iter().collect::>();
-    actions_sorted.sort_by_key(|a| a.name);
+    actions_sorted.sort_by_key(|a| a.name.as_str());
 
     // Start the definition list with custom styling for better spacing
     output.push_str("
\n"); @@ -664,7 +674,7 @@ fn generate_big_table_of_actions() -> String { output.push_str("
\n"); // Add the description, escaping HTML if needed - if let Some(description) = action.docs { + if let Some(description) = action.docs.as_ref() { output.push_str( &description .replace("&", "&") @@ -674,7 +684,7 @@ fn generate_big_table_of_actions() -> String { output.push_str("
\n"); } output.push_str("Keymap Name: "); - output.push_str(action.name); + output.push_str(&action.name); output.push_str("
\n"); if !action.deprecated_aliases.is_empty() { output.push_str("Deprecated Alias(es): "); diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 2c6888d14be49c857e7805fb63f9f9335ac32c8e..2d5fb36a581f7bd17bb76f79791c276c86c9c631 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -11,7 +11,67 @@ workspace = true [lib] path = "src/edit_prediction.rs" +[features] +cli-support = [] + [dependencies] +ai_onboarding.workspace = true +anyhow.workspace = true +arrayvec.workspace = true +brotli.workspace = true client.workspace = true +cloud_llm_client.workspace = true +collections.workspace = true +copilot.workspace = true +db.workspace = true +edit_prediction_types.workspace = true +edit_prediction_context.workspace = true +feature_flags.workspace = true +fs.workspace = true +futures.workspace = true gpui.workspace = true +indoc.workspace = true +itertools.workspace = true language.workspace = true +language_model.workspace = true +log.workspace = true +lsp.workspace = true +menu.workspace = true +open_ai.workspace = true +postage.workspace = true +pretty_assertions.workspace = true +project.workspace = true +pulldown-cmark.workspace = true +rand.workspace = true +regex.workspace = true +release_channel.workspace = true +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +strum.workspace = true +telemetry.workspace = true +telemetry_events.workspace = true +thiserror.workspace = true +ui.workspace = true +util.workspace = true +uuid.workspace = true +workspace.workspace = true +worktree.workspace = true +zed_actions.workspace = true +zeta_prompt.workspace = true + +[dev-dependencies] +clock = { workspace = true, features = ["test-support"] } +cloud_api_types.workspace = true +cloud_llm_client = { workspace = true, features = ["test-support"] } +ctor.workspace = true +gpui = { workspace = true, features = ["test-support"] } +indoc.workspace = true +language = { workspace = true, features = ["test-support"] } +language_model = { workspace = true, features = ["test-support"] } +lsp.workspace = true +parking_lot.workspace = true +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/zeta/license_examples/0bsd.txt b/crates/edit_prediction/license_examples/0bsd.txt similarity index 100% rename from crates/zeta/license_examples/0bsd.txt rename to crates/edit_prediction/license_examples/0bsd.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex0.txt b/crates/edit_prediction/license_examples/apache-2.0-ex0.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex0.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex0.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex1.txt b/crates/edit_prediction/license_examples/apache-2.0-ex1.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex1.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex1.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex2.txt b/crates/edit_prediction/license_examples/apache-2.0-ex2.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex2.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex2.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex3.txt b/crates/edit_prediction/license_examples/apache-2.0-ex3.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex3.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex3.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex4.txt b/crates/edit_prediction/license_examples/apache-2.0-ex4.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex4.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex4.txt diff --git a/crates/zeta/license_examples/bsd-1-clause.txt b/crates/edit_prediction/license_examples/bsd-1-clause.txt similarity index 100% rename from crates/zeta/license_examples/bsd-1-clause.txt rename to crates/edit_prediction/license_examples/bsd-1-clause.txt diff --git a/crates/zeta/license_examples/bsd-2-clause-ex0.txt b/crates/edit_prediction/license_examples/bsd-2-clause-ex0.txt similarity index 100% rename from crates/zeta/license_examples/bsd-2-clause-ex0.txt rename to crates/edit_prediction/license_examples/bsd-2-clause-ex0.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex0.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex0.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex0.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex0.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex1.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex1.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex1.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex1.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex2.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex2.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex2.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex2.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex3.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex3.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex3.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex3.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex4.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex4.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex4.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex4.txt diff --git a/crates/zeta/license_examples/isc.txt b/crates/edit_prediction/license_examples/isc.txt similarity index 100% rename from crates/zeta/license_examples/isc.txt rename to crates/edit_prediction/license_examples/isc.txt diff --git a/crates/zeta/license_examples/mit-ex0.txt b/crates/edit_prediction/license_examples/mit-ex0.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex0.txt rename to crates/edit_prediction/license_examples/mit-ex0.txt diff --git a/crates/zeta/license_examples/mit-ex1.txt b/crates/edit_prediction/license_examples/mit-ex1.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex1.txt rename to crates/edit_prediction/license_examples/mit-ex1.txt diff --git a/crates/zeta/license_examples/mit-ex2.txt b/crates/edit_prediction/license_examples/mit-ex2.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex2.txt rename to crates/edit_prediction/license_examples/mit-ex2.txt diff --git a/crates/zeta/license_examples/mit-ex3.txt b/crates/edit_prediction/license_examples/mit-ex3.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex3.txt rename to crates/edit_prediction/license_examples/mit-ex3.txt diff --git a/crates/zeta/license_examples/upl-1.0.txt b/crates/edit_prediction/license_examples/upl-1.0.txt similarity index 100% rename from crates/zeta/license_examples/upl-1.0.txt rename to crates/edit_prediction/license_examples/upl-1.0.txt diff --git a/crates/zeta/license_examples/zlib-ex0.txt b/crates/edit_prediction/license_examples/zlib-ex0.txt similarity index 100% rename from crates/zeta/license_examples/zlib-ex0.txt rename to crates/edit_prediction/license_examples/zlib-ex0.txt diff --git a/crates/zeta/license_patterns/0bsd-pattern b/crates/edit_prediction/license_patterns/0bsd-pattern similarity index 100% rename from crates/zeta/license_patterns/0bsd-pattern rename to crates/edit_prediction/license_patterns/0bsd-pattern diff --git a/crates/zeta/license_patterns/apache-2.0-pattern b/crates/edit_prediction/license_patterns/apache-2.0-pattern similarity index 100% rename from crates/zeta/license_patterns/apache-2.0-pattern rename to crates/edit_prediction/license_patterns/apache-2.0-pattern diff --git a/crates/zeta/license_patterns/apache-2.0-reference-pattern b/crates/edit_prediction/license_patterns/apache-2.0-reference-pattern similarity index 100% rename from crates/zeta/license_patterns/apache-2.0-reference-pattern rename to crates/edit_prediction/license_patterns/apache-2.0-reference-pattern diff --git a/crates/zeta/license_patterns/bsd-pattern b/crates/edit_prediction/license_patterns/bsd-pattern similarity index 100% rename from crates/zeta/license_patterns/bsd-pattern rename to crates/edit_prediction/license_patterns/bsd-pattern diff --git a/crates/zeta/license_patterns/isc-pattern b/crates/edit_prediction/license_patterns/isc-pattern similarity index 100% rename from crates/zeta/license_patterns/isc-pattern rename to crates/edit_prediction/license_patterns/isc-pattern diff --git a/crates/zeta/license_patterns/mit-pattern b/crates/edit_prediction/license_patterns/mit-pattern similarity index 100% rename from crates/zeta/license_patterns/mit-pattern rename to crates/edit_prediction/license_patterns/mit-pattern diff --git a/crates/zeta/license_patterns/upl-1.0-pattern b/crates/edit_prediction/license_patterns/upl-1.0-pattern similarity index 100% rename from crates/zeta/license_patterns/upl-1.0-pattern rename to crates/edit_prediction/license_patterns/upl-1.0-pattern diff --git a/crates/zeta/license_patterns/zlib-pattern b/crates/edit_prediction/license_patterns/zlib-pattern similarity index 100% rename from crates/zeta/license_patterns/zlib-pattern rename to crates/edit_prediction/license_patterns/zlib-pattern diff --git a/crates/edit_prediction/src/cursor_excerpt.rs b/crates/edit_prediction/src/cursor_excerpt.rs new file mode 100644 index 0000000000000000000000000000000000000000..1f2f1d32ebcb2eaa151433bd49d275e0e2a3b817 --- /dev/null +++ b/crates/edit_prediction/src/cursor_excerpt.rs @@ -0,0 +1,78 @@ +use language::{BufferSnapshot, Point}; +use std::ops::Range; + +pub fn editable_and_context_ranges_for_cursor_position( + position: Point, + snapshot: &BufferSnapshot, + editable_region_token_limit: usize, + context_token_limit: usize, +) -> (Range, Range) { + let mut scope_range = position..position; + let mut remaining_edit_tokens = editable_region_token_limit; + + while let Some(parent) = snapshot.syntax_ancestor(scope_range.clone()) { + let parent_tokens = guess_token_count(parent.byte_range().len()); + let parent_point_range = Point::new( + parent.start_position().row as u32, + parent.start_position().column as u32, + ) + ..Point::new( + parent.end_position().row as u32, + parent.end_position().column as u32, + ); + if parent_point_range == scope_range { + break; + } else if parent_tokens <= editable_region_token_limit { + scope_range = parent_point_range; + remaining_edit_tokens = editable_region_token_limit - parent_tokens; + } else { + break; + } + } + + let editable_range = expand_range(snapshot, scope_range, remaining_edit_tokens); + let context_range = expand_range(snapshot, editable_range.clone(), context_token_limit); + (editable_range, context_range) +} + +fn expand_range( + snapshot: &BufferSnapshot, + range: Range, + mut remaining_tokens: usize, +) -> Range { + let mut expanded_range = range; + expanded_range.start.column = 0; + expanded_range.end.column = snapshot.line_len(expanded_range.end.row); + loop { + let mut expanded = false; + + if remaining_tokens > 0 && expanded_range.start.row > 0 { + expanded_range.start.row -= 1; + let line_tokens = + guess_token_count(snapshot.line_len(expanded_range.start.row) as usize); + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + + if remaining_tokens > 0 && expanded_range.end.row < snapshot.max_point().row { + expanded_range.end.row += 1; + expanded_range.end.column = snapshot.line_len(expanded_range.end.row); + let line_tokens = guess_token_count(expanded_range.end.column as usize); + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + + if !expanded { + break; + } + } + expanded_range +} + +/// Typical number of string bytes per token for the purposes of limiting model input. This is +/// intentionally low to err on the side of underestimating limits. +pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3; + +pub fn guess_token_count(bytes: usize) -> usize { + bytes / BYTES_PER_TOKEN_GUESS +} diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 1984383a9691ae9373973a3eb9f00db4e7e795f2..f5ea7590fcba97ee916af985824e21cdf4ea725f 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,298 +1,2132 @@ -use std::{ops::Range, sync::Arc}; +use anyhow::Result; +use arrayvec::ArrayVec; +use client::{Client, EditPredictionUsage, UserStore}; +use cloud_llm_client::predict_edits_v3::{self, PromptFormat}; +use cloud_llm_client::{ + AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, EditPredictionRejectReason, + EditPredictionRejection, MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, + MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBodyRef, + ZED_VERSION_HEADER_NAME, +}; +use collections::{HashMap, HashSet}; +use db::kvp::{Dismissable, KEY_VALUE_STORE}; +use edit_prediction_context::EditPredictionExcerptOptions; +use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use futures::{ + AsyncReadExt as _, FutureExt as _, StreamExt as _, + channel::mpsc::{self, UnboundedReceiver}, + select_biased, +}; +use gpui::BackgroundExecutor; +use gpui::http_client::Url; +use gpui::{ + App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, + http_client::{self, AsyncBody, Method}, + prelude::*, +}; +use language::language_settings::all_language_settings; +use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToPoint}; +use language::{BufferSnapshot, OffsetRangeExt}; +use language_model::{LlmApiToken, RefreshLlmTokenListener}; +use project::{Project, ProjectPath, WorktreeId}; +use release_channel::AppVersion; +use semver::Version; +use serde::de::DeserializeOwned; +use settings::{EditPredictionProvider, SettingsStore, update_settings_file}; +use std::collections::{VecDeque, hash_map}; +use workspace::Workspace; -use client::EditPredictionUsage; -use gpui::{App, Context, Entity, SharedString}; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt}; +use std::ops::Range; +use std::path::Path; +use std::rc::Rc; +use std::str::FromStr as _; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; +use std::{env, mem}; +use thiserror::Error; +use util::{RangeExt as _, ResultExt as _}; +use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; -// TODO: Find a better home for `Direction`. -// -// This should live in an ancestor crate of `editor` and `edit_prediction`, -// but at time of writing there isn't an obvious spot. -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum Direction { - Prev, - Next, +pub mod cursor_excerpt; +pub mod example_spec; +mod license_detection; +pub mod mercury; +mod onboarding_modal; +pub mod open_ai_response; +mod prediction; +pub mod sweep_ai; + +#[cfg(any(test, feature = "test-support", feature = "cli-support"))] +pub mod udiff; + +mod zed_edit_prediction_delegate; +pub mod zeta1; +pub mod zeta2; + +#[cfg(test)] +mod edit_prediction_tests; + +use crate::license_detection::LicenseDetectionWatcher; +use crate::mercury::Mercury; +use crate::onboarding_modal::ZedPredictModal; +pub use crate::prediction::EditPrediction; +pub use crate::prediction::EditPredictionId; +use crate::prediction::EditPredictionResult; +pub use crate::sweep_ai::SweepAi; +pub use language_model::ApiKeyState; +pub use telemetry_events::EditPredictionRating; +pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate; + +actions!( + edit_prediction, + [ + /// Resets the edit prediction onboarding state. + ResetOnboarding, + /// Clears the edit prediction history. + ClearHistory, + ] +); + +/// Maximum number of events to track. +const EVENT_COUNT_MAX: usize = 6; +const CHANGE_GROUPING_LINE_SPAN: u32 = 8; +const LAST_CHANGE_GROUPING_TIME: Duration = Duration::from_secs(1); +const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; +const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); + +pub struct SweepFeatureFlag; + +impl FeatureFlag for SweepFeatureFlag { + const NAME: &str = "sweep-ai"; } -#[derive(Clone)] -pub enum EditPrediction { - /// Edits within the buffer that requested the prediction - Local { - id: Option, - edits: Vec<(Range, Arc)>, - edit_preview: Option, - }, - /// Jump to a different file from the one that requested the prediction - Jump { - id: Option, - snapshot: language::BufferSnapshot, - target: language::Anchor, +pub struct MercuryFeatureFlag; + +impl FeatureFlag for MercuryFeatureFlag { + const NAME: &str = "mercury"; +} + +pub const DEFAULT_OPTIONS: ZetaOptions = ZetaOptions { + context: EditPredictionExcerptOptions { + max_bytes: 512, + min_bytes: 128, + target_before_cursor_over_total_bytes: 0.5, }, + prompt_format: PromptFormat::DEFAULT, +}; + +static USE_OLLAMA: LazyLock = + LazyLock::new(|| env::var("ZED_ZETA2_OLLAMA").is_ok_and(|var| !var.is_empty())); + +static EDIT_PREDICTIONS_MODEL_ID: LazyLock = LazyLock::new(|| { + match env::var("ZED_ZETA2_MODEL").as_deref() { + Ok("zeta2-exp") => "4w5n28vw", // Fine-tuned model @ Baseten + Ok(model) => model, + Err(_) if *USE_OLLAMA => "qwen3-coder:30b", + Err(_) => "yqvev8r3", // Vanilla qwen3-coder @ Baseten + } + .to_string() +}); + +pub struct Zeta2FeatureFlag; + +impl FeatureFlag for Zeta2FeatureFlag { + const NAME: &'static str = "zeta2"; + + fn enabled_for_staff() -> bool { + true + } +} + +#[derive(Clone)] +struct EditPredictionStoreGlobal(Entity); + +impl Global for EditPredictionStoreGlobal {} + +pub struct EditPredictionStore { + client: Arc, + user_store: Entity, + llm_token: LlmApiToken, + _llm_token_subscription: Subscription, + projects: HashMap, + use_context: bool, + options: ZetaOptions, + update_required: bool, + #[cfg(feature = "cli-support")] + eval_cache: Option>, + edit_prediction_model: EditPredictionModel, + pub sweep_ai: SweepAi, + pub mercury: Mercury, + data_collection_choice: DataCollectionChoice, + reject_predictions_tx: mpsc::UnboundedSender, + shown_predictions: VecDeque, + rated_predictions: HashSet, + custom_predict_edits_url: Option>, +} + +#[derive(Copy, Clone, Default, PartialEq, Eq)] +pub enum EditPredictionModel { + #[default] + Zeta1, + Zeta2, + Sweep, + Mercury, +} + +pub struct EditPredictionModelInput { + project: Entity, + buffer: Entity, + snapshot: BufferSnapshot, + position: Anchor, + events: Vec>, + related_files: Arc<[RelatedFile]>, + recent_paths: VecDeque, + trigger: PredictEditsRequestTrigger, + diagnostic_search_range: Range, + debug_tx: Option>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ZetaOptions { + pub context: EditPredictionExcerptOptions, + pub prompt_format: predict_edits_v3::PromptFormat, +} + +#[derive(Debug)] +pub enum DebugEvent { + ContextRetrievalStarted(ContextRetrievalStartedDebugEvent), + ContextRetrievalFinished(ContextRetrievalFinishedDebugEvent), + EditPredictionStarted(EditPredictionStartedDebugEvent), + EditPredictionFinished(EditPredictionFinishedDebugEvent), +} + +#[derive(Debug)] +pub struct ContextRetrievalStartedDebugEvent { + pub project_entity_id: EntityId, + pub timestamp: Instant, + pub search_prompt: String, +} + +#[derive(Debug)] +pub struct ContextRetrievalFinishedDebugEvent { + pub project_entity_id: EntityId, + pub timestamp: Instant, + pub metadata: Vec<(&'static str, SharedString)>, +} + +#[derive(Debug)] +pub struct EditPredictionStartedDebugEvent { + pub buffer: WeakEntity, + pub position: Anchor, + pub prompt: Option, +} + +#[derive(Debug)] +pub struct EditPredictionFinishedDebugEvent { + pub buffer: WeakEntity, + pub position: Anchor, + pub model_output: Option, +} + +pub type RequestDebugInfo = predict_edits_v3::DebugInfo; + +struct ProjectState { + events: VecDeque>, + last_event: Option, + recent_paths: VecDeque, + registered_buffers: HashMap, + current_prediction: Option, + next_pending_prediction_id: usize, + pending_predictions: ArrayVec, + debug_tx: Option>, + last_prediction_refresh: Option<(EntityId, Instant)>, + cancelled_predictions: HashSet, + context: Entity, + license_detection_watchers: HashMap>, + _subscription: gpui::Subscription, +} + +impl ProjectState { + pub fn events(&self, cx: &App) -> Vec> { + self.events + .iter() + .cloned() + .chain( + self.last_event + .as_ref() + .and_then(|event| event.finalize(&self.license_detection_watchers, cx)), + ) + .collect() + } + + pub fn events_split_by_pause(&self, cx: &App) -> Vec> { + self.events + .iter() + .cloned() + .chain(self.last_event.as_ref().iter().flat_map(|event| { + let (one, two) = event.split_by_pause(); + let one = one.finalize(&self.license_detection_watchers, cx); + let two = two.and_then(|two| two.finalize(&self.license_detection_watchers, cx)); + one.into_iter().chain(two) + })) + .collect() + } + + fn cancel_pending_prediction( + &mut self, + pending_prediction: PendingPrediction, + cx: &mut Context, + ) { + self.cancelled_predictions.insert(pending_prediction.id); + + cx.spawn(async move |this, cx| { + let Some(prediction_id) = pending_prediction.task.await else { + return; + }; + + this.update(cx, |this, _cx| { + this.reject_prediction(prediction_id, EditPredictionRejectReason::Canceled, false); + }) + .ok(); + }) + .detach() + } + + fn active_buffer( + &self, + project: &Entity, + cx: &App, + ) -> Option<(Entity, Option)> { + let project = project.read(cx); + let active_path = project.path_for_entry(project.active_entry()?, cx)?; + let active_buffer = project.buffer_store().read(cx).get_by_path(&active_path)?; + let registered_buffer = self.registered_buffers.get(&active_buffer.entity_id())?; + Some((active_buffer, registered_buffer.last_position)) + } } -pub enum DataCollectionState { - /// The provider doesn't support data collection. - Unsupported, - /// Data collection is enabled. - Enabled { is_project_open_source: bool }, - /// Data collection is disabled or unanswered. - Disabled { is_project_open_source: bool }, +#[derive(Debug, Clone)] +struct CurrentEditPrediction { + pub requested_by: PredictionRequestedBy, + pub prediction: EditPrediction, + pub was_shown: bool, } -impl DataCollectionState { - pub fn is_supported(&self) -> bool { - !matches!(self, DataCollectionState::Unsupported) +impl CurrentEditPrediction { + fn should_replace_prediction(&self, old_prediction: &Self, cx: &App) -> bool { + let Some(new_edits) = self + .prediction + .interpolate(&self.prediction.buffer.read(cx)) + else { + return false; + }; + + if self.prediction.buffer != old_prediction.prediction.buffer { + return true; + } + + let Some(old_edits) = old_prediction + .prediction + .interpolate(&old_prediction.prediction.buffer.read(cx)) + else { + return true; + }; + + let requested_by_buffer_id = self.requested_by.buffer_id(); + + // This reduces the occurrence of UI thrash from replacing edits + // + // TODO: This is fairly arbitrary - should have a more general heuristic that handles multiple edits. + if requested_by_buffer_id == Some(self.prediction.buffer.entity_id()) + && requested_by_buffer_id == Some(old_prediction.prediction.buffer.entity_id()) + && old_edits.len() == 1 + && new_edits.len() == 1 + { + let (old_range, old_text) = &old_edits[0]; + let (new_range, new_text) = &new_edits[0]; + new_range == old_range && new_text.starts_with(old_text.as_ref()) + } else { + true + } } +} - pub fn is_enabled(&self) -> bool { - matches!(self, DataCollectionState::Enabled { .. }) +#[derive(Debug, Clone)] +enum PredictionRequestedBy { + DiagnosticsUpdate, + Buffer(EntityId), +} + +impl PredictionRequestedBy { + pub fn buffer_id(&self) -> Option { + match self { + PredictionRequestedBy::DiagnosticsUpdate => None, + PredictionRequestedBy::Buffer(buffer_id) => Some(*buffer_id), + } } +} + +#[derive(Debug)] +struct PendingPrediction { + id: usize, + task: Task>, +} - pub fn is_project_open_source(&self) -> bool { +/// A prediction from the perspective of a buffer. +#[derive(Debug)] +enum BufferEditPrediction<'a> { + Local { prediction: &'a EditPrediction }, + Jump { prediction: &'a EditPrediction }, +} + +#[cfg(test)] +impl std::ops::Deref for BufferEditPrediction<'_> { + type Target = EditPrediction; + + fn deref(&self) -> &Self::Target { match self { - Self::Enabled { - is_project_open_source, - } - | Self::Disabled { - is_project_open_source, - } => *is_project_open_source, - _ => false, + BufferEditPrediction::Local { prediction } => prediction, + BufferEditPrediction::Jump { prediction } => prediction, + } + } +} + +struct RegisteredBuffer { + file: Option>, + snapshot: TextBufferSnapshot, + last_position: Option, + _subscriptions: [gpui::Subscription; 2], +} + +#[derive(Clone)] +struct LastEvent { + old_snapshot: TextBufferSnapshot, + new_snapshot: TextBufferSnapshot, + old_file: Option>, + new_file: Option>, + end_edit_anchor: Option, + snapshot_after_last_editing_pause: Option, + last_edit_time: Option, +} + +impl LastEvent { + pub fn finalize( + &self, + license_detection_watchers: &HashMap>, + cx: &App, + ) -> Option> { + let path = buffer_path_with_id_fallback(self.new_file.as_ref(), &self.new_snapshot, cx); + let old_path = buffer_path_with_id_fallback(self.old_file.as_ref(), &self.old_snapshot, cx); + + let in_open_source_repo = + [self.new_file.as_ref(), self.old_file.as_ref()] + .iter() + .all(|file| { + file.is_some_and(|file| { + license_detection_watchers + .get(&file.worktree_id(cx)) + .is_some_and(|watcher| watcher.is_project_open_source()) + }) + }); + + let diff = language::unified_diff(&self.old_snapshot.text(), &self.new_snapshot.text()); + + if path == old_path && diff.is_empty() { + None + } else { + Some(Arc::new(zeta_prompt::Event::BufferChange { + old_path, + path, + diff, + in_open_source_repo, + // TODO: Actually detect if this edit was predicted or not + predicted: false, + })) } } + + pub fn split_by_pause(&self) -> (LastEvent, Option) { + let Some(boundary_snapshot) = self.snapshot_after_last_editing_pause.as_ref() else { + return (self.clone(), None); + }; + + let before = LastEvent { + old_snapshot: self.old_snapshot.clone(), + new_snapshot: boundary_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + let after = LastEvent { + old_snapshot: boundary_snapshot.clone(), + new_snapshot: self.new_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + (before, Some(after)) + } } -pub trait EditPredictionProvider: 'static + Sized { - fn name() -> &'static str; - fn display_name() -> &'static str; - fn show_completions_in_menu() -> bool; - fn show_tab_accept_marker() -> bool { - false +fn buffer_path_with_id_fallback( + file: Option<&Arc>, + snapshot: &TextBufferSnapshot, + cx: &App, +) -> Arc { + if let Some(file) = file { + file.full_path(cx).into() + } else { + Path::new(&format!("untitled-{}", snapshot.remote_id())).into() } - fn supports_jump_to_edit() -> bool { - true +} + +impl EditPredictionStore { + pub fn try_global(cx: &App) -> Option> { + cx.try_global::() + .map(|global| global.0.clone()) + } + + pub fn global( + client: &Arc, + user_store: &Entity, + cx: &mut App, + ) -> Entity { + cx.try_global::() + .map(|global| global.0.clone()) + .unwrap_or_else(|| { + let ep_store = cx.new(|cx| Self::new(client.clone(), user_store.clone(), cx)); + cx.set_global(EditPredictionStoreGlobal(ep_store.clone())); + ep_store + }) + } + + pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { + let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let data_collection_choice = Self::load_data_collection_choice(); + + let llm_token = LlmApiToken::default(); + + let (reject_tx, reject_rx) = mpsc::unbounded(); + cx.background_spawn({ + let client = client.clone(); + let llm_token = llm_token.clone(); + let app_version = AppVersion::global(cx); + let background_executor = cx.background_executor().clone(); + async move { + Self::handle_rejected_predictions( + reject_rx, + client, + llm_token, + app_version, + background_executor, + ) + .await + } + }) + .detach(); + + let mut this = Self { + projects: HashMap::default(), + client, + user_store, + options: DEFAULT_OPTIONS, + use_context: false, + llm_token, + _llm_token_subscription: cx.subscribe( + &refresh_llm_token_listener, + |this, _listener, _event, cx| { + let client = this.client.clone(); + let llm_token = this.llm_token.clone(); + cx.spawn(async move |_this, _cx| { + llm_token.refresh(&client).await?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + }, + ), + update_required: false, + #[cfg(feature = "cli-support")] + eval_cache: None, + edit_prediction_model: EditPredictionModel::Zeta2, + sweep_ai: SweepAi::new(cx), + mercury: Mercury::new(cx), + data_collection_choice, + reject_predictions_tx: reject_tx, + rated_predictions: Default::default(), + shown_predictions: Default::default(), + custom_predict_edits_url: match env::var("ZED_PREDICT_EDITS_URL") { + Ok(custom_url) => Url::parse(&custom_url).log_err().map(Into::into), + Err(_) => { + if *USE_OLLAMA { + Some( + Url::parse("http://localhost:11434/v1/chat/completions") + .unwrap() + .into(), + ) + } else { + None + } + } + }, + }; + + this.configure_context_retrieval(cx); + let weak_this = cx.weak_entity(); + cx.on_flags_ready(move |_, cx| { + weak_this + .update(cx, |this, cx| this.configure_context_retrieval(cx)) + .ok(); + }) + .detach(); + cx.observe_global::(|this, cx| { + this.configure_context_retrieval(cx); + }) + .detach(); + + this + } + + #[cfg(test)] + pub fn set_custom_predict_edits_url(&mut self, url: Url) { + self.custom_predict_edits_url = Some(url.into()); + } + + pub fn set_edit_prediction_model(&mut self, model: EditPredictionModel) { + self.edit_prediction_model = model; + } + + pub fn has_sweep_api_token(&self, cx: &App) -> bool { + self.sweep_ai.api_token.read(cx).has_key() + } + + pub fn has_mercury_api_token(&self, cx: &App) -> bool { + self.mercury.api_token.read(cx).has_key() + } + + #[cfg(feature = "cli-support")] + pub fn with_eval_cache(&mut self, cache: Arc) { + self.eval_cache = Some(cache); + } + + pub fn options(&self) -> &ZetaOptions { + &self.options + } + + pub fn set_options(&mut self, options: ZetaOptions) { + self.options = options; + } + + pub fn set_use_context(&mut self, use_context: bool) { + self.use_context = use_context; + } + + pub fn clear_history(&mut self) { + for project_state in self.projects.values_mut() { + project_state.events.clear(); + } } - fn data_collection_state(&self, _cx: &App) -> DataCollectionState { - DataCollectionState::Unsupported + pub fn clear_history_for_project(&mut self, project: &Entity) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + project_state.events.clear(); + } } - fn usage(&self, _cx: &App) -> Option { - None + pub fn edit_history_for_project( + &self, + project: &Entity, + cx: &App, + ) -> Vec> { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events(cx)) + .unwrap_or_default() } - fn toggle_data_collection(&mut self, _cx: &mut App) {} - fn is_enabled( + pub fn edit_history_for_project_with_pause_split_last_event( &self, - buffer: &Entity, - cursor_position: language::Anchor, + project: &Entity, cx: &App, - ) -> bool; - fn is_refreshing(&self, cx: &App) -> bool; - fn refresh( + ) -> Vec> { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events_split_by_pause(cx)) + .unwrap_or_default() + } + + pub fn context_for_project<'a>( + &'a self, + project: &Entity, + cx: &'a App, + ) -> Arc<[RelatedFile]> { + self.projects + .get(&project.entity_id()) + .map(|project| project.context.read(cx).related_files()) + .unwrap_or_else(|| vec![].into()) + } + + pub fn context_for_project_with_buffers<'a>( + &'a self, + project: &Entity, + cx: &'a App, + ) -> Option)>> { + self.projects + .get(&project.entity_id()) + .map(|project| project.context.read(cx).related_files_with_buffers()) + } + + pub fn usage(&self, cx: &App) -> Option { + if self.edit_prediction_model == EditPredictionModel::Zeta2 { + self.user_store.read(cx).edit_prediction_usage() + } else { + None + } + } + + pub fn register_project(&mut self, project: &Entity, cx: &mut Context) { + self.get_or_init_project(project, cx); + } + + pub fn register_buffer( &mut self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, + buffer: &Entity, + project: &Entity, cx: &mut Context, - ); - fn cycle( + ) { + let project_state = self.get_or_init_project(project, cx); + Self::register_buffer_impl(project_state, buffer, project, cx); + } + + fn get_or_init_project( &mut self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, + project: &Entity, + cx: &mut Context, + ) -> &mut ProjectState { + let entity_id = project.entity_id(); + self.projects + .entry(entity_id) + .or_insert_with(|| ProjectState { + context: { + let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(project, cx)); + cx.subscribe(&related_excerpt_store, move |this, _, event, _| { + this.handle_excerpt_store_event(entity_id, event); + }) + .detach(); + related_excerpt_store + }, + events: VecDeque::new(), + last_event: None, + recent_paths: VecDeque::new(), + debug_tx: None, + registered_buffers: HashMap::default(), + current_prediction: None, + cancelled_predictions: HashSet::default(), + pending_predictions: ArrayVec::new(), + next_pending_prediction_id: 0, + last_prediction_refresh: None, + license_detection_watchers: HashMap::default(), + _subscription: cx.subscribe(&project, Self::handle_project_event), + }) + } + + pub fn remove_project(&mut self, project: &Entity) { + self.projects.remove(&project.entity_id()); + } + + fn handle_excerpt_store_event( + &mut self, + project_entity_id: EntityId, + event: &RelatedExcerptStoreEvent, + ) { + if let Some(project_state) = self.projects.get(&project_entity_id) { + if let Some(debug_tx) = project_state.debug_tx.clone() { + match event { + RelatedExcerptStoreEvent::StartedRefresh => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalStarted( + ContextRetrievalStartedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + search_prompt: String::new(), + }, + )) + .ok(); + } + RelatedExcerptStoreEvent::FinishedRefresh { + cache_hit_count, + cache_miss_count, + mean_definition_latency, + max_definition_latency, + } => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalFinished( + ContextRetrievalFinishedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + metadata: vec![ + ( + "Cache Hits", + format!( + "{}/{}", + cache_hit_count, + cache_hit_count + cache_miss_count + ) + .into(), + ), + ( + "Max LSP Time", + format!("{} ms", max_definition_latency.as_millis()) + .into(), + ), + ( + "Mean LSP Time", + format!("{} ms", mean_definition_latency.as_millis()) + .into(), + ), + ], + }, + )) + .ok(); + } + } + } + } + } + + pub fn debug_info( + &mut self, + project: &Entity, + cx: &mut Context, + ) -> mpsc::UnboundedReceiver { + let project_state = self.get_or_init_project(project, cx); + let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); + project_state.debug_tx = Some(debug_watch_tx); + debug_watch_rx + } + + fn handle_project_event( + &mut self, + project: Entity, + event: &project::Event, + cx: &mut Context, + ) { + // TODO [zeta2] init with recent paths + match event { + project::Event::ActiveEntryChanged(Some(active_entry_id)) => { + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + let path = project.read(cx).path_for_entry(*active_entry_id, cx); + if let Some(path) = path { + if let Some(ix) = project_state + .recent_paths + .iter() + .position(|probe| probe == &path) + { + project_state.recent_paths.remove(ix); + } + project_state.recent_paths.push_front(path); + } + } + project::Event::DiagnosticsUpdated { .. } => { + if cx.has_flag::() { + self.refresh_prediction_from_diagnostics(project, cx); + } + } + _ => (), + } + } + + fn register_buffer_impl<'a>( + project_state: &'a mut ProjectState, + buffer: &Entity, + project: &Entity, cx: &mut Context, - ); - fn accept(&mut self, cx: &mut Context); - fn discard(&mut self, cx: &mut Context); - fn did_show(&mut self, _cx: &mut Context) {} - fn suggest( + ) -> &'a mut RegisteredBuffer { + let buffer_id = buffer.entity_id(); + + if let Some(file) = buffer.read(cx).file() { + let worktree_id = file.worktree_id(cx); + if let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) { + project_state + .license_detection_watchers + .entry(worktree_id) + .or_insert_with(|| { + let project_entity_id = project.entity_id(); + cx.observe_release(&worktree, move |this, _worktree, _cx| { + let Some(project_state) = this.projects.get_mut(&project_entity_id) + else { + return; + }; + project_state + .license_detection_watchers + .remove(&worktree_id); + }) + .detach(); + Rc::new(LicenseDetectionWatcher::new(&worktree, cx)) + }); + } + } + + match project_state.registered_buffers.entry(buffer_id) { + hash_map::Entry::Occupied(entry) => entry.into_mut(), + hash_map::Entry::Vacant(entry) => { + let buf = buffer.read(cx); + let snapshot = buf.text_snapshot(); + let file = buf.file().cloned(); + let project_entity_id = project.entity_id(); + entry.insert(RegisteredBuffer { + snapshot, + file, + last_position: None, + _subscriptions: [ + cx.subscribe(buffer, { + let project = project.downgrade(); + move |this, buffer, event, cx| { + if let language::BufferEvent::Edited = event + && let Some(project) = project.upgrade() + { + this.report_changes_for_buffer(&buffer, &project, cx); + } + } + }), + cx.observe_release(buffer, move |this, _buffer, _cx| { + let Some(project_state) = this.projects.get_mut(&project_entity_id) + else { + return; + }; + project_state.registered_buffers.remove(&buffer_id); + }), + ], + }) + } + } + } + + fn report_changes_for_buffer( &mut self, buffer: &Entity, - cursor_position: language::Anchor, + project: &Entity, cx: &mut Context, - ) -> Option; -} + ) { + let project_state = self.get_or_init_project(project, cx); + let registered_buffer = Self::register_buffer_impl(project_state, buffer, project, cx); -pub trait EditPredictionProviderHandle { - fn name(&self) -> &'static str; - fn display_name(&self) -> &'static str; - fn is_enabled( - &self, + let buf = buffer.read(cx); + let new_file = buf.file().cloned(); + let new_snapshot = buf.text_snapshot(); + if new_snapshot.version == registered_buffer.snapshot.version { + return; + } + + let old_file = mem::replace(&mut registered_buffer.file, new_file.clone()); + let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); + let end_edit_anchor = new_snapshot + .anchored_edits_since::(&old_snapshot.version) + .last() + .map(|(_, range)| range.end); + let events = &mut project_state.events; + + let now = cx.background_executor().now(); + if let Some(last_event) = project_state.last_event.as_mut() { + let is_next_snapshot_of_same_buffer = old_snapshot.remote_id() + == last_event.new_snapshot.remote_id() + && old_snapshot.version == last_event.new_snapshot.version; + + let should_coalesce = is_next_snapshot_of_same_buffer + && end_edit_anchor + .as_ref() + .zip(last_event.end_edit_anchor.as_ref()) + .is_some_and(|(a, b)| { + let a = a.to_point(&new_snapshot); + let b = b.to_point(&new_snapshot); + a.row.abs_diff(b.row) <= CHANGE_GROUPING_LINE_SPAN + }); + + if should_coalesce { + let pause_elapsed = last_event + .last_edit_time + .map(|t| now.duration_since(t) >= LAST_CHANGE_GROUPING_TIME) + .unwrap_or(false); + if pause_elapsed { + last_event.snapshot_after_last_editing_pause = + Some(last_event.new_snapshot.clone()); + } + + last_event.end_edit_anchor = end_edit_anchor; + last_event.new_snapshot = new_snapshot; + last_event.last_edit_time = Some(now); + return; + } + } + + if events.len() + 1 >= EVENT_COUNT_MAX { + events.pop_front(); + } + + if let Some(event) = project_state.last_event.take() { + events.extend(event.finalize(&project_state.license_detection_watchers, cx)); + } + + project_state.last_event = Some(LastEvent { + old_file, + new_file, + old_snapshot, + new_snapshot, + end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: Some(now), + }); + } + + fn prediction_at( + &mut self, buffer: &Entity, - cursor_position: language::Anchor, + position: Option, + project: &Entity, cx: &App, - ) -> bool; - fn show_completions_in_menu(&self) -> bool; - fn show_tab_accept_marker(&self) -> bool; - fn supports_jump_to_edit(&self) -> bool; - fn data_collection_state(&self, cx: &App) -> DataCollectionState; - fn usage(&self, cx: &App) -> Option; - fn toggle_data_collection(&self, cx: &mut App); - fn is_refreshing(&self, cx: &App) -> bool; - fn refresh( - &self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, - cx: &mut App, - ); - fn cycle( - &self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut App, - ); - fn did_show(&self, cx: &mut App); - fn accept(&self, cx: &mut App); - fn discard(&self, cx: &mut App); - fn suggest( - &self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut App, - ) -> Option; -} + ) -> Option> { + let project_state = self.projects.get_mut(&project.entity_id())?; + if let Some(position) = position + && let Some(buffer) = project_state + .registered_buffers + .get_mut(&buffer.entity_id()) + { + buffer.last_position = Some(position); + } + + let CurrentEditPrediction { + requested_by, + prediction, + .. + } = project_state.current_prediction.as_ref()?; + + if prediction.targets_buffer(buffer.read(cx)) { + Some(BufferEditPrediction::Local { prediction }) + } else { + let show_jump = match requested_by { + PredictionRequestedBy::Buffer(requested_by_buffer_id) => { + requested_by_buffer_id == &buffer.entity_id() + } + PredictionRequestedBy::DiagnosticsUpdate => true, + }; -impl EditPredictionProviderHandle for Entity -where - T: EditPredictionProvider, -{ - fn name(&self) -> &'static str { - T::name() + if show_jump { + Some(BufferEditPrediction::Jump { prediction }) + } else { + None + } + } } - fn display_name(&self) -> &'static str { - T::display_name() + fn accept_current_prediction(&mut self, project: &Entity, cx: &mut Context) { + let custom_accept_url = env::var("ZED_ACCEPT_PREDICTION_URL").ok(); + match self.edit_prediction_model { + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() && custom_accept_url.is_none() { + return; + } + } + EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, + } + + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + + let Some(prediction) = project_state.current_prediction.take() else { + return; + }; + let request_id = prediction.prediction.id.to_string(); + for pending_prediction in mem::take(&mut project_state.pending_predictions) { + project_state.cancel_pending_prediction(pending_prediction, cx); + } + + let client = self.client.clone(); + let llm_token = self.llm_token.clone(); + let app_version = AppVersion::global(cx); + cx.spawn(async move |this, cx| { + let (url, require_auth) = if let Some(accept_edits_url) = custom_accept_url { + (http_client::Url::parse(&accept_edits_url)?, false) + } else { + ( + client + .http_client() + .build_zed_llm_url("/predict_edits/accept", &[])?, + true, + ) + }; + + let response = cx + .background_spawn(Self::send_api_request::<()>( + move |builder| { + let req = builder.uri(url.as_ref()).body( + serde_json::to_string(&AcceptEditPredictionBody { + request_id: request_id.clone(), + })? + .into(), + ); + Ok(req?) + }, + client, + llm_token, + app_version, + require_auth, + )) + .await; + + Self::handle_api_response(&this, response, cx)?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + async fn handle_rejected_predictions( + rx: UnboundedReceiver, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + background_executor: BackgroundExecutor, + ) { + let mut rx = std::pin::pin!(rx.peekable()); + let mut batched = Vec::new(); + + while let Some(rejection) = rx.next().await { + batched.push(rejection); + + if batched.len() < MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST / 2 { + select_biased! { + next = rx.as_mut().peek().fuse() => { + if next.is_some() { + continue; + } + } + () = background_executor.timer(REJECT_REQUEST_DEBOUNCE).fuse() => {}, + } + } + + let url = client + .http_client() + .build_zed_llm_url("/predict_edits/reject", &[]) + .unwrap(); + + let flush_count = batched + .len() + // in case items have accumulated after failure + .min(MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST); + let start = batched.len() - flush_count; + + let body = RejectEditPredictionsBodyRef { + rejections: &batched[start..], + }; + + let result = Self::send_api_request::<()>( + |builder| { + let req = builder + .uri(url.as_ref()) + .body(serde_json::to_string(&body)?.into()); + anyhow::Ok(req?) + }, + client.clone(), + llm_token.clone(), + app_version.clone(), + true, + ) + .await; + + if result.log_err().is_some() { + batched.drain(start..); + } + } } - fn show_completions_in_menu(&self) -> bool { - T::show_completions_in_menu() + fn reject_current_prediction( + &mut self, + reason: EditPredictionRejectReason, + project: &Entity, + ) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + project_state.pending_predictions.clear(); + if let Some(prediction) = project_state.current_prediction.take() { + self.reject_prediction(prediction.prediction.id, reason, prediction.was_shown); + } + }; } - fn show_tab_accept_marker(&self) -> bool { - T::show_tab_accept_marker() + fn did_show_current_prediction(&mut self, project: &Entity, _cx: &mut Context) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + if let Some(current_prediction) = project_state.current_prediction.as_mut() { + if !current_prediction.was_shown { + current_prediction.was_shown = true; + self.shown_predictions + .push_front(current_prediction.prediction.clone()); + if self.shown_predictions.len() > 50 { + let completion = self.shown_predictions.pop_back().unwrap(); + self.rated_predictions.remove(&completion.id); + } + } + } + } } - fn supports_jump_to_edit(&self) -> bool { - T::supports_jump_to_edit() + fn reject_prediction( + &mut self, + prediction_id: EditPredictionId, + reason: EditPredictionRejectReason, + was_shown: bool, + ) { + match self.edit_prediction_model { + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() { + return; + } + } + EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, + } + + self.reject_predictions_tx + .unbounded_send(EditPredictionRejection { + request_id: prediction_id.to_string(), + reason, + was_shown, + }) + .log_err(); } - fn data_collection_state(&self, cx: &App) -> DataCollectionState { - self.read(cx).data_collection_state(cx) + fn is_refreshing(&self, project: &Entity) -> bool { + self.projects + .get(&project.entity_id()) + .is_some_and(|project_state| !project_state.pending_predictions.is_empty()) } - fn usage(&self, cx: &App) -> Option { - self.read(cx).usage(cx) + pub fn refresh_prediction_from_buffer( + &mut self, + project: Entity, + buffer: Entity, + position: language::Anchor, + cx: &mut Context, + ) { + self.queue_prediction_refresh(project.clone(), buffer.entity_id(), cx, move |this, cx| { + let Some(request_task) = this + .update(cx, |this, cx| { + this.request_prediction( + &project, + &buffer, + position, + PredictEditsRequestTrigger::Other, + cx, + ) + }) + .log_err() + else { + return Task::ready(anyhow::Ok(None)); + }; + + cx.spawn(async move |_cx| { + request_task.await.map(|prediction_result| { + prediction_result.map(|prediction_result| { + ( + prediction_result, + PredictionRequestedBy::Buffer(buffer.entity_id()), + ) + }) + }) + }) + }) } - fn toggle_data_collection(&self, cx: &mut App) { - self.update(cx, |this, cx| this.toggle_data_collection(cx)) + pub fn refresh_prediction_from_diagnostics( + &mut self, + project: Entity, + cx: &mut Context, + ) { + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + + // Prefer predictions from buffer + if project_state.current_prediction.is_some() { + return; + }; + + self.queue_prediction_refresh(project.clone(), project.entity_id(), cx, move |this, cx| { + let Some((active_buffer, snapshot, cursor_point)) = this + .read_with(cx, |this, cx| { + let project_state = this.projects.get(&project.entity_id())?; + let (buffer, position) = project_state.active_buffer(&project, cx)?; + let snapshot = buffer.read(cx).snapshot(); + + if !Self::predictions_enabled_at(&snapshot, position, cx) { + return None; + } + + let cursor_point = position + .map(|pos| pos.to_point(&snapshot)) + .unwrap_or_default(); + + Some((buffer, snapshot, cursor_point)) + }) + .log_err() + .flatten() + else { + return Task::ready(anyhow::Ok(None)); + }; + + cx.spawn(async move |cx| { + let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( + active_buffer, + &snapshot, + Default::default(), + cursor_point, + &project, + cx, + ) + .await? + else { + return anyhow::Ok(None); + }; + + let Some(prediction_result) = this + .update(cx, |this, cx| { + this.request_prediction( + &project, + &jump_buffer, + jump_position, + PredictEditsRequestTrigger::Diagnostics, + cx, + ) + })? + .await? + else { + return anyhow::Ok(None); + }; + + this.update(cx, |this, cx| { + Some(( + if this + .get_or_init_project(&project, cx) + .current_prediction + .is_none() + { + prediction_result + } else { + EditPredictionResult { + id: prediction_result.id, + prediction: Err(EditPredictionRejectReason::CurrentPreferred), + } + }, + PredictionRequestedBy::DiagnosticsUpdate, + )) + }) + }) + }); } - fn is_enabled( - &self, - buffer: &Entity, - cursor_position: language::Anchor, + fn predictions_enabled_at( + snapshot: &BufferSnapshot, + position: Option, cx: &App, ) -> bool { - self.read(cx).is_enabled(buffer, cursor_position, cx) - } + let file = snapshot.file(); + let all_settings = all_language_settings(file, cx); + if !all_settings.show_edit_predictions(snapshot.language(), cx) + || file.is_some_and(|file| !all_settings.edit_predictions_enabled_for_file(file, cx)) + { + return false; + } + + if let Some(last_position) = position { + let settings = snapshot.settings_at(last_position, cx); + + if !settings.edit_predictions_disabled_in.is_empty() + && let Some(scope) = snapshot.language_scope_at(last_position) + && let Some(scope_name) = scope.override_name() + && settings + .edit_predictions_disabled_in + .iter() + .any(|s| s == scope_name) + { + return false; + } + } - fn is_refreshing(&self, cx: &App) -> bool { - self.read(cx).is_refreshing(cx) + true } - fn refresh( - &self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, - cx: &mut App, + #[cfg(not(test))] + pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); + #[cfg(test)] + pub const THROTTLE_TIMEOUT: Duration = Duration::ZERO; + + fn queue_prediction_refresh( + &mut self, + project: Entity, + throttle_entity: EntityId, + cx: &mut Context, + do_refresh: impl FnOnce( + WeakEntity, + &mut AsyncApp, + ) + -> Task>> + + 'static, ) { - self.update(cx, |this, cx| { - this.refresh(buffer, cursor_position, debounce, cx) - }) + let project_state = self.get_or_init_project(&project, cx); + let pending_prediction_id = project_state.next_pending_prediction_id; + project_state.next_pending_prediction_id += 1; + let last_request = project_state.last_prediction_refresh; + + let task = cx.spawn(async move |this, cx| { + if let Some((last_entity, last_timestamp)) = last_request + && throttle_entity == last_entity + && let Some(timeout) = + (last_timestamp + Self::THROTTLE_TIMEOUT).checked_duration_since(Instant::now()) + { + cx.background_executor().timer(timeout).await; + } + + // If this task was cancelled before the throttle timeout expired, + // do not perform a request. + let mut is_cancelled = true; + this.update(cx, |this, cx| { + let project_state = this.get_or_init_project(&project, cx); + if !project_state + .cancelled_predictions + .remove(&pending_prediction_id) + { + project_state.last_prediction_refresh = Some((throttle_entity, Instant::now())); + is_cancelled = false; + } + }) + .ok(); + if is_cancelled { + return None; + } + + let new_prediction_result = do_refresh(this.clone(), cx).await.log_err().flatten(); + let new_prediction_id = new_prediction_result + .as_ref() + .map(|(prediction, _)| prediction.id.clone()); + + // When a prediction completes, remove it from the pending list, and cancel + // any pending predictions that were enqueued before it. + this.update(cx, |this, cx| { + let project_state = this.get_or_init_project(&project, cx); + + let is_cancelled = project_state + .cancelled_predictions + .remove(&pending_prediction_id); + + let new_current_prediction = if !is_cancelled + && let Some((prediction_result, requested_by)) = new_prediction_result + { + match prediction_result.prediction { + Ok(prediction) => { + let new_prediction = CurrentEditPrediction { + requested_by, + prediction, + was_shown: false, + }; + + if let Some(current_prediction) = + project_state.current_prediction.as_ref() + { + if new_prediction.should_replace_prediction(¤t_prediction, cx) + { + this.reject_current_prediction( + EditPredictionRejectReason::Replaced, + &project, + ); + + Some(new_prediction) + } else { + this.reject_prediction( + new_prediction.prediction.id, + EditPredictionRejectReason::CurrentPreferred, + false, + ); + None + } + } else { + Some(new_prediction) + } + } + Err(reject_reason) => { + this.reject_prediction(prediction_result.id, reject_reason, false); + None + } + } + } else { + None + }; + + let project_state = this.get_or_init_project(&project, cx); + + if let Some(new_prediction) = new_current_prediction { + project_state.current_prediction = Some(new_prediction); + } + + let mut pending_predictions = mem::take(&mut project_state.pending_predictions); + for (ix, pending_prediction) in pending_predictions.iter().enumerate() { + if pending_prediction.id == pending_prediction_id { + pending_predictions.remove(ix); + for pending_prediction in pending_predictions.drain(0..ix) { + project_state.cancel_pending_prediction(pending_prediction, cx) + } + break; + } + } + this.get_or_init_project(&project, cx).pending_predictions = pending_predictions; + cx.notify(); + }) + .ok(); + + new_prediction_id + }); + + if project_state.pending_predictions.len() <= 1 { + project_state.pending_predictions.push(PendingPrediction { + id: pending_prediction_id, + task, + }); + } else if project_state.pending_predictions.len() == 2 { + let pending_prediction = project_state.pending_predictions.pop().unwrap(); + project_state.pending_predictions.push(PendingPrediction { + id: pending_prediction_id, + task, + }); + project_state.cancel_pending_prediction(pending_prediction, cx); + } } - fn cycle( - &self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut App, - ) { - self.update(cx, |this, cx| { - this.cycle(buffer, cursor_position, direction, cx) + pub fn request_prediction( + &mut self, + project: &Entity, + active_buffer: &Entity, + position: language::Anchor, + trigger: PredictEditsRequestTrigger, + cx: &mut Context, + ) -> Task>> { + self.request_prediction_internal( + project.clone(), + active_buffer.clone(), + position, + trigger, + cx.has_flag::(), + cx, + ) + } + + fn request_prediction_internal( + &mut self, + project: Entity, + active_buffer: Entity, + position: language::Anchor, + trigger: PredictEditsRequestTrigger, + allow_jump: bool, + cx: &mut Context, + ) -> Task>> { + const DIAGNOSTIC_LINES_RANGE: u32 = 20; + + self.get_or_init_project(&project, cx); + let project_state = self.projects.get(&project.entity_id()).unwrap(); + let events = project_state.events(cx); + let has_events = !events.is_empty(); + let debug_tx = project_state.debug_tx.clone(); + + let snapshot = active_buffer.read(cx).snapshot(); + let cursor_point = position.to_point(&snapshot); + let diagnostic_search_start = cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE); + let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE; + let diagnostic_search_range = + Point::new(diagnostic_search_start, 0)..Point::new(diagnostic_search_end, 0); + + let related_files = if self.use_context { + self.context_for_project(&project, cx) + } else { + Vec::new().into() + }; + + let inputs = EditPredictionModelInput { + project: project.clone(), + buffer: active_buffer.clone(), + snapshot: snapshot.clone(), + position, + events, + related_files, + recent_paths: project_state.recent_paths.clone(), + trigger, + diagnostic_search_range: diagnostic_search_range.clone(), + debug_tx, + }; + + let task = match self.edit_prediction_model { + EditPredictionModel::Zeta1 => zeta1::request_prediction_with_zeta1(self, inputs, cx), + EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2(self, inputs, cx), + EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx), + EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), + }; + + cx.spawn(async move |this, cx| { + let prediction = task.await?; + + if prediction.is_none() && allow_jump { + let cursor_point = position.to_point(&snapshot); + if has_events + && let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( + active_buffer.clone(), + &snapshot, + diagnostic_search_range, + cursor_point, + &project, + cx, + ) + .await? + { + return this + .update(cx, |this, cx| { + this.request_prediction_internal( + project, + jump_buffer, + jump_position, + trigger, + false, + cx, + ) + })? + .await; + } + + return anyhow::Ok(None); + } + + Ok(prediction) }) } - fn accept(&self, cx: &mut App) { - self.update(cx, |this, cx| this.accept(cx)) + async fn next_diagnostic_location( + active_buffer: Entity, + active_buffer_snapshot: &BufferSnapshot, + active_buffer_diagnostic_search_range: Range, + active_buffer_cursor_point: Point, + project: &Entity, + cx: &mut AsyncApp, + ) -> Result, language::Anchor)>> { + // find the closest diagnostic to the cursor that wasn't close enough to be included in the last request + let mut jump_location = active_buffer_snapshot + .diagnostic_groups(None) + .into_iter() + .filter_map(|(_, group)| { + let range = &group.entries[group.primary_ix] + .range + .to_point(&active_buffer_snapshot); + if range.overlaps(&active_buffer_diagnostic_search_range) { + None + } else { + Some(range.start) + } + }) + .min_by_key(|probe| probe.row.abs_diff(active_buffer_cursor_point.row)) + .map(|position| { + ( + active_buffer.clone(), + active_buffer_snapshot.anchor_before(position), + ) + }); + + if jump_location.is_none() { + let active_buffer_path = active_buffer.read_with(cx, |buffer, cx| { + let file = buffer.file()?; + + Some(ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }) + })?; + + let buffer_task = project.update(cx, |project, cx| { + let (path, _, _) = project + .diagnostic_summaries(false, cx) + .filter(|(path, _, _)| Some(path) != active_buffer_path.as_ref()) + .max_by_key(|(path, _, _)| { + // find the buffer with errors that shares most parent directories + path.path + .components() + .zip( + active_buffer_path + .as_ref() + .map(|p| p.path.components()) + .unwrap_or_default(), + ) + .take_while(|(a, b)| a == b) + .count() + })?; + + Some(project.open_buffer(path, cx)) + })?; + + if let Some(buffer_task) = buffer_task { + let closest_buffer = buffer_task.await?; + + jump_location = closest_buffer + .read_with(cx, |buffer, _cx| { + buffer + .buffer_diagnostics(None) + .into_iter() + .min_by_key(|entry| entry.diagnostic.severity) + .map(|entry| entry.range.start) + })? + .map(|position| (closest_buffer, position)); + } + } + + anyhow::Ok(jump_location) } - fn discard(&self, cx: &mut App) { - self.update(cx, |this, cx| this.discard(cx)) + async fn send_raw_llm_request( + request: open_ai::Request, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + #[cfg(feature = "cli-support")] eval_cache: Option>, + #[cfg(feature = "cli-support")] eval_cache_kind: EvalCacheEntryKind, + ) -> Result<(open_ai::Response, Option)> { + let url = client + .http_client() + .build_zed_llm_url("/predict_edits/raw", &[])?; + + #[cfg(feature = "cli-support")] + let cache_key = if let Some(cache) = eval_cache { + use collections::FxHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = FxHasher::default(); + url.hash(&mut hasher); + let request_str = serde_json::to_string_pretty(&request)?; + request_str.hash(&mut hasher); + let hash = hasher.finish(); + + let key = (eval_cache_kind, hash); + if let Some(response_str) = cache.read(key) { + return Ok((serde_json::from_str(&response_str)?, None)); + } + + Some((cache, request_str, key)) + } else { + None + }; + + let (response, usage) = Self::send_api_request( + |builder| { + let req = builder + .uri(url.as_ref()) + .body(serde_json::to_string(&request)?.into()); + Ok(req?) + }, + client, + llm_token, + app_version, + true, + ) + .await?; + + #[cfg(feature = "cli-support")] + if let Some((cache, request, key)) = cache_key { + cache.write(key, &request, &serde_json::to_string_pretty(&response)?); + } + + Ok((response, usage)) } - fn did_show(&self, cx: &mut App) { - self.update(cx, |this, cx| this.did_show(cx)) + fn handle_api_response( + this: &WeakEntity, + response: Result<(T, Option)>, + cx: &mut gpui::AsyncApp, + ) -> Result { + match response { + Ok((data, usage)) => { + if let Some(usage) = usage { + this.update(cx, |this, cx| { + this.user_store.update(cx, |user_store, cx| { + user_store.update_edit_prediction_usage(usage, cx); + }); + }) + .ok(); + } + Ok(data) + } + Err(err) => { + if err.is::() { + cx.update(|cx| { + this.update(cx, |this, _cx| { + this.update_required = true; + }) + .ok(); + + let error_message: SharedString = err.to_string().into(); + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + cx.new(|cx| { + ErrorMessagePrompt::new(error_message.clone(), cx) + .with_link_button("Update Zed", "https://zed.dev/releases") + }) + }, + ); + }) + .ok(); + } + Err(err) + } + } } - fn suggest( - &self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut App, - ) -> Option { - self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx)) - } -} - -/// Returns edits updated based on user edits since the old snapshot. None is returned if any user -/// edit is not a prefix of a predicted insertion. -pub fn interpolate_edits( - old_snapshot: &BufferSnapshot, - new_snapshot: &BufferSnapshot, - current_edits: &[(Range, Arc)], -) -> Option, Arc)>> { - let mut edits = Vec::new(); - - let mut model_edits = current_edits.iter().peekable(); - for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { - while let Some((model_old_range, _)) = model_edits.peek() { - let model_old_range = model_old_range.to_offset(old_snapshot); - if model_old_range.end < user_edit.old.start { - let (model_old_range, model_new_text) = model_edits.next().unwrap(); - edits.push((model_old_range.clone(), model_new_text.clone())); + async fn send_api_request( + build: impl Fn(http_client::http::request::Builder) -> Result>, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + require_auth: bool, + ) -> Result<(Res, Option)> + where + Res: DeserializeOwned, + { + let http_client = client.http_client(); + + let mut token = if require_auth { + Some(llm_token.acquire(&client).await?) + } else { + llm_token.acquire(&client).await.ok() + }; + let mut did_retry = false; + + loop { + let request_builder = http_client::Request::builder().method(Method::POST); + + let mut request_builder = request_builder + .header("Content-Type", "application/json") + .header(ZED_VERSION_HEADER_NAME, app_version.to_string()); + + // Only add Authorization header if we have a token + if let Some(ref token_value) = token { + request_builder = + request_builder.header("Authorization", format!("Bearer {}", token_value)); + } + + let request = build(request_builder)?; + + let mut response = http_client.send(request).await?; + + if let Some(minimum_required_version) = response + .headers() + .get(MINIMUM_REQUIRED_VERSION_HEADER_NAME) + .and_then(|version| Version::from_str(version.to_str().ok()?).ok()) + { + anyhow::ensure!( + app_version >= minimum_required_version, + ZedUpdateRequiredError { + minimum_version: minimum_required_version + } + ); + } + + if response.status().is_success() { + let usage = EditPredictionUsage::from_headers(response.headers()).ok(); + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + return Ok((serde_json::from_slice(&body)?, usage)); + } else if !did_retry + && token.is_some() + && response + .headers() + .get(EXPIRED_LLM_TOKEN_HEADER_NAME) + .is_some() + { + did_retry = true; + token = Some(llm_token.refresh(&client).await?); } else { - break; + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + anyhow::bail!( + "Request failed with status: {:?}\nBody: {}", + response.status(), + body + ); } } + } + + pub fn refresh_context( + &mut self, + project: &Entity, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut Context, + ) { + if self.use_context { + self.get_or_init_project(project, cx) + .context + .update(cx, |store, cx| { + store.refresh(buffer.clone(), cursor_position, cx); + }); + } + } - if let Some((model_old_range, model_new_text)) = model_edits.peek() { - let model_old_offset_range = model_old_range.to_offset(old_snapshot); - if user_edit.old == model_old_offset_range { - let user_new_text = new_snapshot - .text_for_range(user_edit.new.clone()) - .collect::(); + #[cfg(feature = "cli-support")] + pub fn set_context_for_buffer( + &mut self, + project: &Entity, + related_files: Vec, + cx: &mut Context, + ) { + self.get_or_init_project(project, cx) + .context + .update(cx, |store, _| { + store.set_related_files(related_files); + }); + } - if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { - if !model_suffix.is_empty() { - let anchor = old_snapshot.anchor_after(user_edit.old.end); - edits.push((anchor..anchor, model_suffix.into())); - } + fn is_file_open_source( + &self, + project: &Entity, + file: &Arc, + cx: &App, + ) -> bool { + if !file.is_local() || file.is_private() { + return false; + } + let Some(project_state) = self.projects.get(&project.entity_id()) else { + return false; + }; + project_state + .license_detection_watchers + .get(&file.worktree_id(cx)) + .as_ref() + .is_some_and(|watcher| watcher.is_project_open_source()) + } - model_edits.next(); - continue; + fn can_collect_file(&self, project: &Entity, file: &Arc, cx: &App) -> bool { + self.data_collection_choice.is_enabled() && self.is_file_open_source(project, file, cx) + } + + fn can_collect_events(&self, events: &[Arc]) -> bool { + if !self.data_collection_choice.is_enabled() { + return false; + } + events.iter().all(|event| { + matches!( + event.as_ref(), + zeta_prompt::Event::BufferChange { + in_open_source_repo: true, + .. } + ) + }) + } + + fn load_data_collection_choice() -> DataCollectionChoice { + let choice = KEY_VALUE_STORE + .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) + .log_err() + .flatten(); + + match choice.as_deref() { + Some("true") => DataCollectionChoice::Enabled, + Some("false") => DataCollectionChoice::Disabled, + Some(_) => { + log::error!("unknown value in '{ZED_PREDICT_DATA_COLLECTION_CHOICE}'"); + DataCollectionChoice::NotAnswered } + None => DataCollectionChoice::NotAnswered, } + } + + fn toggle_data_collection_choice(&mut self, cx: &mut Context) { + self.data_collection_choice = self.data_collection_choice.toggle(); + let new_choice = self.data_collection_choice; + db::write_and_log(cx, move || { + KEY_VALUE_STORE.write_kvp( + ZED_PREDICT_DATA_COLLECTION_CHOICE.into(), + new_choice.is_enabled().to_string(), + ) + }); + } + + pub fn shown_predictions(&self) -> impl DoubleEndedIterator { + self.shown_predictions.iter() + } + + pub fn shown_completions_len(&self) -> usize { + self.shown_predictions.len() + } + + pub fn is_prediction_rated(&self, id: &EditPredictionId) -> bool { + self.rated_predictions.contains(id) + } + + pub fn rate_prediction( + &mut self, + prediction: &EditPrediction, + rating: EditPredictionRating, + feedback: String, + cx: &mut Context, + ) { + self.rated_predictions.insert(prediction.id.clone()); + telemetry::event!( + "Edit Prediction Rated", + rating, + inputs = prediction.inputs, + output = prediction.edit_preview.as_unified_diff(&prediction.edits), + feedback + ); + self.client.telemetry().flush_events().detach(); + cx.notify(); + } - return None; + fn configure_context_retrieval(&mut self, cx: &mut Context<'_, EditPredictionStore>) { + self.use_context = cx.has_flag::() + && all_language_settings(None, cx).edit_predictions.use_context; } +} + +#[derive(Error, Debug)] +#[error( + "You must update to Zed version {minimum_version} or higher to continue using edit predictions." +)] +pub struct ZedUpdateRequiredError { + minimum_version: Version, +} + +#[cfg(feature = "cli-support")] +pub type EvalCacheKey = (EvalCacheEntryKind, u64); + +#[cfg(feature = "cli-support")] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum EvalCacheEntryKind { + Context, + Search, + Prediction, +} + +#[cfg(feature = "cli-support")] +impl std::fmt::Display for EvalCacheEntryKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EvalCacheEntryKind::Search => write!(f, "search"), + EvalCacheEntryKind::Context => write!(f, "context"), + EvalCacheEntryKind::Prediction => write!(f, "prediction"), + } + } +} + +#[cfg(feature = "cli-support")] +pub trait EvalCache: Send + Sync { + fn read(&self, key: EvalCacheKey) -> Option; + fn write(&self, key: EvalCacheKey, input: &str, value: &str); +} + +#[derive(Debug, Clone, Copy)] +pub enum DataCollectionChoice { + NotAnswered, + Enabled, + Disabled, +} + +impl DataCollectionChoice { + pub fn is_enabled(self) -> bool { + match self { + Self::Enabled => true, + Self::NotAnswered | Self::Disabled => false, + } + } + + pub fn is_answered(self) -> bool { + match self { + Self::Enabled | Self::Disabled => true, + Self::NotAnswered => false, + } + } + + #[must_use] + pub fn toggle(&self) -> DataCollectionChoice { + match self { + Self::Enabled => Self::Disabled, + Self::Disabled => Self::Enabled, + Self::NotAnswered => Self::Enabled, + } + } +} + +impl From for DataCollectionChoice { + fn from(value: bool) -> Self { + match value { + true => DataCollectionChoice::Enabled, + false => DataCollectionChoice::Disabled, + } + } +} + +struct ZedPredictUpsell; + +impl Dismissable for ZedPredictUpsell { + const KEY: &'static str = "dismissed-edit-predict-upsell"; + + fn dismissed() -> bool { + // To make this backwards compatible with older versions of Zed, we + // check if the user has seen the previous Edit Prediction Onboarding + // before, by checking the data collection choice which was written to + // the database once the user clicked on "Accept and Enable" + if KEY_VALUE_STORE + .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) + .log_err() + .is_some_and(|s| s.is_some()) + { + return true; + } + + KEY_VALUE_STORE + .read_kvp(Self::KEY) + .log_err() + .is_some_and(|s| s.is_some()) + } +} + +pub fn should_show_upsell_modal() -> bool { + !ZedPredictUpsell::dismissed() +} - edits.extend(model_edits.cloned()); +pub fn init(cx: &mut App) { + cx.observe_new(move |workspace: &mut Workspace, _, _cx| { + workspace.register_action( + move |workspace, _: &zed_actions::OpenZedPredictOnboarding, window, cx| { + ZedPredictModal::toggle( + workspace, + workspace.user_store().clone(), + workspace.client().clone(), + window, + cx, + ) + }, + ); - if edits.is_empty() { None } else { Some(edits) } + workspace.register_action(|workspace, _: &ResetOnboarding, _window, cx| { + update_settings_file(workspace.app_state().fs.clone(), cx, move |settings, _| { + settings + .project + .all_languages + .features + .get_or_insert_default() + .edit_prediction_provider = Some(EditPredictionProvider::None) + }); + }); + }) + .detach(); } diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..eee3f1f79e93b60ee3ea7c80bd987af22d613833 --- /dev/null +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -0,0 +1,2088 @@ +use super::*; +use crate::{udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS}; +use client::{UserStore, test::FakeServer}; +use clock::{FakeSystemClock, ReplicaId}; +use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; +use cloud_llm_client::{ + EditPredictionRejectReason, EditPredictionRejection, PredictEditsBody, PredictEditsResponse, + RejectEditPredictionsBody, +}; +use futures::{ + AsyncReadExt, StreamExt, + channel::{mpsc, oneshot}, +}; +use gpui::{ + Entity, TestAppContext, + http_client::{FakeHttpClient, Response}, +}; +use indoc::indoc; +use language::{Point, ToOffset as _}; +use lsp::LanguageServerId; +use open_ai::Usage; +use parking_lot::Mutex; +use pretty_assertions::{assert_eq, assert_matches}; +use project::{FakeFs, Project}; +use serde_json::json; +use settings::SettingsStore; +use std::{path::Path, sync::Arc, time::Duration}; +use util::{path, rel_path::rel_path}; +use uuid::Uuid; +use zeta_prompt::ZetaPromptInput; + +use crate::{BufferEditPrediction, EditPredictionId, EditPredictionStore, REJECT_REQUEST_DEBOUNCE}; + +#[gpui::test] +async fn test_current_state(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "1.txt": "Hello!\nHow\nBye\n", + "2.txt": "Hola!\nComo\nAdios\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer1 = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("/root/1.txt"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot1.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_project(&project, cx); + ep_store.register_buffer(&buffer1, &project, cx); + }); + + // Prediction for current file + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx) + }); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + respond_tx + .send(model_response( + request, + indoc! {r" + --- a/root/1.txt + +++ b/root/1.txt + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) + .unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer1, None, &project, cx) + .unwrap(); + assert_matches!(prediction, BufferEditPrediction::Local { .. }); + }); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project); + }); + + // Prediction for diagnostic in another file + + let diagnostic = lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "Sentence is incomplete".to_string(), + ..Default::default() + }; + + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), + diagnostics: vec![diagnostic], + version: None, + }, + None, + language::DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx + .send(model_response( + request, + indoc! {r#" + --- a/root/2.txt + +++ b/root/2.txt + @@ ... @@ + Hola! + -Como + +Como estas? + Adios + "#}, + )) + .unwrap(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer1, None, &project, cx) + .unwrap(); + assert_matches!( + prediction, + BufferEditPrediction::Jump { prediction } if prediction.snapshot.file().unwrap().full_path(cx) == Path::new(path!("root/2.txt")) + ); + }); + + let buffer2 = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/2.txt"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer2, None, &project, cx) + .unwrap(); + assert_matches!(prediction, BufferEditPrediction::Local { .. }); + }); +} + +#[gpui::test] +async fn test_simple_request(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + // TODO Put back when we have a structured request again + // assert_eq!( + // request.excerpt_path.as_ref(), + // Path::new(path!("root/foo.md")) + // ); + // assert_eq!( + // request.cursor_point, + // Point { + // line: Line(1), + // column: 3 + // } + // ); + + respond_tx + .send(model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) + .unwrap(); + + let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); + + assert_eq!(prediction.edits.len(), 1); + assert_eq!( + prediction.edits[0].0.to_point(&snapshot).start, + language::Point::new(1, 3) + ); + assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); +} + +#[gpui::test] +async fn test_request_events(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\n\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(7..7, "How")], None, cx); + }); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + let prompt = prompt_from_request(&request); + assert!( + prompt.contains(indoc! {" + --- a/root/foo.md + +++ b/root/foo.md + @@ -1,3 +1,3 @@ + Hello! + - + +How + Bye + "}), + "{prompt}" + ); + + respond_tx + .send(model_response( + request, + indoc! {r#" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "#}, + )) + .unwrap(); + + let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); + + assert_eq!(prediction.edits.len(), 1); + assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); +} + +#[gpui::test] +async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\n\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + // First burst: insert "How" + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(7..7, "How")], None, cx); + }); + + // Simulate a pause longer than the grouping threshold (e.g. 500ms). + cx.executor().advance_clock(LAST_CHANGE_GROUPING_TIME * 2); + cx.run_until_parked(); + + // Second burst: append " are you?" immediately after "How" on the same line. + // + // Keeping both bursts on the same line ensures the existing line-span coalescing logic + // groups them into a single `LastEvent`, allowing the pause-split getter to return two diffs. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(10..10, " are you?")], None, cx); + }); + + // A second edit shortly after the first post-pause edit ensures the last edit timestamp is + // advanced after the pause boundary is recorded, making pause-splitting deterministic. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(19..19, "!")], None, cx); + }); + + // Without time-based splitting, there is one event. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!(events.len(), 1); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How are you?! + Bye + "} + ); + + // With time-based splitting, there are two distinct events. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + assert_eq!(events.len(), 2); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How + Bye + "} + ); + + let zeta_prompt::Event::BufferChange { diff, .. } = events[1].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + -How + +How are you?! + Bye + "} + ); +} + +#[gpui::test] +async fn test_empty_prediction(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let response = model_response(request, ""); + let id = response.id.clone(); + respond_tx.send(response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .is_none() + ); + }); + + // prediction is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: id, + reason: EditPredictionRejectReason::Empty, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_interpolated_empty(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + buffer.update(cx, |buffer, cx| { + buffer.set_text("Hello!\nHow are you?\nBye", cx); + }); + + let response = model_response(request, SIMPLE_DIFF); + let id = response.id.clone(); + respond_tx.send(response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .is_none() + ); + }); + + // prediction is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: id, + reason: EditPredictionRejectReason::InterpolatedEmpty, + was_shown: false + }] + ); +} + +const SIMPLE_DIFF: &str = indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye +"}; + +#[gpui::test] +async fn test_replace_current(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_tx.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // a second request is triggered + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let second_response = model_response(request, SIMPLE_DIFF); + let second_id = second_response.id.clone(); + respond_tx.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // second replaces first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + // first is reported as replaced + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Replaced, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_current_preferred(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_tx.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // a second request is triggered + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + // worse than current prediction + let second_response = model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are + Bye + "}, + ); + let second_id = second_response.id.clone(); + respond_tx.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // first is preferred over second + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // second is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: second_id, + reason: EditPredictionRejectReason::CurrentPreferred, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + // start two refresh tasks + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request1, respond_first) = requests.predict.next().await.unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_second) = requests.predict.next().await.unwrap(); + + // wait for throttle + cx.run_until_parked(); + + // second responds first + let second_response = model_response(request, SIMPLE_DIFF); + let second_id = second_response.id.clone(); + respond_second.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is second + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + let first_response = model_response(request1, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_first.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is still second, since first was cancelled + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + // first is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + cx.run_until_parked(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Canceled, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + // start two refresh tasks + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request1, respond_first) = requests.predict.next().await.unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request2, respond_second) = requests.predict.next().await.unwrap(); + + // wait for throttle, so requests are sent + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // start a third request + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + + // 2 are pending, so 2nd is cancelled + assert_eq!( + ep_store + .get_or_init_project(&project, cx) + .cancelled_predictions + .iter() + .copied() + .collect::>(), + [1] + ); + }); + + // wait for throttle + cx.run_until_parked(); + + let (request3, respond_third) = requests.predict.next().await.unwrap(); + + let first_response = model_response(request1, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_first.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + let cancelled_response = model_response(request2, SIMPLE_DIFF); + let cancelled_id = cancelled_response.id.clone(); + respond_second.send(cancelled_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is still first, since second was cancelled + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + let third_response = model_response(request3, SIMPLE_DIFF); + let third_response_id = third_response.id.clone(); + respond_third.send(third_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // third completes and replaces first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + third_response_id + ); + }); + + // second is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + cx.run_until_parked(); + + assert_eq!( + &reject_request.rejections, + &[ + EditPredictionRejection { + request_id: cancelled_id, + reason: EditPredictionRejectReason::Canceled, + was_shown: false + }, + EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Replaced, + was_shown: false + } + ] + ); +} + +#[gpui::test] +async fn test_rejections_flushing(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("test-1".into()), + EditPredictionRejectReason::Discarded, + false, + ); + ep_store.reject_prediction( + EditPredictionId("test-2".into()), + EditPredictionRejectReason::Canceled, + true, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + // batched + assert_eq!(reject_request.rejections.len(), 2); + assert_eq!( + reject_request.rejections[0], + EditPredictionRejection { + request_id: "test-1".to_string(), + reason: EditPredictionRejectReason::Discarded, + was_shown: false + } + ); + assert_eq!( + reject_request.rejections[1], + EditPredictionRejection { + request_id: "test-2".to_string(), + reason: EditPredictionRejectReason::Canceled, + was_shown: true + } + ); + + // Reaching batch size limit sends without debounce + ep_store.update(cx, |ep_store, _cx| { + for i in 0..70 { + ep_store.reject_prediction( + EditPredictionId(format!("batch-{}", i).into()), + EditPredictionRejectReason::Discarded, + false, + ); + } + }); + + // First MAX/2 items are sent immediately + cx.run_until_parked(); + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 50); + assert_eq!(reject_request.rejections[0].request_id, "batch-0"); + assert_eq!(reject_request.rejections[49].request_id, "batch-49"); + + // Remaining items are debounced with the next batch + cx.executor().advance_clock(Duration::from_secs(15)); + cx.run_until_parked(); + + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 20); + assert_eq!(reject_request.rejections[0].request_id, "batch-50"); + assert_eq!(reject_request.rejections[19].request_id, "batch-69"); + + // Request failure + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("retry-1".into()), + EditPredictionRejectReason::Discarded, + false, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + let (reject_request, _respond_tx) = requests.reject.next().await.unwrap(); + assert_eq!(reject_request.rejections.len(), 1); + assert_eq!(reject_request.rejections[0].request_id, "retry-1"); + // Simulate failure + drop(_respond_tx); + + // Add another rejection + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("retry-2".into()), + EditPredictionRejectReason::Discarded, + false, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + // Retry should include both the failed item and the new one + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 2); + assert_eq!(reject_request.rejections[0].request_id, "retry-1"); + assert_eq!(reject_request.rejections[1].request_id, "retry-2"); +} + +// Skipped until we start including diagnostics in prompt +// #[gpui::test] +// async fn test_request_diagnostics(cx: &mut TestAppContext) { +// let (ep_store, mut req_rx) = init_test_with_fake_client(cx); +// let fs = FakeFs::new(cx.executor()); +// fs.insert_tree( +// "/root", +// json!({ +// "foo.md": "Hello!\nBye" +// }), +// ) +// .await; +// let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + +// let path_to_buffer_uri = lsp::Uri::from_file_path(path!("/root/foo.md")).unwrap(); +// let diagnostic = lsp::Diagnostic { +// range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), +// severity: Some(lsp::DiagnosticSeverity::ERROR), +// message: "\"Hello\" deprecated. Use \"Hi\" instead".to_string(), +// ..Default::default() +// }; + +// project.update(cx, |project, cx| { +// project.lsp_store().update(cx, |lsp_store, cx| { +// // Create some diagnostics +// lsp_store +// .update_diagnostics( +// LanguageServerId(0), +// lsp::PublishDiagnosticsParams { +// uri: path_to_buffer_uri.clone(), +// diagnostics: vec![diagnostic], +// version: None, +// }, +// None, +// language::DiagnosticSourceKind::Pushed, +// &[], +// cx, +// ) +// .unwrap(); +// }); +// }); + +// let buffer = project +// .update(cx, |project, cx| { +// let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); +// project.open_buffer(path, cx) +// }) +// .await +// .unwrap(); + +// let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); +// let position = snapshot.anchor_before(language::Point::new(0, 0)); + +// let _prediction_task = ep_store.update(cx, |ep_store, cx| { +// ep_store.request_prediction(&project, &buffer, position, cx) +// }); + +// let (request, _respond_tx) = req_rx.next().await.unwrap(); + +// assert_eq!(request.diagnostic_groups.len(), 1); +// let value = serde_json::from_str::(request.diagnostic_groups[0].0.get()) +// .unwrap(); +// // We probably don't need all of this. TODO define a specific diagnostic type in predict_edits_v3 +// assert_eq!( +// value, +// json!({ +// "entries": [{ +// "range": { +// "start": 8, +// "end": 10 +// }, +// "diagnostic": { +// "source": null, +// "code": null, +// "code_description": null, +// "severity": 1, +// "message": "\"Hello\" deprecated. Use \"Hi\" instead", +// "markdown": null, +// "group_id": 0, +// "is_primary": true, +// "is_disk_based": false, +// "is_unnecessary": false, +// "source_kind": "Pushed", +// "data": null, +// "underline": true +// } +// }], +// "primary_ix": 0 +// }) +// ); +// } + +// Generate a model response that would apply the given diff to the active file. +fn model_response(request: open_ai::Request, diff_to_apply: &str) -> open_ai::Response { + let prompt = match &request.messages[0] { + open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(content), + } => content, + _ => panic!("unexpected request {request:?}"), + }; + + let open = "\n"; + let close = ""; + let cursor = "<|user_cursor|>"; + + let start_ix = open.len() + prompt.find(open).unwrap(); + let end_ix = start_ix + &prompt[start_ix..].find(close).unwrap(); + let excerpt = prompt[start_ix..end_ix].replace(cursor, ""); + let new_excerpt = apply_diff_to_string(diff_to_apply, &excerpt).unwrap(); + + open_ai::Response { + id: Uuid::new_v4().to_string(), + object: "response".into(), + created: 0, + model: "model".into(), + choices: vec![open_ai::Choice { + index: 0, + message: open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain(new_excerpt)), + tool_calls: vec![], + }, + finish_reason: None, + }], + usage: Usage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + } +} + +fn prompt_from_request(request: &open_ai::Request) -> &str { + assert_eq!(request.messages.len(), 1); + let open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(content), + .. + } = &request.messages[0] + else { + panic!( + "Request does not have single user message of type Plain. {:#?}", + request + ); + }; + content +} + +struct RequestChannels { + predict: mpsc::UnboundedReceiver<(open_ai::Request, oneshot::Sender)>, + reject: mpsc::UnboundedReceiver<(RejectEditPredictionsBody, oneshot::Sender<()>)>, +} + +fn init_test_with_fake_client( + cx: &mut TestAppContext, +) -> (Entity, RequestChannels) { + cx.update(move |cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + zlog::init_test(); + + let (predict_req_tx, predict_req_rx) = mpsc::unbounded(); + let (reject_req_tx, reject_req_rx) = mpsc::unbounded(); + + let http_client = FakeHttpClient::create({ + move |req| { + let uri = req.uri().path().to_string(); + let mut body = req.into_body(); + let predict_req_tx = predict_req_tx.clone(); + let reject_req_tx = reject_req_tx.clone(); + async move { + let resp = match uri.as_str() { + "/client/llm_tokens" => serde_json::to_string(&json!({ + "token": "test" + })) + .unwrap(), + "/predict_edits/raw" => { + let mut buf = Vec::new(); + body.read_to_end(&mut buf).await.ok(); + let req = serde_json::from_slice(&buf).unwrap(); + + let (res_tx, res_rx) = oneshot::channel(); + predict_req_tx.unbounded_send((req, res_tx)).unwrap(); + serde_json::to_string(&res_rx.await?).unwrap() + } + "/predict_edits/reject" => { + let mut buf = Vec::new(); + body.read_to_end(&mut buf).await.ok(); + let req = serde_json::from_slice(&buf).unwrap(); + + let (res_tx, res_rx) = oneshot::channel(); + reject_req_tx.unbounded_send((req, res_tx)).unwrap(); + serde_json::to_string(&res_rx.await?).unwrap() + } + _ => { + panic!("Unexpected path: {}", uri) + } + }; + + Ok(Response::builder().body(resp.into()).unwrap()) + } + } + }); + + let client = client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); + client.cloud_client().set_credentials(1, "test".into()); + + language_model::init(client.clone(), cx); + + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let ep_store = EditPredictionStore::global(&client, &user_store, cx); + + ( + ep_store, + RequestChannels { + predict: predict_req_rx, + reject: reject_req_rx, + }, + ) + }) +} + +const BSD_0_TXT: &str = include_str!("../license_examples/0bsd.txt"); + +#[gpui::test] +async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); + let edits: Arc<[(Range, Arc)]> = cx.update(|cx| { + to_completion_edits([(2..5, "REM".into()), (9..11, "".into())], &buffer, cx).into() + }); + + let edit_preview = cx + .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) + .await; + + let prediction = EditPrediction { + edits, + edit_preview, + buffer: buffer.clone(), + snapshot: cx.read(|cx| buffer.read(cx).snapshot()), + id: EditPredictionId("the-id".into()), + inputs: ZetaPromptInput { + events: Default::default(), + related_files: Default::default(), + cursor_path: Path::new("").into(), + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, + cursor_offset_in_excerpt: 0, + }, + buffer_snapshotted_at: Instant::now(), + response_received_at: Instant::now(), + }; + + cx.update(|cx| { + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".into()), (9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..2, "REM".into()), (6..8, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".into()), (9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(3..3, "EM".into()), (7..9, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into()), (8..10, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into()), (8..10, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); + assert_eq!(prediction.interpolate(&buffer.read(cx).snapshot()), None); + }) +} + +#[gpui::test] +async fn test_clean_up_diff(cx: &mut TestAppContext) { + init_test(cx); + + assert_eq!( + apply_edit_prediction( + indoc! {" + fn main() { + let word_1 = \"lorem\"; + let range = word.len()..word.len(); + } + "}, + indoc! {" + <|editable_region_start|> + fn main() { + let word_1 = \"lorem\"; + let range = word_1.len()..word_1.len(); + } + + <|editable_region_end|> + "}, + cx, + ) + .await, + indoc! {" + fn main() { + let word_1 = \"lorem\"; + let range = word_1.len()..word_1.len(); + } + "}, + ); + + assert_eq!( + apply_edit_prediction( + indoc! {" + fn main() { + let story = \"the quick\" + } + "}, + indoc! {" + <|editable_region_start|> + fn main() { + let story = \"the quick brown fox jumps over the lazy dog\"; + } + + <|editable_region_end|> + "}, + cx, + ) + .await, + indoc! {" + fn main() { + let story = \"the quick brown fox jumps over the lazy dog\"; + } + "}, + ); +} + +#[gpui::test] +async fn test_edit_prediction_end_of_buffer(cx: &mut TestAppContext) { + init_test(cx); + + let buffer_content = "lorem\n"; + let completion_response = indoc! {" + ```animals.js + <|start_of_file|> + <|editable_region_start|> + lorem + ipsum + <|editable_region_end|> + ```"}; + + assert_eq!( + apply_edit_prediction(buffer_content, completion_response, cx).await, + "lorem\nipsum" + ); +} + +#[gpui::test] +async fn test_can_collect_data(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({ "LICENSE": BSD_0_TXT })) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/src/main.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Disabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_remote_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + + let buffer = cx.new(|_cx| { + Buffer::remote( + language::BufferId::new(1).unwrap(), + ReplicaId::new(1), + language::Capability::ReadWrite, + "fn main() {\n println!(\"Hello\");\n}", + ) + }); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_private_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "LICENSE": BSD_0_TXT, + ".env": "SECRET_KEY=secret" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/project/.env", cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_untitled_buffer(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + let buffer = cx.new(|cx| Buffer::local("", cx)); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_when_closed_source(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({ "main.rs": "fn main() {}" })) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/project/main.rs", cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_data_collection_status_changes_on_move(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/open_source_worktree"), + json!({ "LICENSE": BSD_0_TXT, "main.rs": "" }), + ) + .await; + fs.insert_tree(path!("/closed_source_worktree"), json!({ "main.rs": "" })) + .await; + + let project = Project::test( + fs.clone(), + [ + path!("/open_source_worktree").as_ref(), + path!("/closed_source_worktree").as_ref(), + ], + cx, + ) + .await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/open_source_worktree/main.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + let closed_source_file = project + .update(cx, |project, cx| { + let worktree2 = project + .worktree_for_root_name("closed_source_worktree", cx) + .unwrap(); + worktree2.update(cx, |worktree2, cx| { + worktree2.load_file(rel_path("main.rs"), cx) + }) + }) + .await + .unwrap() + .file; + + buffer.update(cx, |buffer, cx| { + buffer.file_updated(closed_source_file, cx); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_events_in_uncollectable_buffers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/worktree1"), + json!({ "LICENSE": BSD_0_TXT, "main.rs": "", "other.rs": "" }), + ) + .await; + fs.insert_tree(path!("/worktree2"), json!({ "private.rs": "" })) + .await; + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/worktree1/main.rs"), cx) + }) + .await + .unwrap(); + let private_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/worktree2/file.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + // this has a side effect of registering the buffer to watch for edits + run_edit_prediction(&private_buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); + + private_buffer.update(cx, |private_buffer, cx| { + private_buffer.edit([(0..0, "An edit for the history!")], None, cx); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); + + // make an edit that uses too many bytes, causing private_buffer edit to not be able to be + // included + buffer.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + " ".repeat(MAX_EVENT_TOKENS * cursor_excerpt::BYTES_PER_TOKEN_GUESS), + )], + None, + cx, + ); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); +} + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); +} + +async fn apply_edit_prediction( + buffer_content: &str, + completion_response: &str, + cx: &mut TestAppContext, +) -> String { + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); + let (ep_store, _, response) = make_test_ep_store(&project, cx).await; + *response.lock() = completion_response.to_string(); + let edit_prediction = run_edit_prediction(&buffer, &project, &ep_store, cx).await; + buffer.update(cx, |buffer, cx| { + buffer.edit(edit_prediction.edits.iter().cloned(), None, cx) + }); + buffer.read_with(cx, |buffer, _| buffer.text()) +} + +async fn run_edit_prediction( + buffer: &Entity, + project: &Entity, + ep_store: &Entity, + cx: &mut TestAppContext, +) -> EditPrediction { + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, buffer, cursor, Default::default(), cx) + }); + prediction_task.await.unwrap().unwrap().prediction.unwrap() +} + +async fn make_test_ep_store( + project: &Entity, + cx: &mut TestAppContext, +) -> ( + Entity, + Arc>>, + Arc>, +) { + let default_response = indoc! {" + ```main.rs + <|start_of_file|> + <|editable_region_start|> + hello world + <|editable_region_end|> + ```" + }; + let captured_request: Arc>> = Arc::new(Mutex::new(None)); + let completion_response: Arc> = + Arc::new(Mutex::new(default_response.to_string())); + let http_client = FakeHttpClient::create({ + let captured_request = captured_request.clone(); + let completion_response = completion_response.clone(); + let mut next_request_id = 0; + move |req| { + let captured_request = captured_request.clone(); + let completion_response = completion_response.clone(); + async move { + match (req.method(), req.uri().path()) { + (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&CreateLlmTokenResponse { + token: LlmToken("the-llm-token".to_string()), + }) + .unwrap() + .into(), + ) + .unwrap()), + (&Method::POST, "/predict_edits/v2") => { + let mut request_body = String::new(); + req.into_body().read_to_string(&mut request_body).await?; + *captured_request.lock() = + Some(serde_json::from_str(&request_body).unwrap()); + next_request_id += 1; + Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&PredictEditsResponse { + request_id: format!("request-{next_request_id}"), + output_excerpt: completion_response.lock().clone(), + }) + .unwrap() + .into(), + ) + .unwrap()) + } + _ => Ok(http_client::Response::builder() + .status(404) + .body("Not Found".into()) + .unwrap()), + } + } + } + }); + + let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + RefreshLlmTokenListener::register(client.clone(), cx); + }); + let _server = FakeServer::for_client(42, &client, cx).await; + + let ep_store = cx.new(|cx| { + let mut ep_store = EditPredictionStore::new(client, project.read(cx).user_store(), cx); + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + + let worktrees = project.read(cx).worktrees(cx).collect::>(); + for worktree in worktrees { + let worktree_id = worktree.read(cx).id(); + ep_store + .get_or_init_project(project, cx) + .license_detection_watchers + .entry(worktree_id) + .or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(&worktree, cx))); + } + + ep_store + }); + + (ep_store, captured_request, completion_response) +} + +fn to_completion_edits( + iterator: impl IntoIterator, Arc)>, + buffer: &Entity, + cx: &App, +) -> Vec<(Range, Arc)> { + let buffer = buffer.read(cx); + iterator + .into_iter() + .map(|(range, text)| { + ( + buffer.anchor_after(range.start)..buffer.anchor_before(range.end), + text, + ) + }) + .collect() +} + +fn from_completion_edits( + editor_edits: &[(Range, Arc)], + buffer: &Entity, + cx: &App, +) -> Vec<(Range, Arc)> { + let buffer = buffer.read(cx); + editor_edits + .iter() + .map(|(range, text)| { + ( + range.start.to_offset(buffer)..range.end.to_offset(buffer), + text.clone(), + ) + }) + .collect() +} + +#[gpui::test] +async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let http_client = FakeHttpClient::create(|_req| async move { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let result = completion_task.await; + assert!( + result.is_err(), + "Without authentication and without custom URL, prediction should fail" + ); +} + +#[gpui::test] +async fn test_unauthenticated_with_custom_url_allows_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let predict_called = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let predict_called_clone = predict_called.clone(); + + let http_client = FakeHttpClient::create({ + move |req| { + let uri = req.uri().path().to_string(); + let predict_called = predict_called_clone.clone(); + async move { + if uri.contains("predict") { + predict_called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(gpui::http_client::Response::builder() + .body( + serde_json::to_string(&open_ai::Response { + id: "test-123".to_string(), + object: "chat.completion".to_string(), + created: 0, + model: "test".to_string(), + usage: open_ai::Usage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + choices: vec![open_ai::Choice { + index: 0, + message: open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain( + indoc! {" + ```main.rs + <|start_of_file|> + <|editable_region_start|> + fn main() { + println!(\"Hello, world!\"); + } + <|editable_region_end|> + ``` + "} + .to_string(), + )), + tool_calls: vec![], + }, + finish_reason: Some("stop".to_string()), + }], + }) + .unwrap() + .into(), + ) + .unwrap()) + } else { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + } + } + } + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_custom_predict_edits_url(Url::parse("http://test/predict").unwrap()); + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let _ = completion_task.await; + + assert!( + predict_called.load(std::sync::atomic::Ordering::SeqCst), + "With custom URL, predict endpoint should be called even without authentication" + ); +} + +#[ctor::ctor] +fn init_logger() { + zlog::init_test(); +} diff --git a/crates/edit_prediction/src/example_spec.rs b/crates/edit_prediction/src/example_spec.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf221b576b890f1200c4ee3c095f73edaea71462 --- /dev/null +++ b/crates/edit_prediction/src/example_spec.rs @@ -0,0 +1,212 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt::Write as _, mem, path::Path, sync::Arc}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleSpec { + #[serde(default)] + pub name: String, + pub repository_url: String, + pub revision: String, + #[serde(default)] + pub uncommitted_diff: String, + pub cursor_path: Arc, + pub cursor_position: String, + pub edit_history: String, + pub expected_patch: String, +} + +const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; +const EDIT_HISTORY_HEADING: &str = "Edit History"; +const CURSOR_POSITION_HEADING: &str = "Cursor Position"; +const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; +const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; +const REPOSITORY_URL_FIELD: &str = "repository_url"; +const REVISION_FIELD: &str = "revision"; + +impl ExampleSpec { + /// Format this example spec as markdown. + pub fn to_markdown(&self) -> String { + let mut markdown = String::new(); + + _ = writeln!(markdown, "# {}", self.name); + markdown.push('\n'); + + _ = writeln!(markdown, "repository_url = {}", self.repository_url); + _ = writeln!(markdown, "revision = {}", self.revision); + markdown.push('\n'); + + if !self.uncommitted_diff.is_empty() { + _ = writeln!(markdown, "## {}", UNCOMMITTED_DIFF_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.uncommitted_diff); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING); + _ = writeln!(markdown); + + if self.edit_history.is_empty() { + _ = writeln!(markdown, "(No edit history)"); + _ = writeln!(markdown); + } else { + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.edit_history); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", CURSOR_POSITION_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```{}", self.cursor_path.to_string_lossy()); + markdown.push_str(&self.cursor_position); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + _ = writeln!(markdown, "## {}", EXPECTED_PATCH_HEADING); + markdown.push('\n'); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.expected_patch); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + markdown + } + + /// Parse an example spec from markdown. + pub fn from_markdown(name: String, input: &str) -> anyhow::Result { + use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd}; + + let parser = Parser::new(input); + + let mut spec = ExampleSpec { + name, + repository_url: String::new(), + revision: String::new(), + uncommitted_diff: String::new(), + cursor_path: Path::new("").into(), + cursor_position: String::new(), + edit_history: String::new(), + expected_patch: String::new(), + }; + + let mut text = String::new(); + let mut block_info: CowStr = "".into(); + + #[derive(PartialEq)] + enum Section { + Start, + UncommittedDiff, + EditHistory, + CursorPosition, + ExpectedExcerpts, + ExpectedPatch, + Other, + } + + let mut current_section = Section::Start; + + for event in parser { + match event { + Event::Text(line) => { + text.push_str(&line); + + if let Section::Start = current_section + && let Some((field, value)) = line.split_once('=') + { + match field.trim() { + REPOSITORY_URL_FIELD => { + spec.repository_url = value.trim().to_string(); + } + REVISION_FIELD => { + spec.revision = value.trim().to_string(); + } + _ => {} + } + } + } + Event::End(TagEnd::Heading(HeadingLevel::H2)) => { + let title = mem::take(&mut text); + current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { + Section::UncommittedDiff + } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { + Section::EditHistory + } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { + Section::CursorPosition + } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { + Section::ExpectedPatch + } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { + Section::ExpectedExcerpts + } else { + Section::Other + }; + } + Event::End(TagEnd::Heading(HeadingLevel::H3)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(HeadingLevel::H4)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(level)) => { + anyhow::bail!("Unexpected heading level: {level}"); + } + Event::Start(Tag::CodeBlock(kind)) => { + match kind { + CodeBlockKind::Fenced(info) => { + block_info = info; + } + CodeBlockKind::Indented => { + anyhow::bail!("Unexpected indented codeblock"); + } + }; + } + Event::Start(_) => { + text.clear(); + block_info = "".into(); + } + Event::End(TagEnd::CodeBlock) => { + let block_info = block_info.trim(); + match current_section { + Section::UncommittedDiff => { + spec.uncommitted_diff = mem::take(&mut text); + } + Section::EditHistory => { + spec.edit_history.push_str(&mem::take(&mut text)); + } + Section::CursorPosition => { + spec.cursor_path = Path::new(block_info).into(); + spec.cursor_position = mem::take(&mut text); + } + Section::ExpectedExcerpts => { + mem::take(&mut text); + } + Section::ExpectedPatch => { + spec.expected_patch = mem::take(&mut text); + } + Section::Start | Section::Other => {} + } + } + _ => {} + } + } + + if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() { + anyhow::bail!("Missing cursor position codeblock"); + } + + Ok(spec) + } +} diff --git a/crates/zeta/src/license_detection.rs b/crates/edit_prediction/src/license_detection.rs similarity index 99% rename from crates/zeta/src/license_detection.rs rename to crates/edit_prediction/src/license_detection.rs index d4d4825615f19e5e5654f7bd78439d9eaa39e4c1..3ad34e7e6df6233cd4ff7462681d7b3588d36534 100644 --- a/crates/zeta/src/license_detection.rs +++ b/crates/edit_prediction/src/license_detection.rs @@ -735,6 +735,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -758,6 +759,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -816,6 +818,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs new file mode 100644 index 0000000000000000000000000000000000000000..8186fc5d8c609468be04c117eabac11c6c015efd --- /dev/null +++ b/crates/edit_prediction/src/mercury.rs @@ -0,0 +1,322 @@ +use crate::{ + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, open_ai_response::text_from_response, + prediction::EditPredictionResult, +}; +use anyhow::{Context as _, Result}; +use futures::AsyncReadExt as _; +use gpui::{ + App, AppContext as _, Entity, Global, SharedString, Task, + http_client::{self, AsyncBody, Method}, +}; +use language::{OffsetRangeExt as _, ToOffset, ToPoint as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; +use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; +use zeta_prompt::ZetaPromptInput; + +const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; + +pub struct Mercury { + pub api_token: Entity, +} + +impl Mercury { + pub fn new(cx: &mut App) -> Self { + Mercury { + api_token: mercury_api_token(cx), + } + } + + pub(crate) fn request_prediction( + &self, + EditPredictionModelInput { + buffer, + snapshot, + position, + events, + related_files, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut App, + ) -> Task>> { + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else { + return Task::ready(Ok(None)); + }; + let full_path: Arc = snapshot + .file() + .map(|file| file.full_path(cx)) + .unwrap_or_else(|| "untitled".into()) + .into(); + + let http_client = cx.http_client(); + let cursor_point = position.to_point(&snapshot); + let buffer_snapshotted_at = Instant::now(); + let active_buffer = buffer.clone(); + + let result = cx.background_spawn(async move { + let (editable_range, context_range) = + crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + &snapshot, + MAX_CONTEXT_TOKENS, + MAX_REWRITE_TOKENS, + ); + + let context_offset_range = context_range.to_offset(&snapshot); + + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = zeta_prompt::ZetaPromptInput { + events, + related_files, + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) + - context_range.start.to_offset(&snapshot), + cursor_path: full_path.clone(), + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_offset_range.start + - context_offset_range.start) + ..(editable_offset_range.end - context_offset_range.start), + }; + + let prompt = build_prompt(&inputs); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: active_buffer.downgrade(), + prompt: Some(prompt.clone()), + position, + }, + )) + .ok(); + } + + let request_body = open_ai::Request { + model: "mercury-coder".into(), + messages: vec![open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(prompt), + }], + stream: false, + max_completion_tokens: None, + stop: vec![], + temperature: None, + tool_choice: None, + parallel_tool_calls: None, + tools: vec![], + prompt_cache_key: None, + reasoning_effort: None, + }; + + let buf = serde_json::to_vec(&request_body)?; + let body: AsyncBody = buf.into(); + + let request = http_client::Request::builder() + .uri(MERCURY_API_URL) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_token)) + .header("Connection", "keep-alive") + .method(Method::POST) + .body(body) + .context("Failed to create request")?; + + let mut response = http_client + .send(request) + .await + .context("Failed to send request")?; + + let mut body: Vec = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("Failed to read response body")?; + + let response_received_at = Instant::now(); + if !response.status().is_success() { + anyhow::bail!( + "Request failed with status: {:?}\nBody: {}", + response.status(), + String::from_utf8_lossy(&body), + ); + }; + + let mut response: open_ai::Response = + serde_json::from_slice(&body).context("Failed to parse response")?; + + let id = mem::take(&mut response.id); + let response_str = text_from_response(response).unwrap_or_default(); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: active_buffer.downgrade(), + model_output: Some(response_str.clone()), + position, + }, + )) + .ok(); + } + + let response_str = response_str.strip_prefix("```\n").unwrap_or(&response_str); + let response_str = response_str.strip_suffix("\n```").unwrap_or(&response_str); + + let mut edits = Vec::new(); + const NO_PREDICTION_OUTPUT: &str = "None"; + + if response_str != NO_PREDICTION_OUTPUT { + let old_text = snapshot + .text_for_range(editable_offset_range.clone()) + .collect::(); + edits.extend( + language::text_diff(&old_text, &response_str) + .into_iter() + .map(|(range, text)| { + ( + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot + .anchor_before(editable_offset_range.start + range.end), + text, + ) + }), + ); + } + + anyhow::Ok((id, edits, snapshot, response_received_at, inputs)) + }); + + cx.spawn(async move |cx| { + let (id, edits, old_snapshot, response_received_at, inputs) = + result.await.context("Mercury edit prediction failed")?; + anyhow::Ok(Some( + EditPredictionResult::new( + EditPredictionId(id.into()), + &buffer, + &old_snapshot, + edits.into(), + buffer_snapshotted_at, + response_received_at, + inputs, + cx, + ) + .await, + )) + }) + } +} + +fn build_prompt(inputs: &ZetaPromptInput) -> String { + const RECENTLY_VIEWED_SNIPPETS_START: &str = "<|recently_viewed_code_snippets|>\n"; + const RECENTLY_VIEWED_SNIPPETS_END: &str = "<|/recently_viewed_code_snippets|>\n"; + const RECENTLY_VIEWED_SNIPPET_START: &str = "<|recently_viewed_code_snippet|>\n"; + const RECENTLY_VIEWED_SNIPPET_END: &str = "<|/recently_viewed_code_snippet|>\n"; + const CURRENT_FILE_CONTENT_START: &str = "<|current_file_content|>\n"; + const CURRENT_FILE_CONTENT_END: &str = "<|/current_file_content|>\n"; + const CODE_TO_EDIT_START: &str = "<|code_to_edit|>\n"; + const CODE_TO_EDIT_END: &str = "<|/code_to_edit|>\n"; + const EDIT_DIFF_HISTORY_START: &str = "<|edit_diff_history|>\n"; + const EDIT_DIFF_HISTORY_END: &str = "<|/edit_diff_history|>\n"; + const CURSOR_TAG: &str = "<|cursor|>"; + const CODE_SNIPPET_FILE_PATH_PREFIX: &str = "code_snippet_file_path: "; + const CURRENT_FILE_PATH_PREFIX: &str = "current_file_path: "; + + let mut prompt = String::new(); + + push_delimited( + &mut prompt, + RECENTLY_VIEWED_SNIPPETS_START..RECENTLY_VIEWED_SNIPPETS_END, + |prompt| { + for related_file in inputs.related_files.iter() { + for related_excerpt in &related_file.excerpts { + push_delimited( + prompt, + RECENTLY_VIEWED_SNIPPET_START..RECENTLY_VIEWED_SNIPPET_END, + |prompt| { + prompt.push_str(CODE_SNIPPET_FILE_PATH_PREFIX); + prompt.push_str(related_file.path.to_string_lossy().as_ref()); + prompt.push('\n'); + prompt.push_str(&related_excerpt.text.to_string()); + }, + ); + } + } + }, + ); + + push_delimited( + &mut prompt, + CURRENT_FILE_CONTENT_START..CURRENT_FILE_CONTENT_END, + |prompt| { + prompt.push_str(CURRENT_FILE_PATH_PREFIX); + prompt.push_str(inputs.cursor_path.as_os_str().to_string_lossy().as_ref()); + prompt.push('\n'); + + prompt.push_str(&inputs.cursor_excerpt[0..inputs.editable_range_in_excerpt.start]); + push_delimited(prompt, CODE_TO_EDIT_START..CODE_TO_EDIT_END, |prompt| { + prompt.push_str( + &inputs.cursor_excerpt + [inputs.editable_range_in_excerpt.start..inputs.cursor_offset_in_excerpt], + ); + prompt.push_str(CURSOR_TAG); + prompt.push_str( + &inputs.cursor_excerpt + [inputs.cursor_offset_in_excerpt..inputs.editable_range_in_excerpt.end], + ); + }); + prompt.push_str(&inputs.cursor_excerpt[inputs.editable_range_in_excerpt.end..]); + }, + ); + + push_delimited( + &mut prompt, + EDIT_DIFF_HISTORY_START..EDIT_DIFF_HISTORY_END, + |prompt| { + for event in inputs.events.iter() { + zeta_prompt::write_event(prompt, &event); + } + }, + ); + + prompt +} + +fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce(&mut String)) { + prompt.push_str(delimiters.start); + cb(prompt); + prompt.push_str(delimiters.end); +} + +pub const MERCURY_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions"); +pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token"; +pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("MERCURY_AI_TOKEN"); + +struct GlobalMercuryApiKey(Entity); + +impl Global for GlobalMercuryApiKey {} + +pub fn mercury_api_token(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); + } + let entity = + cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone())); + cx.set_global(GlobalMercuryApiKey(entity.clone())); + entity +} + +pub fn load_mercury_api_token(cx: &mut App) -> Task> { + mercury_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx) + }) +} diff --git a/crates/zeta/src/onboarding_modal.rs b/crates/edit_prediction/src/onboarding_modal.rs similarity index 93% rename from crates/zeta/src/onboarding_modal.rs rename to crates/edit_prediction/src/onboarding_modal.rs index 94480add3053bece5017cf478e9f74065491639b..97f529ae38df350ef21ffc04b79df6e8e6a7a501 100644 --- a/crates/zeta/src/onboarding_modal.rs +++ b/crates/edit_prediction/src/onboarding_modal.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::{ZedPredictUpsell, onboarding_event}; +use crate::ZedPredictUpsell; use ai_onboarding::EditPredictionOnboarding; use client::{Client, UserStore}; use db::kvp::Dismissable; @@ -14,6 +14,16 @@ use settings::update_settings_file; use ui::{Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; +#[macro_export] +macro_rules! onboarding_event { + ($name:expr) => { + telemetry::event!($name, source = "Edit Prediction Onboarding"); + }; + ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { + telemetry::event!($name, source = "Edit Prediction Onboarding", $($key $(= $value)?),+); + }; +} + /// Introduces user to Zed's Edit Prediction feature pub struct ZedPredictModal { onboarding: Entity, @@ -121,8 +131,8 @@ impl Render for ZedPredictModal { onboarding_event!("Cancelled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/edit_prediction/src/open_ai_response.rs b/crates/edit_prediction/src/open_ai_response.rs new file mode 100644 index 0000000000000000000000000000000000000000..c7e3350936dd89c89849130ba279ad2914dd2bd8 --- /dev/null +++ b/crates/edit_prediction/src/open_ai_response.rs @@ -0,0 +1,31 @@ +pub fn text_from_response(mut res: open_ai::Response) -> Option { + let choice = res.choices.pop()?; + let output_text = match choice.message { + open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain(content)), + .. + } => content, + open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Multipart(mut content)), + .. + } => { + if content.is_empty() { + log::error!("No output from Baseten completion response"); + return None; + } + + match content.remove(0) { + open_ai::MessagePart::Text { text } => text, + open_ai::MessagePart::Image { .. } => { + log::error!("Expected text, got an image"); + return None; + } + } + } + _ => { + log::error!("Invalid response message: {:?}", choice.message); + return None; + } + }; + Some(output_text) +} diff --git a/crates/zeta2/src/prediction.rs b/crates/edit_prediction/src/prediction.rs similarity index 73% rename from crates/zeta2/src/prediction.rs rename to crates/edit_prediction/src/prediction.rs index e9f726ce00c36b5235919c0e185876996f4fda03..c63640ccd0e1815b32f736e8a0fee8d75d124df1 100644 --- a/crates/zeta2/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -1,7 +1,14 @@ -use std::{ops::Range, sync::Arc}; - +use std::{ + ops::Range, + sync::Arc, + time::{Duration, Instant}, +}; + +use cloud_llm_client::EditPredictionRejectReason; +use edit_prediction_types::interpolate_edits; use gpui::{AsyncApp, Entity, SharedString}; -use language::{Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, TextBufferSnapshot}; +use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot}; +use zeta_prompt::ZetaPromptInput; #[derive(Clone, Default, Debug, PartialEq, Eq, Hash)] pub struct EditPredictionId(pub SharedString); @@ -18,55 +25,92 @@ impl std::fmt::Display for EditPredictionId { } } -#[derive(Clone)] -pub struct EditPrediction { +/// A prediction response that was returned from the provider, whether it was ultimately valid or not. +pub struct EditPredictionResult { pub id: EditPredictionId, - pub edits: Arc<[(Range, Arc)]>, - pub snapshot: BufferSnapshot, - pub edit_preview: EditPreview, - // We keep a reference to the buffer so that we do not need to reload it from disk when applying the prediction. - pub buffer: Entity, + pub prediction: Result, } -impl EditPrediction { +impl EditPredictionResult { pub async fn new( id: EditPredictionId, edited_buffer: &Entity, edited_buffer_snapshot: &BufferSnapshot, - edits: Vec<(Range, Arc)>, + edits: Arc<[(Range, Arc)]>, + buffer_snapshotted_at: Instant, + response_received_at: Instant, + inputs: ZetaPromptInput, cx: &mut AsyncApp, - ) -> Option { - let (edits, snapshot, edit_preview_task) = edited_buffer + ) -> Self { + if edits.is_empty() { + return Self { + id, + prediction: Err(EditPredictionRejectReason::Empty), + }; + } + + let Some((edits, snapshot, edit_preview_task)) = edited_buffer .read_with(cx, |buffer, cx| { let new_snapshot = buffer.snapshot(); let edits: Arc<[_]> = - interpolate_edits(&edited_buffer_snapshot, &new_snapshot, edits.into())?.into(); + interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits)?.into(); Some((edits.clone(), new_snapshot, buffer.preview_edits(edits, cx))) }) - .ok()??; + .ok() + .flatten() + else { + return Self { + id, + prediction: Err(EditPredictionRejectReason::InterpolatedEmpty), + }; + }; let edit_preview = edit_preview_task.await; - Some(EditPrediction { - id, - edits, - snapshot, - edit_preview, - buffer: edited_buffer.clone(), - }) + Self { + id: id.clone(), + prediction: Ok(EditPrediction { + id, + edits, + snapshot, + edit_preview, + inputs, + buffer: edited_buffer.clone(), + buffer_snapshotted_at, + response_received_at, + }), + } } +} +#[derive(Clone)] +pub struct EditPrediction { + pub id: EditPredictionId, + pub edits: Arc<[(Range, Arc)]>, + pub snapshot: BufferSnapshot, + pub edit_preview: EditPreview, + pub buffer: Entity, + pub buffer_snapshotted_at: Instant, + pub response_received_at: Instant, + pub inputs: zeta_prompt::ZetaPromptInput, +} + +impl EditPrediction { pub fn interpolate( &self, new_snapshot: &TextBufferSnapshot, ) -> Option, Arc)>> { - interpolate_edits(&self.snapshot, new_snapshot, self.edits.clone()) + interpolate_edits(&self.snapshot, new_snapshot, &self.edits) } pub fn targets_buffer(&self, buffer: &Buffer) -> bool { self.snapshot.remote_id() == buffer.remote_id() } + + pub fn latency(&self) -> Duration { + self.response_received_at - self.buffer_snapshotted_at + } } impl std::fmt::Debug for EditPrediction { @@ -78,57 +122,14 @@ impl std::fmt::Debug for EditPrediction { } } -pub fn interpolate_edits( - old_snapshot: &TextBufferSnapshot, - new_snapshot: &TextBufferSnapshot, - current_edits: Arc<[(Range, Arc)]>, -) -> Option, Arc)>> { - let mut edits = Vec::new(); - - let mut model_edits = current_edits.iter().peekable(); - for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { - while let Some((model_old_range, _)) = model_edits.peek() { - let model_old_range = model_old_range.to_offset(old_snapshot); - if model_old_range.end < user_edit.old.start { - let (model_old_range, model_new_text) = model_edits.next().unwrap(); - edits.push((model_old_range.clone(), model_new_text.clone())); - } else { - break; - } - } - - if let Some((model_old_range, model_new_text)) = model_edits.peek() { - let model_old_offset_range = model_old_range.to_offset(old_snapshot); - if user_edit.old == model_old_offset_range { - let user_new_text = new_snapshot - .text_for_range(user_edit.new.clone()) - .collect::(); - - if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { - if !model_suffix.is_empty() { - let anchor = old_snapshot.anchor_after(user_edit.old.end); - edits.push((anchor..anchor, model_suffix.into())); - } - - model_edits.next(); - continue; - } - } - } - - return None; - } - - edits.extend(model_edits.cloned()); - - if edits.is_empty() { None } else { Some(edits) } -} - #[cfg(test)] mod tests { + use std::path::Path; + use super::*; use gpui::{App, Entity, TestAppContext, prelude::*}; use language::{Buffer, ToOffset as _}; + use zeta_prompt::ZetaPromptInput; #[gpui::test] async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { @@ -147,6 +148,16 @@ mod tests { snapshot: cx.read(|cx| buffer.read(cx).snapshot()), buffer: buffer.clone(), edit_preview, + inputs: ZetaPromptInput { + events: vec![], + related_files: vec![].into(), + cursor_path: Path::new("path.txt").into(), + cursor_offset_in_excerpt: 0, + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, + }, + buffer_snapshotted_at: Instant::now(), + response_received_at: Instant::now(), }; cx.update(|cx| { diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs new file mode 100644 index 0000000000000000000000000000000000000000..71f28c9213c3440a9267dab7d5a5416dc219f2f3 --- /dev/null +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -0,0 +1,406 @@ +use anyhow::Result; +use futures::AsyncReadExt as _; +use gpui::{ + App, AppContext as _, Entity, Global, SharedString, Task, + http_client::{self, AsyncBody, Method}, +}; +use language::{Point, ToOffset as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; +use lsp::DiagnosticSeverity; +use serde::{Deserialize, Serialize}; +use std::{ + fmt::{self, Write as _}, + path::Path, + sync::Arc, + time::Instant, +}; + +use crate::{EditPredictionId, EditPredictionModelInput, prediction::EditPredictionResult}; + +const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; + +pub struct SweepAi { + pub api_token: Entity, + pub debug_info: Arc, +} + +impl SweepAi { + pub fn new(cx: &mut App) -> Self { + SweepAi { + api_token: sweep_api_token(cx), + debug_info: debug_info(cx), + } + } + + pub fn request_prediction_with_sweep( + &self, + inputs: EditPredictionModelInput, + cx: &mut App, + ) -> Task>> { + let debug_info = self.debug_info.clone(); + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { + return Task::ready(Ok(None)); + }; + let full_path: Arc = inputs + .snapshot + .file() + .map(|file| file.full_path(cx)) + .unwrap_or_else(|| "untitled".into()) + .into(); + + let project_file = project::File::from_dyn(inputs.snapshot.file()); + let repo_name = project_file + .map(|file| file.worktree.read(cx).root_name_str()) + .unwrap_or("untitled") + .into(); + let offset = inputs.position.to_offset(&inputs.snapshot); + + let recent_buffers = inputs.recent_paths.iter().cloned(); + let http_client = cx.http_client(); + + let recent_buffer_snapshots = recent_buffers + .filter_map(|project_path| { + let buffer = inputs.project.read(cx).get_open_buffer(&project_path, cx)?; + if inputs.buffer == buffer { + None + } else { + Some(buffer.read(cx).snapshot()) + } + }) + .take(3) + .collect::>(); + + let buffer_snapshotted_at = Instant::now(); + + let result = cx.background_spawn(async move { + let text = inputs.snapshot.text(); + + let mut recent_changes = String::new(); + for event in &inputs.events { + write_event(event.as_ref(), &mut recent_changes).unwrap(); + } + + let mut file_chunks = recent_buffer_snapshots + .into_iter() + .map(|snapshot| { + let end_point = Point::new(30, 0).min(snapshot.max_point()); + FileChunk { + content: snapshot.text_for_range(Point::zero()..end_point).collect(), + file_path: snapshot + .file() + .map(|f| f.path().as_unix_str()) + .unwrap_or("untitled") + .to_string(), + start_line: 0, + end_line: end_point.row as usize, + timestamp: snapshot.file().and_then(|file| { + Some( + file.disk_state() + .mtime()? + .to_seconds_and_nanos_for_persistence()? + .0, + ) + }), + } + }) + .collect::>(); + + let retrieval_chunks = inputs + .related_files + .iter() + .flat_map(|related_file| { + related_file.excerpts.iter().map(|excerpt| FileChunk { + file_path: related_file.path.to_string_lossy().to_string(), + start_line: excerpt.row_range.start as usize, + end_line: excerpt.row_range.end as usize, + content: excerpt.text.to_string(), + timestamp: None, + }) + }) + .collect(); + + let diagnostic_entries = inputs + .snapshot + .diagnostics_in_range(inputs.diagnostic_search_range, false); + let mut diagnostic_content = String::new(); + let mut diagnostic_count = 0; + + for entry in diagnostic_entries { + let start_point: Point = entry.range.start; + + let severity = match entry.diagnostic.severity { + DiagnosticSeverity::ERROR => "error", + DiagnosticSeverity::WARNING => "warning", + DiagnosticSeverity::INFORMATION => "info", + DiagnosticSeverity::HINT => "hint", + _ => continue, + }; + + diagnostic_count += 1; + + writeln!( + &mut diagnostic_content, + "{} at line {}: {}", + severity, + start_point.row + 1, + entry.diagnostic.message + )?; + } + + if !diagnostic_content.is_empty() { + file_chunks.push(FileChunk { + file_path: format!("Diagnostics for {}", full_path.display()), + start_line: 0, + end_line: diagnostic_count, + content: diagnostic_content, + timestamp: None, + }); + } + + let request_body = AutocompleteRequest { + debug_info, + repo_name, + file_path: full_path.clone(), + file_contents: text.clone(), + original_file_contents: text, + cursor_position: offset, + recent_changes: recent_changes.clone(), + changes_above_cursor: true, + multiple_suggestions: false, + branch: None, + file_chunks, + retrieval_chunks, + recent_user_actions: vec![], + use_bytes: true, + // TODO + privacy_mode_enabled: false, + }; + + let mut buf: Vec = Vec::new(); + let writer = brotli::CompressorWriter::new(&mut buf, 4096, 11, 22); + serde_json::to_writer(writer, &request_body)?; + let body: AsyncBody = buf.into(); + + let ep_inputs = zeta_prompt::ZetaPromptInput { + events: inputs.events, + related_files: inputs.related_files.clone(), + cursor_path: full_path.clone(), + cursor_excerpt: request_body.file_contents.into(), + // we actually don't know + editable_range_in_excerpt: 0..inputs.snapshot.len(), + cursor_offset_in_excerpt: request_body.cursor_position, + }; + + let request = http_client::Request::builder() + .uri(SWEEP_API_URL) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_token)) + .header("Connection", "keep-alive") + .header("Content-Encoding", "br") + .method(Method::POST) + .body(body)?; + + let mut response = http_client.send(request).await?; + + let mut body: Vec = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + let response_received_at = Instant::now(); + if !response.status().is_success() { + anyhow::bail!( + "Request failed with status: {:?}\nBody: {}", + response.status(), + String::from_utf8_lossy(&body), + ); + }; + + let response: AutocompleteResponse = serde_json::from_slice(&body)?; + + let old_text = inputs + .snapshot + .text_for_range(response.start_index..response.end_index) + .collect::(); + let edits = language::text_diff(&old_text, &response.completion) + .into_iter() + .map(|(range, text)| { + ( + inputs + .snapshot + .anchor_after(response.start_index + range.start) + ..inputs + .snapshot + .anchor_before(response.start_index + range.end), + text, + ) + }) + .collect::>(); + + anyhow::Ok(( + response.autocomplete_id, + edits, + inputs.snapshot, + response_received_at, + ep_inputs, + )) + }); + + let buffer = inputs.buffer.clone(); + + cx.spawn(async move |cx| { + let (id, edits, old_snapshot, response_received_at, inputs) = result.await?; + anyhow::Ok(Some( + EditPredictionResult::new( + EditPredictionId(id.into()), + &buffer, + &old_snapshot, + edits.into(), + buffer_snapshotted_at, + response_received_at, + inputs, + cx, + ) + .await, + )) + }) + } +} + +pub const SWEEP_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://autocomplete.sweep.dev"); +pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token"; +pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("SWEEP_AI_TOKEN"); + +struct GlobalSweepApiKey(Entity); + +impl Global for GlobalSweepApiKey {} + +pub fn sweep_api_token(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); + } + let entity = + cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone())); + cx.set_global(GlobalSweepApiKey(entity.clone())); + entity +} + +pub fn load_sweep_api_token(cx: &mut App) -> Task> { + sweep_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx) + }) +} + +#[derive(Debug, Clone, Serialize)] +struct AutocompleteRequest { + pub debug_info: Arc, + pub repo_name: String, + pub branch: Option, + pub file_path: Arc, + pub file_contents: String, + pub recent_changes: String, + pub cursor_position: usize, + pub original_file_contents: String, + pub file_chunks: Vec, + pub retrieval_chunks: Vec, + pub recent_user_actions: Vec, + pub multiple_suggestions: bool, + pub privacy_mode_enabled: bool, + pub changes_above_cursor: bool, + pub use_bytes: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct FileChunk { + pub file_path: String, + pub start_line: usize, + pub end_line: usize, + pub content: String, + pub timestamp: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct UserAction { + pub action_type: ActionType, + pub line_number: usize, + pub offset: usize, + pub file_path: String, + pub timestamp: u64, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum ActionType { + CursorMovement, + InsertChar, + DeleteChar, + InsertSelection, + DeleteSelection, +} + +#[derive(Debug, Clone, Deserialize)] +struct AutocompleteResponse { + pub autocomplete_id: String, + pub start_index: usize, + pub end_index: usize, + pub completion: String, + #[allow(dead_code)] + pub confidence: f64, + #[allow(dead_code)] + pub logprobs: Option, + #[allow(dead_code)] + pub finish_reason: Option, + #[allow(dead_code)] + pub elapsed_time_ms: u64, + #[allow(dead_code)] + #[serde(default, rename = "completions")] + pub additional_completions: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +struct AdditionalCompletion { + pub start_index: usize, + pub end_index: usize, + pub completion: String, + pub confidence: f64, + pub autocomplete_id: String, + pub logprobs: Option, + pub finish_reason: Option, +} + +fn write_event(event: &zeta_prompt::Event, f: &mut impl fmt::Write) -> fmt::Result { + match event { + zeta_prompt::Event::BufferChange { + old_path, + path, + diff, + .. + } => { + if old_path != path { + // TODO confirm how to do this for sweep + // writeln!(f, "User renamed {:?} to {:?}\n", old_path, new_path)?; + } + + if !diff.is_empty() { + write!(f, "File: {}:\n{}\n", path.display(), diff)? + } + + fmt::Result::Ok(()) + } + } +} + +fn debug_info(cx: &gpui::App) -> Arc { + format!( + "Zed v{version} ({sha}) - OS: {os} - Zed v{version}", + version = release_channel::AppVersion::global(cx), + sha = release_channel::AppCommitSha::try_global(cx) + .map_or("unknown".to_string(), |sha| sha.full()), + os = client::telemetry::os_name(), + ) + .into() +} diff --git a/crates/zeta2/src/udiff.rs b/crates/edit_prediction/src/udiff.rs similarity index 80% rename from crates/zeta2/src/udiff.rs rename to crates/edit_prediction/src/udiff.rs index 5ae029c6c16c2c6b6d0c2451cc961e8399a64a8f..78fec03dd78301d56ac6e3f914ba60432e41637d 100644 --- a/crates/zeta2/src/udiff.rs +++ b/crates/edit_prediction/src/udiff.rs @@ -14,87 +14,48 @@ use anyhow::anyhow; use collections::HashMap; use gpui::AsyncApp; use gpui::Entity; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, TextBufferSnapshot}; -use project::Project; +use language::{Anchor, Buffer, OffsetRangeExt as _, TextBufferSnapshot}; +use project::{Project, ProjectPath}; +use util::paths::PathStyle; +use util::rel_path::RelPath; -pub async fn parse_diff<'a>( - diff_str: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - let mut diff = DiffParser::new(diff_str); - let mut edited_buffer = None; - let mut edits = Vec::new(); - - while let Some(event) = diff.next()? { - match event { - DiffEvent::Hunk { - path: file_path, - hunk, - } => { - let (buffer, ranges) = match edited_buffer { - None => { - edited_buffer = get_buffer(&Path::new(file_path.as_ref())); - edited_buffer - .as_ref() - .context("Model tried to edit a file that wasn't included")? - } - Some(ref current) => current, - }; - - edits.extend( - resolve_hunk_edits_in_buffer(hunk, &buffer.text, ranges) - .with_context(|| format!("Diff:\n{diff_str}"))?, - ); - } - DiffEvent::FileEnd { renamed_to } => { - let (buffer, _) = edited_buffer - .take() - .context("Got a FileEnd event before an Hunk event")?; - - if renamed_to.is_some() { - anyhow::bail!("edit predictions cannot rename files"); - } - - if diff.next()?.is_some() { - anyhow::bail!("Edited more than one file"); - } - - return Ok((buffer, edits)); - } - } - } - - Err(anyhow::anyhow!("No EOF")) -} - -#[derive(Debug)] -pub struct OpenedBuffers<'a>(#[allow(unused)] HashMap, Entity>); +#[derive(Clone, Debug)] +pub struct OpenedBuffers(#[allow(unused)] HashMap>); #[must_use] -pub async fn apply_diff<'a>( - diff_str: &'a str, +pub async fn apply_diff( + diff_str: &str, project: &Entity, cx: &mut AsyncApp, -) -> Result> { +) -> Result { let mut included_files = HashMap::default(); + let worktree_id = project.read_with(cx, |project, cx| { + anyhow::Ok( + project + .visible_worktrees(cx) + .next() + .context("no worktrees")? + .read(cx) + .id(), + ) + })??; + for line in diff_str.lines() { let diff_line = DiffLine::parse(line); if let DiffLine::OldPath { path } = diff_line { let buffer = project .update(cx, |project, cx| { - let project_path = - project - .find_project_path(path.as_ref(), cx) - .with_context(|| { - format!("Failed to find worktree for new path: {}", path) - })?; + let project_path = ProjectPath { + worktree_id, + path: RelPath::new(Path::new(path.as_ref()), PathStyle::Posix)?.into_arc(), + }; anyhow::Ok(project.open_buffer(project_path, cx)) })?? .await?; - included_files.insert(path, buffer); + included_files.insert(path.to_string(), buffer); } } @@ -113,7 +74,7 @@ pub async fn apply_diff<'a>( let (buffer, ranges) = match current_file { None => { let buffer = included_files - .get_mut(&file_path) + .get_mut(file_path.as_ref()) .expect("Opened all files in diff"); current_file = Some((buffer, ranges.as_slice())); @@ -167,6 +128,29 @@ pub async fn apply_diff<'a>( Ok(OpenedBuffers(included_files)) } +pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result { + let mut diff = DiffParser::new(diff_str); + + let mut text = text.to_string(); + + while let Some(event) = diff.next()? { + match event { + DiffEvent::Hunk { hunk, .. } => { + let hunk_offset = text + .find(&hunk.context) + .ok_or_else(|| anyhow!("couldn't resolve hunk {:?}", hunk.context))?; + for edit in hunk.edits.iter().rev() { + let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end); + text.replace_range(range, &edit.text); + } + } + DiffEvent::FileEnd { .. } => {} + } + } + + Ok(text) +} + struct PatchFile<'a> { old_path: Cow<'a, str>, new_path: Cow<'a, str>, @@ -492,7 +476,6 @@ mod tests { use super::*; use gpui::TestAppContext; use indoc::indoc; - use language::Point; use pretty_assertions::assert_eq; use project::{FakeFs, Project}; use serde_json::json; @@ -754,38 +737,38 @@ mod tests { let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 one two -three +3 four five - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 3 -four -five +4 +5 - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 -one -two 3 4 - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 +5 six - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 seven +7.5 eight - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 ten +11 "#}; @@ -817,137 +800,6 @@ mod tests { }); } - #[gpui::test] - async fn test_apply_diff_non_unique(cx: &mut TestAppContext) { - let fs = init_test(cx); - - let buffer_1_text = indoc! {r#" - one - two - three - four - five - one - two - three - four - five - "# }; - - fs.insert_tree( - path!("/root"), - json!({ - "file1": buffer_1_text, - }), - ) - .await; - - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/file1"), cx) - }) - .await - .unwrap(); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 - one - two - -three - +3 - four - five - "#}; - - let final_text = indoc! {r#" - one - two - three - four - five - one - two - 3 - four - five - "#}; - - apply_diff(diff, &project, &mut cx.to_async()) - .await - .expect_err("Non-unique edits should fail"); - - let ranges = [buffer_snapshot.anchor_before(Point::new(1, 0)) - ..buffer_snapshot.anchor_after(buffer_snapshot.max_point())]; - - let (edited_snapshot, edits) = parse_diff(diff, |_path| Some((&buffer_snapshot, &ranges))) - .await - .unwrap(); - - assert_eq!(edited_snapshot.remote_id(), buffer_snapshot.remote_id()); - buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - assert_eq!(buffer.text(), final_text); - }); - } - - #[gpui::test] - async fn test_parse_diff_with_edits_within_line(cx: &mut TestAppContext) { - let fs = init_test(cx); - - let buffer_1_text = indoc! {r#" - one two three four - five six seven eight - nine ten eleven twelve - "# }; - - fs.insert_tree( - path!("/root"), - json!({ - "file1": buffer_1_text, - }), - ) - .await; - - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/file1"), cx) - }) - .await - .unwrap(); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 - one two three four - -five six seven eight - +five SIX seven eight! - nine ten eleven twelve - "#}; - - let (buffer, edits) = parse_diff(diff, |_path| { - Some((&buffer_snapshot, &[(Anchor::MIN..Anchor::MAX)] as &[_])) - }) - .await - .unwrap(); - - let edits = edits - .into_iter() - .map(|(range, text)| (range.to_point(&buffer), text)) - .collect::>(); - assert_eq!( - edits, - &[ - (Point::new(1, 5)..Point::new(1, 8), "SIX".into()), - (Point::new(1, 20)..Point::new(1, 20), "!".into()) - ] - ); - } - #[gpui::test] async fn test_apply_diff_unique_via_previous_context(cx: &mut TestAppContext) { let fs = init_test(cx); @@ -985,8 +837,8 @@ mod tests { let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 one two -three diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs new file mode 100644 index 0000000000000000000000000000000000000000..289bcd76daab2b9a4b82db88b86285e6c7aca00d --- /dev/null +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -0,0 +1,230 @@ +use std::{cmp, sync::Arc}; + +use client::{Client, UserStore}; +use cloud_llm_client::EditPredictionRejectReason; +use edit_prediction_types::{DataCollectionState, EditPredictionDelegate}; +use gpui::{App, Entity, prelude::*}; +use language::{Buffer, ToPoint as _}; +use project::Project; + +use crate::{BufferEditPrediction, EditPredictionModel, EditPredictionStore}; + +pub struct ZedEditPredictionDelegate { + store: Entity, + project: Entity, + singleton_buffer: Option>, +} + +impl ZedEditPredictionDelegate { + pub fn new( + project: Entity, + singleton_buffer: Option>, + client: &Arc, + user_store: &Entity, + cx: &mut Context, + ) -> Self { + let store = EditPredictionStore::global(client, user_store, cx); + store.update(cx, |store, cx| { + store.register_project(&project, cx); + }); + + cx.observe(&store, |_this, _ep_store, cx| { + cx.notify(); + }) + .detach(); + + Self { + project: project, + store: store, + singleton_buffer, + } + } +} + +impl EditPredictionDelegate for ZedEditPredictionDelegate { + fn name() -> &'static str { + "zed-predict" + } + + fn display_name() -> &'static str { + "Zed's Edit Predictions" + } + + fn show_predictions_in_menu() -> bool { + true + } + + fn show_tab_accept_marker() -> bool { + true + } + + fn data_collection_state(&self, cx: &App) -> DataCollectionState { + if let Some(buffer) = &self.singleton_buffer + && let Some(file) = buffer.read(cx).file() + { + let is_project_open_source = + self.store + .read(cx) + .is_file_open_source(&self.project, file, cx); + if self.store.read(cx).data_collection_choice.is_enabled() { + DataCollectionState::Enabled { + is_project_open_source, + } + } else { + DataCollectionState::Disabled { + is_project_open_source, + } + } + } else { + return DataCollectionState::Disabled { + is_project_open_source: false, + }; + } + } + + fn toggle_data_collection(&mut self, cx: &mut App) { + self.store.update(cx, |store, cx| { + store.toggle_data_collection_choice(cx); + }); + } + + fn usage(&self, cx: &App) -> Option { + self.store.read(cx).usage(cx) + } + + fn is_enabled( + &self, + _buffer: &Entity, + _cursor_position: language::Anchor, + cx: &App, + ) -> bool { + let store = self.store.read(cx); + if store.edit_prediction_model == EditPredictionModel::Sweep { + store.has_sweep_api_token(cx) + } else { + true + } + } + + fn is_refreshing(&self, cx: &App) -> bool { + self.store.read(cx).is_refreshing(&self.project) + } + + fn refresh( + &mut self, + buffer: Entity, + cursor_position: language::Anchor, + _debounce: bool, + cx: &mut Context, + ) { + let store = self.store.read(cx); + + if store.user_store.read_with(cx, |user_store, _cx| { + user_store.account_too_young() || user_store.has_overdue_invoices() + }) { + return; + } + + self.store.update(cx, |store, cx| { + if let Some(current) = + store.prediction_at(&buffer, Some(cursor_position), &self.project, cx) + && let BufferEditPrediction::Local { prediction } = current + && prediction.interpolate(buffer.read(cx)).is_some() + { + return; + } + + store.refresh_context(&self.project, &buffer, cursor_position, cx); + store.refresh_prediction_from_buffer(self.project.clone(), buffer, cursor_position, cx) + }); + } + + fn accept(&mut self, cx: &mut Context) { + self.store.update(cx, |store, cx| { + store.accept_current_prediction(&self.project, cx); + }); + } + + fn discard(&mut self, cx: &mut Context) { + self.store.update(cx, |store, _cx| { + store.reject_current_prediction(EditPredictionRejectReason::Discarded, &self.project); + }); + } + + fn did_show(&mut self, cx: &mut Context) { + self.store.update(cx, |store, cx| { + store.did_show_current_prediction(&self.project, cx); + }); + } + + fn suggest( + &mut self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut Context, + ) -> Option { + self.store.update(cx, |store, cx| { + let prediction = + store.prediction_at(buffer, Some(cursor_position), &self.project, cx)?; + + let prediction = match prediction { + BufferEditPrediction::Local { prediction } => prediction, + BufferEditPrediction::Jump { prediction } => { + return Some(edit_prediction_types::EditPrediction::Jump { + id: Some(prediction.id.to_string().into()), + snapshot: prediction.snapshot.clone(), + target: prediction.edits.first().unwrap().0.start, + }); + } + }; + + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + + let Some(edits) = prediction.interpolate(&snapshot) else { + store.reject_current_prediction( + EditPredictionRejectReason::InterpolatedEmpty, + &self.project, + ); + return None; + }; + + let cursor_row = cursor_position.to_point(&snapshot).row; + let (closest_edit_ix, (closest_edit_range, _)) = + edits.iter().enumerate().min_by_key(|(_, (range, _))| { + let distance_from_start = + cursor_row.abs_diff(range.start.to_point(&snapshot).row); + let distance_from_end = cursor_row.abs_diff(range.end.to_point(&snapshot).row); + cmp::min(distance_from_start, distance_from_end) + })?; + + let mut edit_start_ix = closest_edit_ix; + for (range, _) in edits[..edit_start_ix].iter().rev() { + let distance_from_closest_edit = closest_edit_range.start.to_point(&snapshot).row + - range.end.to_point(&snapshot).row; + if distance_from_closest_edit <= 1 { + edit_start_ix -= 1; + } else { + break; + } + } + + let mut edit_end_ix = closest_edit_ix + 1; + for (range, _) in &edits[edit_end_ix..] { + let distance_from_closest_edit = range.start.to_point(buffer).row + - closest_edit_range.end.to_point(&snapshot).row; + if distance_from_closest_edit <= 1 { + edit_end_ix += 1; + } else { + break; + } + } + + Some(edit_prediction_types::EditPrediction::Local { + id: Some(prediction.id.to_string().into()), + edits: edits[edit_start_ix..edit_end_ix].to_vec(), + edit_preview: Some(prediction.edit_preview.clone()), + }) + }) + } +} diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs new file mode 100644 index 0000000000000000000000000000000000000000..01c26573307e66cd6ca3bf8ab748ba8d082ea688 --- /dev/null +++ b/crates/edit_prediction/src/zeta1.rs @@ -0,0 +1,671 @@ +use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant}; + +use crate::{ + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, EditPredictionStore, ZedUpdateRequiredError, + cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count}, + prediction::EditPredictionResult, +}; +use anyhow::{Context as _, Result}; +use cloud_llm_client::{ + PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse, +}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task}; +use language::{ + Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset, ToPoint as _, text_diff, +}; +use project::{Project, ProjectPath}; +use release_channel::AppVersion; +use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; +use zeta_prompt::{Event, ZetaPromptInput}; + +const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; +const START_OF_FILE_MARKER: &str = "<|start_of_file|>"; +const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>"; +const EDITABLE_REGION_END_MARKER: &str = "<|editable_region_end|>"; + +pub(crate) const MAX_CONTEXT_TOKENS: usize = 150; +pub(crate) const MAX_REWRITE_TOKENS: usize = 350; +pub(crate) const MAX_EVENT_TOKENS: usize = 500; + +pub(crate) fn request_prediction_with_zeta1( + store: &mut EditPredictionStore, + EditPredictionModelInput { + project, + buffer, + snapshot, + position, + events, + trigger, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut Context, +) -> Task>> { + let buffer_snapshotted_at = Instant::now(); + let client = store.client.clone(); + let llm_token = store.llm_token.clone(); + let app_version = AppVersion::global(cx); + + let (git_info, can_collect_file) = if let Some(file) = snapshot.file() { + let can_collect_file = store.can_collect_file(&project, file, cx); + let git_info = if can_collect_file { + git_info_for_file(&project, &ProjectPath::from_file(file.as_ref(), cx), cx) + } else { + None + }; + (git_info, can_collect_file) + } else { + (None, false) + }; + + let full_path: Arc = snapshot + .file() + .map(|f| Arc::from(f.full_path(cx).as_path())) + .unwrap_or_else(|| Arc::from(Path::new("untitled"))); + let full_path_str = full_path.to_string_lossy().into_owned(); + let cursor_point = position.to_point(&snapshot); + let prompt_for_events = { + let events = events.clone(); + move || prompt_for_events_impl(&events, MAX_EVENT_TOKENS) + }; + let gather_task = gather_context( + full_path_str, + &snapshot, + cursor_point, + prompt_for_events, + trigger, + cx, + ); + + let (uri, require_auth) = match &store.custom_predict_edits_url { + Some(custom_url) => (custom_url.clone(), false), + None => { + match client + .http_client() + .build_zed_llm_url("/predict_edits/v2", &[]) + { + Ok(url) => (url.into(), true), + Err(err) => return Task::ready(Err(err)), + } + } + }; + + cx.spawn(async move |this, cx| { + let GatherContextOutput { + mut body, + context_range, + editable_range, + included_events_count, + } = gather_task.await?; + let done_gathering_context_at = Instant::now(); + + let included_events = &events[events.len() - included_events_count..events.len()]; + body.can_collect_data = can_collect_file + && this + .read_with(cx, |this, _| this.can_collect_events(included_events)) + .unwrap_or(false); + if body.can_collect_data { + body.git_info = git_info; + } + + log::debug!( + "Events:\n{}\nExcerpt:\n{:?}", + body.input_events, + body.input_excerpt + ); + + let response = EditPredictionStore::send_api_request::( + |request| { + Ok(request + .uri(uri.as_str()) + .body(serde_json::to_string(&body)?.into())?) + }, + client, + llm_token, + app_version, + require_auth, + ) + .await; + + let context_start_offset = context_range.start.to_offset(&snapshot); + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = ZetaPromptInput { + events: included_events.into(), + related_files: vec![].into(), + cursor_path: full_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset), + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_start_offset, + }; + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(serde_json::to_string(&inputs).unwrap()), + position, + }, + )) + .ok(); + } + + let (response, usage) = match response { + Ok(response) => response, + Err(err) => { + if err.is::() { + cx.update(|cx| { + this.update(cx, |ep_store, _cx| { + ep_store.update_required = true; + }) + .ok(); + + let error_message: SharedString = err.to_string().into(); + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + cx.new(|cx| { + ErrorMessagePrompt::new(error_message.clone(), cx) + .with_link_button("Update Zed", "https://zed.dev/releases") + }) + }, + ); + }) + .ok(); + } + + return Err(err); + } + }; + + let received_response_at = Instant::now(); + log::debug!("completion response: {}", &response.output_excerpt); + + if let Some(usage) = usage { + this.update(cx, |this, cx| { + this.user_store.update(cx, |user_store, cx| { + user_store.update_edit_prediction_usage(usage, cx); + }); + }) + .ok(); + } + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + model_output: Some(response.output_excerpt.clone()), + position, + }, + )) + .ok(); + } + + let edit_prediction = process_completion_response( + response, + buffer, + &snapshot, + editable_range, + inputs, + buffer_snapshotted_at, + received_response_at, + cx, + ) + .await; + + let finished_at = Instant::now(); + + // record latency for ~1% of requests + if rand::random::() <= 2 { + telemetry::event!( + "Edit Prediction Request", + context_latency = done_gathering_context_at + .duration_since(buffer_snapshotted_at) + .as_millis(), + request_latency = received_response_at + .duration_since(done_gathering_context_at) + .as_millis(), + process_latency = finished_at.duration_since(received_response_at).as_millis() + ); + } + + edit_prediction.map(Some) + }) +} + +fn process_completion_response( + prediction_response: PredictEditsResponse, + buffer: Entity, + snapshot: &BufferSnapshot, + editable_range: Range, + inputs: ZetaPromptInput, + buffer_snapshotted_at: Instant, + received_response_at: Instant, + cx: &AsyncApp, +) -> Task> { + let snapshot = snapshot.clone(); + let request_id = prediction_response.request_id; + let output_excerpt = prediction_response.output_excerpt; + cx.spawn(async move |cx| { + let output_excerpt: Arc = output_excerpt.into(); + + let edits: Arc<[(Range, Arc)]> = cx + .background_spawn({ + let output_excerpt = output_excerpt.clone(); + let editable_range = editable_range.clone(); + let snapshot = snapshot.clone(); + async move { parse_edits(output_excerpt, editable_range, &snapshot) } + }) + .await? + .into(); + + let id = EditPredictionId(request_id.into()); + Ok(EditPredictionResult::new( + id, + &buffer, + &snapshot, + edits, + buffer_snapshotted_at, + received_response_at, + inputs, + cx, + ) + .await) + }) +} + +fn parse_edits( + output_excerpt: Arc, + editable_range: Range, + snapshot: &BufferSnapshot, +) -> Result, Arc)>> { + let content = output_excerpt.replace(CURSOR_MARKER, ""); + + let start_markers = content + .match_indices(EDITABLE_REGION_START_MARKER) + .collect::>(); + anyhow::ensure!( + start_markers.len() == 1, + "expected exactly one start marker, found {}", + start_markers.len() + ); + + let end_markers = content + .match_indices(EDITABLE_REGION_END_MARKER) + .collect::>(); + anyhow::ensure!( + end_markers.len() == 1, + "expected exactly one end marker, found {}", + end_markers.len() + ); + + let sof_markers = content + .match_indices(START_OF_FILE_MARKER) + .collect::>(); + anyhow::ensure!( + sof_markers.len() <= 1, + "expected at most one start-of-file marker, found {}", + sof_markers.len() + ); + + let codefence_start = start_markers[0].0; + let content = &content[codefence_start..]; + + let newline_ix = content.find('\n').context("could not find newline")?; + let content = &content[newline_ix + 1..]; + + let codefence_end = content + .rfind(&format!("\n{EDITABLE_REGION_END_MARKER}")) + .context("could not find end marker")?; + let new_text = &content[..codefence_end]; + + let old_text = snapshot + .text_for_range(editable_range.clone()) + .collect::(); + + Ok(compute_edits( + old_text, + new_text, + editable_range.start, + snapshot, + )) +} + +pub fn compute_edits( + old_text: String, + new_text: &str, + offset: usize, + snapshot: &BufferSnapshot, +) -> Vec<(Range, Arc)> { + text_diff(&old_text, new_text) + .into_iter() + .map(|(mut old_range, new_text)| { + old_range.start += offset; + old_range.end += offset; + + let prefix_len = common_prefix( + snapshot.chars_for_range(old_range.clone()), + new_text.chars(), + ); + old_range.start += prefix_len; + + let suffix_len = common_prefix( + snapshot.reversed_chars_for_range(old_range.clone()), + new_text[prefix_len..].chars().rev(), + ); + old_range.end = old_range.end.saturating_sub(suffix_len); + + let new_text = new_text[prefix_len..new_text.len() - suffix_len].into(); + let range = if old_range.is_empty() { + let anchor = snapshot.anchor_after(old_range.start); + anchor..anchor + } else { + snapshot.anchor_after(old_range.start)..snapshot.anchor_before(old_range.end) + }; + (range, new_text) + }) + .collect() +} + +fn common_prefix, T2: Iterator>(a: T1, b: T2) -> usize { + a.zip(b) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum() +} + +fn git_info_for_file( + project: &Entity, + project_path: &ProjectPath, + cx: &App, +) -> Option { + let git_store = project.read(cx).git_store().read(cx); + if let Some((repository, _repo_path)) = + git_store.repository_and_path_for_project_path(project_path, cx) + { + let repository = repository.read(cx); + let head_sha = repository + .head_commit + .as_ref() + .map(|head_commit| head_commit.sha.to_string()); + let remote_origin_url = repository.remote_origin_url.clone(); + let remote_upstream_url = repository.remote_upstream_url.clone(); + if head_sha.is_none() && remote_origin_url.is_none() && remote_upstream_url.is_none() { + return None; + } + Some(PredictEditsGitInfo { + head_sha, + remote_origin_url, + remote_upstream_url, + }) + } else { + None + } +} + +pub struct GatherContextOutput { + pub body: PredictEditsBody, + pub context_range: Range, + pub editable_range: Range, + pub included_events_count: usize, +} + +pub fn gather_context( + full_path_str: String, + snapshot: &BufferSnapshot, + cursor_point: language::Point, + prompt_for_events: impl FnOnce() -> (String, usize) + Send + 'static, + trigger: PredictEditsRequestTrigger, + cx: &App, +) -> Task> { + cx.background_spawn({ + let snapshot = snapshot.clone(); + async move { + let input_excerpt = excerpt_for_cursor_position( + cursor_point, + &full_path_str, + &snapshot, + MAX_REWRITE_TOKENS, + MAX_CONTEXT_TOKENS, + ); + let (input_events, included_events_count) = prompt_for_events(); + let editable_range = input_excerpt.editable_range.to_offset(&snapshot); + + let body = PredictEditsBody { + input_events, + input_excerpt: input_excerpt.prompt, + can_collect_data: false, + diagnostic_groups: None, + git_info: None, + outline: None, + speculated_output: None, + trigger, + }; + + Ok(GatherContextOutput { + body, + context_range: input_excerpt.context_range, + editable_range, + included_events_count, + }) + } + }) +} + +fn prompt_for_events_impl(events: &[Arc], mut remaining_tokens: usize) -> (String, usize) { + let mut result = String::new(); + for (ix, event) in events.iter().rev().enumerate() { + let event_string = format_event(event.as_ref()); + let event_tokens = guess_token_count(event_string.len()); + if event_tokens > remaining_tokens { + return (result, ix); + } + + if !result.is_empty() { + result.insert_str(0, "\n\n"); + } + result.insert_str(0, &event_string); + remaining_tokens -= event_tokens; + } + return (result, events.len()); +} + +pub fn format_event(event: &Event) -> String { + match event { + Event::BufferChange { + path, + old_path, + diff, + .. + } => { + let mut prompt = String::new(); + + if old_path != path { + writeln!( + prompt, + "User renamed {} to {}\n", + old_path.display(), + path.display() + ) + .unwrap(); + } + + if !diff.is_empty() { + write!( + prompt, + "User edited {}:\n```diff\n{}\n```", + path.display(), + diff + ) + .unwrap(); + } + + prompt + } + } +} + +#[derive(Debug)] +pub struct InputExcerpt { + pub context_range: Range, + pub editable_range: Range, + pub prompt: String, +} + +pub fn excerpt_for_cursor_position( + position: Point, + path: &str, + snapshot: &BufferSnapshot, + editable_region_token_limit: usize, + context_token_limit: usize, +) -> InputExcerpt { + let (editable_range, context_range) = editable_and_context_ranges_for_cursor_position( + position, + snapshot, + editable_region_token_limit, + context_token_limit, + ); + + let mut prompt = String::new(); + + writeln!(&mut prompt, "```{path}").unwrap(); + if context_range.start == Point::zero() { + writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap(); + } + + for chunk in snapshot.chunks(context_range.start..editable_range.start, false) { + prompt.push_str(chunk.text); + } + + push_editable_range(position, snapshot, editable_range.clone(), &mut prompt); + + for chunk in snapshot.chunks(editable_range.end..context_range.end, false) { + prompt.push_str(chunk.text); + } + write!(prompt, "\n```").unwrap(); + + InputExcerpt { + context_range, + editable_range, + prompt, + } +} + +fn push_editable_range( + cursor_position: Point, + snapshot: &BufferSnapshot, + editable_range: Range, + prompt: &mut String, +) { + writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap(); + for chunk in snapshot.chunks(editable_range.start..cursor_position, false) { + prompt.push_str(chunk.text); + } + prompt.push_str(CURSOR_MARKER); + for chunk in snapshot.chunks(cursor_position..editable_range.end, false) { + prompt.push_str(chunk.text); + } + write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap(); +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{App, AppContext}; + use indoc::indoc; + use language::Buffer; + + #[gpui::test] + fn test_excerpt_for_cursor_position(cx: &mut App) { + let text = indoc! {r#" + fn foo() { + let x = 42; + println!("Hello, world!"); + } + + fn bar() { + let x = 42; + let mut sum = 0; + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + return sum; + } + + fn generate_random_numbers() -> Vec { + let mut rng = rand::thread_rng(); + let mut numbers = Vec::new(); + for _ in 0..5 { + numbers.push(rng.random_range(1..101)); + } + numbers + } + "#}; + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language::rust_lang(), cx)); + let snapshot = buffer.read(cx).snapshot(); + + // Ensure we try to fit the largest possible syntax scope, resorting to line-based expansion + // when a larger scope doesn't fit the editable region. + let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32); + assert_eq!( + excerpt.prompt, + indoc! {r#" + ```main.rs + let x = 42; + println!("Hello, world!"); + <|editable_region_start|> + } + + fn bar() { + let x = 42; + let mut sum = 0; + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + r<|user_cursor_is_here|>eturn sum; + } + + fn generate_random_numbers() -> Vec { + <|editable_region_end|> + let mut rng = rand::thread_rng(); + let mut numbers = Vec::new(); + ```"#} + ); + + // The `bar` function won't fit within the editable region, so we resort to line-based expansion. + let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32); + assert_eq!( + excerpt.prompt, + indoc! {r#" + ```main.rs + fn bar() { + let x = 42; + let mut sum = 0; + <|editable_region_start|> + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + r<|user_cursor_is_here|>eturn sum; + } + + fn generate_random_numbers() -> Vec { + let mut rng = rand::thread_rng(); + <|editable_region_end|> + let mut numbers = Vec::new(); + for _ in 0..5 { + numbers.push(rng.random_range(1..101)); + ```"#} + ); + } +} diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs new file mode 100644 index 0000000000000000000000000000000000000000..9706e2b9ecd03f6e8ba592210722725f420643d3 --- /dev/null +++ b/crates/edit_prediction/src/zeta2.rs @@ -0,0 +1,243 @@ +#[cfg(feature = "cli-support")] +use crate::EvalCacheEntryKind; +use crate::open_ai_response::text_from_response; +use crate::prediction::EditPredictionResult; +use crate::{ + DebugEvent, EDIT_PREDICTIONS_MODEL_ID, EditPredictionFinishedDebugEvent, EditPredictionId, + EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, +}; +use anyhow::{Result, anyhow}; +use cloud_llm_client::EditPredictionRejectReason; +use gpui::{Task, prelude::*}; +use language::{OffsetRangeExt as _, ToOffset as _, ToPoint}; +use release_channel::AppVersion; +use std::{path::Path, sync::Arc, time::Instant}; +use zeta_prompt::CURSOR_MARKER; +use zeta_prompt::format_zeta_prompt; + +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; + +pub fn request_prediction_with_zeta2( + store: &mut EditPredictionStore, + EditPredictionModelInput { + buffer, + snapshot, + position, + related_files, + events, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut Context, +) -> Task>> { + let buffer_snapshotted_at = Instant::now(); + + let Some(excerpt_path) = snapshot + .file() + .map(|file| -> Arc { file.full_path(cx).into() }) + else { + return Task::ready(Err(anyhow!("No file path for excerpt"))); + }; + + let client = store.client.clone(); + let llm_token = store.llm_token.clone(); + let app_version = AppVersion::global(cx); + + #[cfg(feature = "cli-support")] + let eval_cache = store.eval_cache.clone(); + + let request_task = cx.background_spawn({ + async move { + let cursor_offset = position.to_offset(&snapshot); + let (editable_offset_range, prompt_input) = zeta2_prompt_input( + &snapshot, + related_files, + events, + excerpt_path, + cursor_offset, + ); + + let prompt = format_zeta_prompt(&prompt_input); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(prompt.clone()), + position, + }, + )) + .ok(); + } + + let request = open_ai::Request { + model: EDIT_PREDICTIONS_MODEL_ID.clone(), + messages: vec![open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(prompt), + }], + stream: false, + max_completion_tokens: None, + stop: Default::default(), + temperature: Default::default(), + tool_choice: None, + parallel_tool_calls: None, + tools: vec![], + prompt_cache_key: None, + reasoning_effort: None, + }; + + log::trace!("Sending edit prediction request"); + + let response = EditPredictionStore::send_raw_llm_request( + request, + client, + llm_token, + app_version, + #[cfg(feature = "cli-support")] + eval_cache, + #[cfg(feature = "cli-support")] + EvalCacheEntryKind::Prediction, + ) + .await; + let received_response_at = Instant::now(); + + log::trace!("Got edit prediction response"); + + let (res, usage) = response?; + let request_id = EditPredictionId(res.id.clone().into()); + let Some(mut output_text) = text_from_response(res) else { + return Ok((Some((request_id, None)), usage)); + }; + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + position, + model_output: Some(output_text.clone()), + }, + )) + .ok(); + } + + if output_text.contains(CURSOR_MARKER) { + log::trace!("Stripping out {CURSOR_MARKER} from response"); + output_text = output_text.replace(CURSOR_MARKER, ""); + } + + let old_text = snapshot + .text_for_range(editable_offset_range.clone()) + .collect::(); + let edits: Vec<_> = language::text_diff(&old_text, &output_text) + .into_iter() + .map(|(range, text)| { + ( + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot.anchor_before(editable_offset_range.start + range.end), + text, + ) + }) + .collect(); + + anyhow::Ok(( + Some(( + request_id, + Some(( + prompt_input, + buffer, + snapshot.clone(), + edits, + received_response_at, + )), + )), + usage, + )) + } + }); + + cx.spawn(async move |this, cx| { + let Some((id, prediction)) = + EditPredictionStore::handle_api_response(&this, request_task.await, cx)? + else { + return Ok(None); + }; + + let Some((inputs, edited_buffer, edited_buffer_snapshot, edits, received_response_at)) = + prediction + else { + return Ok(Some(EditPredictionResult { + id, + prediction: Err(EditPredictionRejectReason::Empty), + })); + }; + + Ok(Some( + EditPredictionResult::new( + id, + &edited_buffer, + &edited_buffer_snapshot, + edits.into(), + buffer_snapshotted_at, + received_response_at, + inputs, + cx, + ) + .await, + )) + }) +} + +pub fn zeta2_prompt_input( + snapshot: &language::BufferSnapshot, + related_files: Arc<[zeta_prompt::RelatedFile]>, + events: Vec>, + excerpt_path: Arc, + cursor_offset: usize, +) -> (std::ops::Range, zeta_prompt::ZetaPromptInput) { + let cursor_point = cursor_offset.to_point(snapshot); + + let (editable_range, context_range) = + crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + snapshot, + MAX_CONTEXT_TOKENS, + MAX_REWRITE_TOKENS, + ); + + let context_start_offset = context_range.start.to_offset(snapshot); + let editable_offset_range = editable_range.to_offset(snapshot); + let cursor_offset_in_excerpt = cursor_offset - context_start_offset; + let editable_range_in_excerpt = (editable_offset_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset); + + let prompt_input = zeta_prompt::ZetaPromptInput { + cursor_path: excerpt_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt, + cursor_offset_in_excerpt, + events, + related_files, + }; + (editable_offset_range, prompt_input) +} + +#[cfg(feature = "cli-support")] +pub fn zeta2_output_for_patch(input: &zeta_prompt::ZetaPromptInput, patch: &str) -> Result { + let text = &input.cursor_excerpt; + let editable_region = input.editable_range_in_excerpt.clone(); + let old_prefix = &text[..editable_region.start]; + let old_suffix = &text[editable_region.end..]; + + let new = crate::udiff::apply_diff_to_string(patch, text)?; + if !new.starts_with(old_prefix) || !new.ends_with(old_suffix) { + anyhow::bail!("Patch shouldn't affect text outside of editable region"); + } + + Ok(new[editable_region.start..new.len() - old_suffix.len()].to_string()) +} diff --git a/crates/zeta_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml similarity index 68% rename from crates/zeta_cli/Cargo.toml rename to crates/edit_prediction_cli/Cargo.toml index e18cf54787ca98e2be60db4977dd2de18e9c09e2..b6bace2a2c080626126af96f9ef51e435d6ab8fa 100644 --- a/crates/zeta_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "zeta_cli" +name = "edit_prediction_cli" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,38 +9,37 @@ license = "GPL-3.0-or-later" workspace = true [[bin]] -name = "zeta" +name = "ep" path = "src/main.rs" [dependencies] - anyhow.workspace = true +anthropic.workspace = true +http_client.workspace = true chrono.workspace = true clap.workspace = true client.workspace = true cloud_llm_client.workspace= true -cloud_zeta2_prompt.workspace= true collections.workspace = true debug_adapter_extension.workspace = true -edit_prediction_context.workspace = true +dirs.workspace = true extension.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true gpui_tokio.workspace = true +indoc.workspace = true language.workspace = true language_extension.workspace = true language_model.workspace = true language_models.workspace = true languages = { workspace = true, features = ["load-grammars"] } +libc.workspace = true log.workspace = true node_runtime.workspace = true -ordered-float.workspace = true paths.workspace = true -polars = { version = "0.51", features = ["lazy", "dtype-struct", "parquet"] } project.workspace = true prompt_store.workspace = true -pulldown-cmark.workspace = true release_channel.workspace = true reqwest_client.workspace = true serde.workspace = true @@ -48,14 +47,22 @@ serde_json.workspace = true settings.workspace = true shellexpand.workspace = true smol.workspace = true -soa-rs = "0.8.1" +sqlez.workspace = true +sqlez_macros.workspace = true terminal_view.workspace = true -toml.workspace = true util.workspace = true watch.workspace = true -zeta.workspace = true -zeta2 = { workspace = true, features = ["eval-support"] } -zlog.workspace = true +edit_prediction = { workspace = true, features = ["cli-support"] } +wasmtime.workspace = true +zeta_prompt.workspace = true + +# Wasmtime is included as a dependency in order to enable the same +# features that are enabled in Zed. +# +# If we don't enable these features we get crashes when creating +# a Tree-sitter WasmStore. +[package.metadata.cargo-machete] +ignored = ["wasmtime"] [dev-dependencies] indoc.workspace = true diff --git a/crates/edit_prediction_button/LICENSE-GPL b/crates/edit_prediction_cli/LICENSE-GPL similarity index 100% rename from crates/edit_prediction_button/LICENSE-GPL rename to crates/edit_prediction_cli/LICENSE-GPL diff --git a/crates/zeta_cli/build.rs b/crates/edit_prediction_cli/build.rs similarity index 100% rename from crates/zeta_cli/build.rs rename to crates/edit_prediction_cli/build.rs diff --git a/crates/edit_prediction_cli/src/anthropic_client.rs b/crates/edit_prediction_cli/src/anthropic_client.rs new file mode 100644 index 0000000000000000000000000000000000000000..8afc4d1c03f8a37ae258cc2926daf85caebe3d8a --- /dev/null +++ b/crates/edit_prediction_cli/src/anthropic_client.rs @@ -0,0 +1,418 @@ +use anthropic::{ + ANTHROPIC_API_URL, Message, Request as AnthropicRequest, RequestContent, + Response as AnthropicResponse, Role, non_streaming_completion, +}; +use anyhow::Result; +use http_client::HttpClient; +use indoc::indoc; +use reqwest_client::ReqwestClient; +use sqlez::bindable::Bind; +use sqlez::bindable::StaticColumnCount; +use sqlez_macros::sql; +use std::hash::Hash; +use std::hash::Hasher; +use std::path::Path; +use std::sync::Arc; + +pub struct PlainLlmClient { + http_client: Arc, + api_key: String, +} + +impl PlainLlmClient { + fn new() -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; + Ok(Self { + http_client, + api_key, + }) + } + + async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result { + let request = AnthropicRequest { + model: model.to_string(), + max_tokens, + messages, + tools: Vec::new(), + thinking: None, + tool_choice: None, + system: None, + metadata: None, + stop_sequences: Vec::new(), + temperature: None, + top_k: None, + top_p: None, + }; + + let response = non_streaming_completion( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + request, + None, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + Ok(response) + } +} + +pub struct BatchingLlmClient { + connection: sqlez::connection::Connection, + http_client: Arc, + api_key: String, +} + +struct CacheRow { + request_hash: String, + request: Option, + response: Option, + batch_id: Option, +} + +impl StaticColumnCount for CacheRow { + fn column_count() -> usize { + 4 + } +} + +impl Bind for CacheRow { + fn bind(&self, statement: &sqlez::statement::Statement, start_index: i32) -> Result { + let next_index = statement.bind(&self.request_hash, start_index)?; + let next_index = statement.bind(&self.request, next_index)?; + let next_index = statement.bind(&self.response, next_index)?; + let next_index = statement.bind(&self.batch_id, next_index)?; + Ok(next_index) + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct SerializableRequest { + model: String, + max_tokens: u64, + messages: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct SerializableMessage { + role: String, + content: String, +} + +impl BatchingLlmClient { + fn new(cache_path: &Path) -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; + + let connection = sqlez::connection::Connection::open_file(&cache_path.to_str().unwrap()); + let mut statement = sqlez::statement::Statement::prepare( + &connection, + indoc! {" + CREATE TABLE IF NOT EXISTS cache ( + request_hash TEXT PRIMARY KEY, + request TEXT, + response TEXT, + batch_id TEXT + ); + "}, + )?; + statement.exec()?; + drop(statement); + + Ok(Self { + connection, + http_client, + api_key, + }) + } + + pub fn lookup( + &self, + model: &str, + max_tokens: u64, + messages: &[Message], + ) -> Result> { + let request_hash_str = Self::request_hash(model, max_tokens, messages); + let response: Vec = self.connection.select_bound( + &sql!(SELECT response FROM cache WHERE request_hash = ?1 AND response IS NOT NULL;), + )?(request_hash_str.as_str())?; + Ok(response + .into_iter() + .next() + .and_then(|text| serde_json::from_str(&text).ok())) + } + + pub fn mark_for_batch(&self, model: &str, max_tokens: u64, messages: &[Message]) -> Result<()> { + let request_hash = Self::request_hash(model, max_tokens, messages); + + let serializable_messages: Vec = messages + .iter() + .map(|msg| SerializableMessage { + role: match msg.role { + Role::User => "user".to_string(), + Role::Assistant => "assistant".to_string(), + }, + content: message_content_to_string(&msg.content), + }) + .collect(); + + let serializable_request = SerializableRequest { + model: model.to_string(), + max_tokens, + messages: serializable_messages, + }; + + let request = Some(serde_json::to_string(&serializable_request)?); + let cache_row = CacheRow { + request_hash, + request, + response: None, + batch_id: None, + }; + self.connection.exec_bound(sql!( + INSERT OR IGNORE INTO cache(request_hash, request, response, batch_id) VALUES (?, ?, ?, ?)))?( + cache_row, + ) + } + + async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result> { + let response = self.lookup(model, max_tokens, &messages)?; + if let Some(response) = response { + return Ok(Some(response)); + } + + self.mark_for_batch(model, max_tokens, &messages)?; + + Ok(None) + } + + /// Uploads pending requests as a new batch; downloads finished batches if any. + async fn sync_batches(&self) -> Result<()> { + self.upload_pending_requests().await?; + self.download_finished_batches().await + } + + async fn download_finished_batches(&self) -> Result<()> { + let q = sql!(SELECT DISTINCT batch_id FROM cache WHERE batch_id IS NOT NULL AND response IS NULL); + let batch_ids: Vec = self.connection.select(q)?()?; + + for batch_id in batch_ids { + let batch_status = anthropic::batches::retrieve_batch( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + &batch_id, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + log::info!( + "Batch {} status: {}", + batch_id, + batch_status.processing_status + ); + + if batch_status.processing_status == "ended" { + let results = anthropic::batches::retrieve_batch_results( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + &batch_id, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + let mut success_count = 0; + for result in results { + let request_hash = result + .custom_id + .strip_prefix("req_hash_") + .unwrap_or(&result.custom_id) + .to_string(); + + match result.result { + anthropic::batches::BatchResult::Succeeded { message } => { + let response_json = serde_json::to_string(&message)?; + let q = sql!(UPDATE cache SET response = ? WHERE request_hash = ?); + self.connection.exec_bound(q)?((response_json, request_hash))?; + success_count += 1; + } + anthropic::batches::BatchResult::Errored { error } => { + log::error!("Batch request {} failed: {:?}", request_hash, error); + } + anthropic::batches::BatchResult::Canceled => { + log::warn!("Batch request {} was canceled", request_hash); + } + anthropic::batches::BatchResult::Expired => { + log::warn!("Batch request {} expired", request_hash); + } + } + } + log::info!("Downloaded {} successful requests", success_count); + } + } + + Ok(()) + } + + async fn upload_pending_requests(&self) -> Result { + let q = sql!( + SELECT request_hash, request FROM cache WHERE batch_id IS NULL AND response IS NULL + ); + + let rows: Vec<(String, String)> = self.connection.select(q)?()?; + + if rows.is_empty() { + return Ok(String::new()); + } + + let batch_requests = rows + .iter() + .map(|(hash, request_str)| { + let serializable_request: SerializableRequest = + serde_json::from_str(&request_str).unwrap(); + + let messages: Vec = serializable_request + .messages + .into_iter() + .map(|msg| Message { + role: match msg.role.as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + _ => Role::User, + }, + content: vec![RequestContent::Text { + text: msg.content, + cache_control: None, + }], + }) + .collect(); + + let params = AnthropicRequest { + model: serializable_request.model, + max_tokens: serializable_request.max_tokens, + messages, + tools: Vec::new(), + thinking: None, + tool_choice: None, + system: None, + metadata: None, + stop_sequences: Vec::new(), + temperature: None, + top_k: None, + top_p: None, + }; + + let custom_id = format!("req_hash_{}", hash); + anthropic::batches::BatchRequest { custom_id, params } + }) + .collect::>(); + + let batch_len = batch_requests.len(); + let batch = anthropic::batches::create_batch( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + anthropic::batches::CreateBatchRequest { + requests: batch_requests, + }, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + let q = sql!( + UPDATE cache SET batch_id = ? WHERE batch_id is NULL + ); + self.connection.exec_bound(q)?(batch.id.as_str())?; + + log::info!("Uploaded batch with {} requests", batch_len); + + Ok(batch.id) + } + + fn request_hash(model: &str, max_tokens: u64, messages: &[Message]) -> String { + let mut hasher = std::hash::DefaultHasher::new(); + model.hash(&mut hasher); + max_tokens.hash(&mut hasher); + for msg in messages { + message_content_to_string(&msg.content).hash(&mut hasher); + } + let request_hash = hasher.finish(); + format!("{request_hash:016x}") + } +} + +fn message_content_to_string(content: &[RequestContent]) -> String { + content + .iter() + .filter_map(|c| match c { + RequestContent::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join("\n") +} + +pub enum AnthropicClient { + // No batching + Plain(PlainLlmClient), + Batch(BatchingLlmClient), + Dummy, +} + +impl AnthropicClient { + pub fn plain() -> Result { + Ok(Self::Plain(PlainLlmClient::new()?)) + } + + pub fn batch(cache_path: &Path) -> Result { + Ok(Self::Batch(BatchingLlmClient::new(cache_path)?)) + } + + #[allow(dead_code)] + pub fn dummy() -> Self { + Self::Dummy + } + + pub async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result> { + match self { + AnthropicClient::Plain(plain_llm_client) => plain_llm_client + .generate(model, max_tokens, messages) + .await + .map(Some), + AnthropicClient::Batch(batching_llm_client) => { + batching_llm_client + .generate(model, max_tokens, messages) + .await + } + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + } + } + + pub async fn sync_batches(&self) -> Result<()> { + match self { + AnthropicClient::Plain(_) => Ok(()), + AnthropicClient::Batch(batching_llm_client) => batching_llm_client.sync_batches().await, + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + } + } +} diff --git a/crates/edit_prediction_cli/src/distill.rs b/crates/edit_prediction_cli/src/distill.rs new file mode 100644 index 0000000000000000000000000000000000000000..abfe178ae61b6da522f43c93d40b6000800d0e4d --- /dev/null +++ b/crates/edit_prediction_cli/src/distill.rs @@ -0,0 +1,22 @@ +use anyhow::{Result, anyhow}; +use std::mem; + +use crate::example::Example; + +pub async fn run_distill(example: &mut Example) -> Result<()> { + let [prediction]: [_; 1] = + mem::take(&mut example.predictions) + .try_into() + .map_err(|preds: Vec<_>| { + anyhow!( + "Example has {} predictions, but it should have exactly one", + preds.len() + ) + })?; + + example.spec.expected_patch = prediction.actual_patch; + example.prompt = None; + example.predictions = Vec::new(); + example.score = Vec::new(); + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs new file mode 100644 index 0000000000000000000000000000000000000000..e37619bf224b3fa506516714856cfbc5024ece14 --- /dev/null +++ b/crates/edit_prediction_cli/src/example.rs @@ -0,0 +1,250 @@ +use crate::{PredictionProvider, PromptFormat, metrics::ClassificationMetrics}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use edit_prediction::example_spec::ExampleSpec; +use edit_prediction::udiff::OpenedBuffers; +use gpui::Entity; +use http_client::Url; +use language::{Anchor, Buffer}; +use project::Project; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::{ + borrow::Cow, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use zeta_prompt::RelatedFile; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Example { + #[serde(flatten)] + pub spec: ExampleSpec, + + /// The full content of the file where an edit is being predicted, and the + /// actual cursor offset. + #[serde(skip_serializing_if = "Option::is_none")] + pub buffer: Option, + + /// The context retrieved for the prediction. This requires the worktree to + /// be loaded and the language server to be started. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + + /// The input and expected output from the edit prediction model. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + /// The actual predictions from the model. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub predictions: Vec, + + /// The scores, for how well the actual predictions match the expected + /// predictions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub score: Vec, + + /// The application state used to process this example. + #[serde(skip)] + pub state: Option, +} + +#[derive(Clone, Debug)] +pub struct ExampleState { + pub project: Entity, + pub buffer: Entity, + pub cursor_position: Anchor, + pub _open_buffers: OpenedBuffers, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleContext { + pub files: Arc<[RelatedFile]>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleBuffer { + pub content: String, + pub cursor_row: u32, + pub cursor_column: u32, + pub cursor_offset: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrompt { + pub input: String, + pub expected_output: String, + pub format: PromptFormat, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrediction { + pub actual_patch: String, + pub actual_output: String, + pub provider: PredictionProvider, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleScore { + pub delta_chr_f: f32, + pub line_match: ClassificationMetrics, +} + +impl Example { + pub fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> { + // git@github.com:owner/repo.git + if self.spec.repository_url.contains('@') { + let (owner, repo) = self + .spec + .repository_url + .split_once(':') + .context("expected : in git url")? + .1 + .split_once('/') + .context("expected / in git url")?; + Ok(( + Cow::Borrowed(owner), + Cow::Borrowed(repo.trim_end_matches(".git")), + )) + // http://github.com/owner/repo.git + } else { + let url = Url::parse(&self.spec.repository_url)?; + let mut segments = url.path_segments().context("empty http url")?; + let owner = segments + .next() + .context("expected owner path segment")? + .to_string(); + let repo = segments + .next() + .context("expected repo path segment")? + .trim_end_matches(".git") + .to_string(); + assert!(segments.next().is_none()); + + Ok((owner.into(), repo.into())) + } + } +} + +pub fn read_examples(inputs: &[PathBuf]) -> Vec { + let mut examples = Vec::new(); + + let stdin_path: PathBuf = PathBuf::from("-"); + + let inputs = if inputs.is_empty() { + &[stdin_path] + } else { + inputs + }; + + for path in inputs { + let is_stdin = path.as_path() == Path::new("-"); + let content = if is_stdin { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .expect("Failed to read from stdin"); + buffer + } else { + std::fs::read_to_string(path) + .unwrap_or_else(|_| panic!("Failed to read path: {:?}", &path)) + }; + let filename = path.file_stem().unwrap().to_string_lossy().to_string(); + let ext = if !is_stdin { + path.extension() + .map(|ext| ext.to_string_lossy().to_string()) + .unwrap_or_else(|| panic!("{} should have an extension", path.display())) + } else { + "jsonl".to_string() + }; + + match ext.as_ref() { + "json" => { + let mut example = + serde_json::from_str::(&content).unwrap_or_else(|error| { + panic!("Failed to parse example file: {}\n{error}", path.display()) + }); + if example.spec.name.is_empty() { + example.spec.name = filename; + } + examples.push(example); + } + "jsonl" => examples.extend( + content + .lines() + .enumerate() + .map(|(line_ix, line)| { + let mut example = + serde_json::from_str::(line).unwrap_or_else(|error| { + panic!( + "Failed to parse example on {}:{}\n{error}", + path.display(), + line_ix + 1 + ) + }); + if example.spec.name.is_empty() { + example.spec.name = format!("{filename}-{line_ix}") + } + example + }) + .collect::>(), + ), + "md" => { + examples.push(parse_markdown_example(filename, &content).unwrap()); + } + ext => { + panic!("{} has invalid example extension `{ext}`", path.display()) + } + } + } + + sort_examples_by_repo_and_rev(&mut examples); + examples +} + +pub fn write_examples(examples: &[Example], output_path: Option<&PathBuf>) { + let mut content = String::new(); + for example in examples { + let line = serde_json::to_string(example).unwrap(); + content.push_str(&line); + content.push('\n'); + } + if let Some(output_path) = output_path { + std::fs::write(output_path, content).expect("Failed to write examples"); + } else { + std::io::stdout().write_all(&content.as_bytes()).unwrap(); + } +} + +pub fn sort_examples_by_repo_and_rev(examples: &mut [Example]) { + examples.sort_by(|a, b| { + a.spec + .repository_url + .cmp(&b.spec.repository_url) + .then(b.spec.revision.cmp(&a.spec.revision)) + }); +} + +pub fn group_examples_by_repo(examples: &mut [Example]) -> Vec> { + let mut examples_by_repo = HashMap::default(); + for example in examples.iter_mut() { + examples_by_repo + .entry(example.spec.repository_url.clone()) + .or_insert_with(Vec::new) + .push(example); + } + examples_by_repo.into_values().collect() +} + +fn parse_markdown_example(name: String, input: &str) -> Result { + let spec = ExampleSpec::from_markdown(name, input)?; + Ok(Example { + spec, + buffer: None, + context: None, + prompt: None, + predictions: Vec::new(), + score: Vec::new(), + state: None, + }) +} diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs new file mode 100644 index 0000000000000000000000000000000000000000..f543d0799b379403f0caa980df76954649e1aceb --- /dev/null +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -0,0 +1,288 @@ +use crate::{ + PromptFormat, + example::{Example, ExamplePrompt}, + headless::EpAppState, + load_project::run_load_project, + progress::{Progress, Step}, + retrieve_context::run_context_retrieval, +}; +use anyhow::{Context as _, Result, ensure}; +use edit_prediction::{ + EditPredictionStore, + zeta2::{zeta2_output_for_patch, zeta2_prompt_input}, +}; +use gpui::AsyncApp; +use std::sync::Arc; +use zeta_prompt::format_zeta_prompt; + +pub async fn run_format_prompt( + example: &mut Example, + prompt_format: PromptFormat, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; + + let _step_progress = Progress::global().start(Step::FormatPrompt, &example.spec.name); + + match prompt_format { + PromptFormat::Teacher => { + let prompt = TeacherPrompt::format_prompt(example); + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output: example.spec.expected_patch.clone(), // TODO + format: prompt_format, + }); + } + PromptFormat::Zeta2 => { + run_load_project(example, app_state, cx.clone()).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let state = example.state.as_ref().context("state must be set")?; + let snapshot = state.buffer.read_with(&cx, |buffer, _| buffer.snapshot())?; + let project = state.project.clone(); + let (_, input) = ep_store.update(&mut cx, |ep_store, cx| { + anyhow::Ok(zeta2_prompt_input( + &snapshot, + example + .context + .as_ref() + .context("context must be set")? + .files + .clone(), + ep_store.edit_history_for_project(&project, cx), + example.spec.cursor_path.clone(), + example + .buffer + .as_ref() + .context("buffer must be set")? + .cursor_offset, + )) + })??; + let prompt = format_zeta_prompt(&input); + let expected_output = + zeta2_output_for_patch(&input, &example.spec.expected_patch.clone())?; + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output, + format: prompt_format, + }); + } + }; + Ok(()) +} + +pub struct TeacherPrompt; + +impl TeacherPrompt { + const PROMPT: &str = include_str!("teacher.prompt.md"); + pub(crate) const EDITABLE_REGION_START: &str = "<|editable_region_start|>\n"; + pub(crate) const EDITABLE_REGION_END: &str = "<|editable_region_end|>"; + + /// Truncate edit history to this number of last lines + const MAX_HISTORY_LINES: usize = 128; + + pub fn format_prompt(example: &Example) -> String { + let edit_history = Self::format_edit_history(&example.spec.edit_history); + let context = Self::format_context(example); + let editable_region = Self::format_editable_region(example); + + let prompt = Self::PROMPT + .replace("{{context}}", &context) + .replace("{{edit_history}}", &edit_history) + .replace("{{editable_region}}", &editable_region); + + prompt + } + + pub fn parse(example: &Example, response: &str) -> Result { + // Ideally, we should always be able to find cursor position in the retrieved context. + // In reality, sometimes we don't find it for these reasons: + // 1. `example.cursor_position` contains _more_ context than included in the retrieved context + // (can be fixed by getting cursor coordinates at the load_example stage) + // 2. Context retriever just didn't include cursor line. + // + // In that case, fallback to using `cursor_position` as excerpt. + let cursor_file = &example + .buffer + .as_ref() + .context("`buffer` should be filled in in the context collection step")? + .content; + + // Extract updated (new) editable region from the model response + let new_editable_region = extract_last_codeblock(response); + + // Reconstruct old editable region we sent to the model + let old_editable_region = Self::format_editable_region(example); + let old_editable_region = Self::extract_editable_region(&old_editable_region); + ensure!( + cursor_file.contains(&old_editable_region), + "Something's wrong: editable_region is not found in the cursor file" + ); + + // Apply editable region to a larger context and compute diff. + // This is needed to get a better context lines around the editable region + let edited_file = cursor_file.replace(&old_editable_region, &new_editable_region); + let diff = language::unified_diff(&cursor_file, &edited_file); + + let diff = indoc::formatdoc! {" + --- a/{path} + +++ b/{path} + {diff}", + path = example.spec.cursor_path.to_string_lossy(), + diff = diff, + }; + + Ok(diff) + } + + fn format_edit_history(edit_history: &str) -> String { + // Strip comments ("garbage lines") from edit history + let lines = edit_history + .lines() + .filter(|&s| Self::is_udiff_content_line(s)) + .collect::>(); + + let history_lines = if lines.len() > Self::MAX_HISTORY_LINES { + &lines[lines.len() - Self::MAX_HISTORY_LINES..] + } else { + &lines + }; + + if history_lines.is_empty() { + return "(No edit history)".to_string(); + } + + history_lines.join("\n") + } + + fn format_context(example: &Example) -> String { + assert!(example.context.is_some(), "Missing context retriever step"); + + let mut prompt = String::new(); + zeta_prompt::write_related_files(&mut prompt, &example.context.as_ref().unwrap().files); + + prompt + } + + fn format_editable_region(example: &Example) -> String { + let mut result = String::new(); + + let path_str = example.spec.cursor_path.to_string_lossy(); + result.push_str(&format!("`````path=\"{path_str}\"\n")); + result.push_str(Self::EDITABLE_REGION_START); + + // TODO: control number of lines around cursor + result.push_str(&example.spec.cursor_position); + if !example.spec.cursor_position.ends_with('\n') { + result.push('\n'); + } + + result.push_str(&format!("{}\n", Self::EDITABLE_REGION_END)); + result.push_str("`````"); + + result + } + + fn extract_editable_region(text: &str) -> String { + let start = text + .find(Self::EDITABLE_REGION_START) + .map_or(0, |pos| pos + Self::EDITABLE_REGION_START.len()); + let end = text.find(Self::EDITABLE_REGION_END).unwrap_or(text.len()); + + let region = &text[start..end]; + + region.replace("<|user_cursor|>", "") + } + + fn is_udiff_content_line(s: &str) -> bool { + s.starts_with("-") + || s.starts_with("+") + || s.starts_with(" ") + || s.starts_with("---") + || s.starts_with("+++") + || s.starts_with("@@") + } +} + +fn extract_last_codeblock(text: &str) -> String { + let mut last_block = None; + let mut search_start = 0; + + while let Some(start) = text[search_start..].find("```") { + let start = start + search_start; + let bytes = text.as_bytes(); + let mut backtick_end = start; + + while backtick_end < bytes.len() && bytes[backtick_end] == b'`' { + backtick_end += 1; + } + + let backtick_count = backtick_end - start; + let closing_backticks = "`".repeat(backtick_count); + + while backtick_end < bytes.len() && bytes[backtick_end] != b'\n' { + backtick_end += 1; + } + + if let Some(end_pos) = text[backtick_end..].find(&closing_backticks) { + let code_block = &text[backtick_end + 1..backtick_end + end_pos]; + last_block = Some(code_block.to_string()); + search_start = backtick_end + end_pos + backtick_count; + } else { + break; + } + } + + last_block.unwrap_or_else(|| text.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_last_code_block() { + let text = indoc::indoc! {" + Some thinking + + ``` + first block + ``` + + `````path='something' lines=1:2 + last block + ````` + "}; + let last_block = extract_last_codeblock(text); + assert_eq!(last_block, "last block\n"); + } + + #[test] + fn test_extract_editable_region() { + let text = indoc::indoc! {" + some lines + are + here + <|editable_region_start|> + one + two three + + <|editable_region_end|> + more + lines here + "}; + let parsed = TeacherPrompt::extract_editable_region(text); + assert_eq!( + parsed, + indoc::indoc! {" + one + two three + + "} + ); + } +} diff --git a/crates/zeta_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs similarity index 84% rename from crates/zeta_cli/src/headless.rs rename to crates/edit_prediction_cli/src/headless.rs index c4d8667d63dfb3dd39fbced609e0ae0bc44974d2..da96e7ef6520e952e2b7696eee6b82c243e90e4e 100644 --- a/crates/zeta_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -1,4 +1,5 @@ use client::{Client, ProxySettings, UserStore}; +use collections::HashMap; use extension::ExtensionHostProxy; use fs::RealFs; use gpui::http_client::read_proxy_from_env; @@ -7,25 +8,38 @@ use gpui_tokio::Tokio; use language::LanguageRegistry; use language_extension::LspAccess; use node_runtime::{NodeBinaryOptions, NodeRuntime}; -use project::project_settings::ProjectSettings; +use project::{Project, project_settings::ProjectSettings}; use release_channel::{AppCommitSha, AppVersion}; use reqwest_client::ReqwestClient; use settings::{Settings, SettingsStore}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use util::ResultExt as _; /// Headless subset of `workspace::AppState`. -pub struct ZetaCliAppState { +pub struct EpAppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, pub fs: Arc, pub node_runtime: NodeRuntime, + pub project_cache: ProjectCache, } -// TODO: dedupe with crates/eval/src/eval.rs -pub fn init(cx: &mut App) -> ZetaCliAppState { +#[derive(Default)] +pub struct ProjectCache(Mutex>>); + +impl ProjectCache { + pub fn insert(&self, repository_url: String, project: Entity) { + self.0.lock().unwrap().insert(repository_url, project); + } + + pub fn get(&self, repository_url: &String) -> Option> { + self.0.lock().unwrap().get(repository_url).cloned() + } +} + +pub fn init(cx: &mut App) -> EpAppState { let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned())); let app_version = AppVersion::load( @@ -112,11 +126,14 @@ pub fn init(cx: &mut App) -> ZetaCliAppState { prompt_store::init(cx); terminal_view::init(cx); - ZetaCliAppState { + let project_cache = ProjectCache::default(); + + EpAppState { languages, client, user_store, fs, node_runtime, + project_cache, } } diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs new file mode 100644 index 0000000000000000000000000000000000000000..70daf00b79486fd917556cffaa26b1fd01ed4d28 --- /dev/null +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -0,0 +1,357 @@ +use crate::{ + example::{Example, ExampleBuffer, ExampleState}, + headless::EpAppState, + paths::{REPOS_DIR, WORKTREES_DIR}, + progress::{InfoStyle, Progress, Step, StepProgress}, +}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use edit_prediction::EditPredictionStore; +use edit_prediction::udiff::OpenedBuffers; +use futures::{ + AsyncWriteExt as _, + lock::{Mutex, OwnedMutexGuard}, +}; +use gpui::{AsyncApp, Entity}; +use language::{Anchor, Buffer, LanguageNotFound, ToOffset, ToPoint}; +use project::buffer_store::BufferStoreEvent; +use project::{Project, ProjectPath}; +use std::{ + cell::RefCell, + fs, + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{paths::PathStyle, rel_path::RelPath}; +use zeta_prompt::CURSOR_MARKER; + +pub async fn run_load_project( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { + if example.state.is_some() { + return Ok(()); + } + + let progress = Progress::global().start(Step::LoadProject, &example.spec.name); + + let project = setup_project(example, &app_state, &progress, &mut cx).await?; + + let _open_buffers = apply_edit_history(example, &project, &mut cx).await?; + + let (buffer, cursor_position) = cursor_position(example, &project, &mut cx).await?; + let (example_buffer, language_name) = buffer.read_with(&cx, |buffer, _cx| { + let cursor_point = cursor_position.to_point(&buffer); + let language_name = buffer + .language() + .map(|l| l.name().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + ( + ExampleBuffer { + content: buffer.text(), + cursor_row: cursor_point.row, + cursor_column: cursor_point.column, + cursor_offset: cursor_position.to_offset(&buffer), + }, + language_name, + ) + })?; + + progress.set_info(language_name, InfoStyle::Normal); + + example.buffer = Some(example_buffer); + example.state = Some(ExampleState { + buffer, + project, + cursor_position, + _open_buffers, + }); + Ok(()) +} + +async fn cursor_position( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result<(Entity, Anchor)> { + let language_registry = project.read_with(cx, |project, _| project.languages().clone())?; + let result = language_registry + .load_language_for_file_path(&example.spec.cursor_path) + .await; + + if let Err(error) = result + && !error.is::() + { + return Err(error); + } + + let worktree = project.read_with(cx, |project, cx| { + project + .visible_worktrees(cx) + .next() + .context("No visible worktrees") + })??; + + let cursor_path = RelPath::new(&example.spec.cursor_path, PathStyle::Posix) + .context("Failed to create RelPath")? + .into_arc(); + let cursor_buffer = project + .update(cx, |project, cx| { + project.open_buffer( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: cursor_path, + }, + cx, + ) + })? + .await?; + let cursor_offset_within_excerpt = example + .spec + .cursor_position + .find(CURSOR_MARKER) + .context("missing cursor marker")?; + let mut cursor_excerpt = example.spec.cursor_position.clone(); + cursor_excerpt.replace_range( + cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), + "", + ); + let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| { + let text = buffer.text(); + + let mut matches = text.match_indices(&cursor_excerpt); + let (excerpt_offset, _) = matches.next().with_context(|| { + format!( + "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Example: {}\nCursor excerpt did not exist in buffer.", + example.spec.name + ) + })?; + anyhow::ensure!( + matches.next().is_none(), + "More than one cursor position match found for {}", + &example.spec.name + ); + Ok(excerpt_offset) + })??; + + let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; + let cursor_anchor = + cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?; + + Ok((cursor_buffer, cursor_anchor)) +} + +async fn setup_project( + example: &mut Example, + app_state: &Arc, + step_progress: &StepProgress, + cx: &mut AsyncApp, +) -> Result> { + let ep_store = cx + .update(|cx| EditPredictionStore::try_global(cx))? + .context("Store should be initialized at init")?; + + let worktree_path = setup_worktree(example, step_progress).await?; + + if let Some(project) = app_state.project_cache.get(&example.spec.repository_url) { + ep_store.update(cx, |ep_store, _| { + ep_store.clear_history_for_project(&project); + })?; + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + let buffers = buffer_store.read_with(cx, |buffer_store, _| { + buffer_store.buffers().collect::>() + })?; + for buffer in buffers { + buffer + .update(cx, |buffer, cx| buffer.reload(cx))? + .await + .ok(); + } + return Ok(project); + } + + let project = cx.update(|cx| { + Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + false, + cx, + ) + })?; + + project + .update(cx, |project, cx| { + project.disable_worktree_scanner(cx); + project.create_worktree(&worktree_path, true, cx) + })? + .await?; + + app_state + .project_cache + .insert(example.spec.repository_url.clone(), project.clone()); + + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + cx.subscribe(&buffer_store, { + let project = project.clone(); + move |_, event, cx| match event { + BufferStoreEvent::BufferAdded(buffer) => { + ep_store.update(cx, |store, cx| store.register_buffer(&buffer, &project, cx)); + } + _ => {} + } + })? + .detach(); + + Ok(project) +} + +async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Result { + let (repo_owner, repo_name) = example.repo_name().context("failed to get repo name")?; + let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()); + let worktree_path = WORKTREES_DIR + .join(repo_owner.as_ref()) + .join(repo_name.as_ref()); + let repo_lock = lock_repo(&repo_dir).await; + + if !repo_dir.is_dir() { + step_progress.set_substatus(format!("cloning {}", repo_name)); + fs::create_dir_all(&repo_dir)?; + run_git(&repo_dir, &["init"]).await?; + run_git( + &repo_dir, + &["remote", "add", "origin", &example.spec.repository_url], + ) + .await?; + } + + // Resolve the example to a revision, fetching it if needed. + let revision = run_git( + &repo_dir, + &[ + "rev-parse", + &format!("{}^{{commit}}", example.spec.revision), + ], + ) + .await; + let revision = if let Ok(revision) = revision { + revision + } else { + step_progress.set_substatus("fetching"); + if run_git( + &repo_dir, + &["fetch", "--depth", "1", "origin", &example.spec.revision], + ) + .await + .is_err() + { + run_git(&repo_dir, &["fetch", "origin"]).await?; + } + let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?; + revision + }; + + // Create the worktree for this example if needed. + step_progress.set_substatus("preparing worktree"); + if worktree_path.is_dir() { + run_git(&worktree_path, &["clean", "--force", "-d"]).await?; + run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; + run_git(&worktree_path, &["checkout", revision.as_str()]).await?; + } else { + let worktree_path_string = worktree_path.to_string_lossy(); + run_git( + &repo_dir, + &["branch", "-f", &example.spec.name, revision.as_str()], + ) + .await?; + run_git( + &repo_dir, + &[ + "worktree", + "add", + "-f", + &worktree_path_string, + &example.spec.name, + ], + ) + .await?; + } + drop(repo_lock); + + // Apply the uncommitted diff for this example. + if !example.spec.uncommitted_diff.is_empty() { + step_progress.set_substatus("applying diff"); + let mut apply_process = smol::process::Command::new("git") + .current_dir(&worktree_path) + .args(&["apply", "-"]) + .stdin(std::process::Stdio::piped()) + .spawn()?; + + let mut stdin = apply_process.stdin.take().context("Failed to get stdin")?; + stdin + .write_all(example.spec.uncommitted_diff.as_bytes()) + .await?; + stdin.close().await?; + drop(stdin); + + let apply_result = apply_process.output().await?; + anyhow::ensure!( + apply_result.status.success(), + "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", + apply_result.status, + String::from_utf8_lossy(&apply_result.stderr), + String::from_utf8_lossy(&apply_result.stdout), + ); + } + + step_progress.clear_substatus(); + Ok(worktree_path) +} + +async fn apply_edit_history( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result { + edit_prediction::udiff::apply_diff(&example.spec.edit_history, project, cx).await +} + +thread_local! { + static REPO_LOCKS: RefCell>>> = RefCell::new(HashMap::default()); +} + +#[must_use] +pub async fn lock_repo(path: impl AsRef) -> OwnedMutexGuard<()> { + REPO_LOCKS + .with(|cell| { + cell.borrow_mut() + .entry(path.as_ref().to_path_buf()) + .or_default() + .clone() + }) + .lock_owned() + .await +} + +async fn run_git(repo_path: &Path, args: &[&str]) -> Result { + let output = smol::process::Command::new("git") + .current_dir(repo_path) + .args(args) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", + args.join(" "), + repo_path.display(), + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..dce0fbbed57dbc4b18faf93787cfb8f2341a126a --- /dev/null +++ b/crates/edit_prediction_cli/src/main.rs @@ -0,0 +1,343 @@ +mod anthropic_client; +mod distill; +mod example; +mod format_prompt; +mod headless; +mod load_project; +mod metrics; +mod paths; +mod predict; +mod progress; +mod retrieve_context; +mod score; + +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use edit_prediction::EditPredictionStore; +use gpui::Application; +use reqwest_client::ReqwestClient; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::{path::PathBuf, sync::Arc}; + +use crate::distill::run_distill; +use crate::example::{group_examples_by_repo, read_examples, write_examples}; +use crate::format_prompt::run_format_prompt; +use crate::load_project::run_load_project; +use crate::paths::FAILED_EXAMPLES_DIR; +use crate::predict::run_prediction; +use crate::progress::Progress; +use crate::retrieve_context::run_context_retrieval; +use crate::score::run_scoring; + +#[derive(Parser, Debug)] +#[command(name = "ep")] +struct EpArgs { + #[arg(long, default_value_t = false)] + printenv: bool, + #[clap(long, default_value_t = 10, global = true)] + max_parallelism: usize, + #[command(subcommand)] + command: Option, + #[clap(global = true)] + inputs: Vec, + #[arg(long, short, global = true)] + output: Option, + #[arg(long, short, global = true)] + in_place: bool, + #[arg(long, short, global = true)] + failfast: bool, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Parse markdown examples and output a combined .jsonl file + ParseExample, + /// Create git worktrees for each example and load file contents + LoadProject, + /// Retrieve context for input examples. + Context, + /// Generate a prompt string for a specific model + FormatPrompt(FormatPromptArgs), + /// Runs edit prediction + Predict(PredictArgs), + /// Computes a score based on actual and expected patches + Score(PredictArgs), + /// Prepares a distillation dataset by copying expected outputs to + /// predicted outputs and removing actual outputs and prompts. + Distill, + /// Print aggregated scores + Eval(PredictArgs), + /// Remove git repositories and worktrees + Clean, +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Command::ParseExample => write!(f, "parse-example"), + Command::LoadProject => write!(f, "load-project"), + Command::Context => write!(f, "context"), + Command::FormatPrompt(format_prompt_args) => write!( + f, + "format-prompt --prompt-format={}", + format_prompt_args + .prompt_format + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Predict(predict_args) => { + write!( + f, + "predict --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Score(predict_args) => { + write!( + f, + "score --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Distill => write!(f, "distill"), + Command::Eval(predict_args) => write!( + f, + "eval --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Clean => write!(f, "clean"), + } + } +} + +#[derive(Debug, Args)] +struct FormatPromptArgs { + #[clap(long)] + prompt_format: PromptFormat, +} + +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] +enum PromptFormat { + Teacher, + Zeta2, +} + +#[derive(Debug, Args)] +struct PredictArgs { + #[clap(long)] + provider: PredictionProvider, + #[clap(long, default_value_t = 1)] + repetitions: usize, +} + +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] +enum PredictionProvider { + Sweep, + Mercury, + Zeta1, + Zeta2, + Teacher, + TeacherNonBatching, +} + +impl EpArgs { + fn output_path(&self) -> Option { + if self.in_place { + if self.inputs.len() == 1 { + self.inputs.first().cloned() + } else { + panic!("--in-place requires exactly one input file") + } + } else { + self.output.clone() + } + } +} + +fn main() { + let args = EpArgs::parse(); + + if args.printenv { + ::util::shell_env::print_env(); + return; + } + + let output = args.output_path(); + let command = match args.command { + Some(cmd) => cmd, + None => { + EpArgs::command().print_help().unwrap(); + return; + } + }; + + match &command { + Command::Clean => { + std::fs::remove_dir_all(&*paths::DATA_DIR).unwrap(); + return; + } + _ => {} + } + + let mut examples = read_examples(&args.inputs); + let http_client = Arc::new(ReqwestClient::new()); + let app = Application::headless().with_http_client(http_client); + + app.run(move |cx| { + let app_state = Arc::new(headless::init(cx)); + EditPredictionStore::global(&app_state.client, &app_state.user_store, cx); + + cx.spawn(async move |cx| { + let result = async { + if let Command::Predict(args) = &command { + predict::sync_batches(&args.provider).await?; + } + + let total_examples = examples.len(); + Progress::global().set_total_examples(total_examples); + + let mut grouped_examples = group_examples_by_repo(&mut examples); + let example_batches = grouped_examples.chunks_mut(args.max_parallelism); + + for example_batch in example_batches { + let futures = example_batch.into_iter().map(|repo_examples| async { + for example in repo_examples.iter_mut() { + let result = async { + match &command { + Command::ParseExample => {} + Command::LoadProject => { + run_load_project(example, app_state.clone(), cx.clone()) + .await?; + } + Command::Context => { + run_context_retrieval( + example, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::FormatPrompt(args) => { + run_format_prompt( + example, + args.prompt_format, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Predict(args) => { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Distill => { + run_distill(example).await?; + } + Command::Score(args) | Command::Eval(args) => { + run_scoring(example, &args, app_state.clone(), cx.clone()) + .await?; + } + Command::Clean => { + unreachable!() + } + } + anyhow::Ok(()) + } + .await; + + if let Err(e) = result { + Progress::global().increment_failed(); + let failed_example_path = + FAILED_EXAMPLES_DIR.join(format!("{}.json", example.spec.name)); + app_state + .fs + .write( + &failed_example_path, + &serde_json::to_vec_pretty(&example).unwrap(), + ) + .await + .unwrap(); + let err_path = FAILED_EXAMPLES_DIR + .join(format!("{}_err.txt", example.spec.name)); + app_state + .fs + .write(&err_path, e.to_string().as_bytes()) + .await + .unwrap(); + + let msg = format!( + indoc::indoc! {" + While processing {}: + + {:?} + + Written to: \x1b[36m{}\x1b[0m + + Explore this example data with: + fx \x1b[36m{}\x1b[0m + + Re-run this example with: + cargo run -p edit_prediction_cli -- {} \x1b[36m{}\x1b[0m + "}, + example.spec.name, + e, + err_path.display(), + failed_example_path.display(), + command, + failed_example_path.display(), + ); + if args.failfast || total_examples == 1 { + Progress::global().finalize(); + panic!("{}", msg); + } else { + log::error!("{}", msg); + } + } + } + }); + futures::future::join_all(futures).await; + } + Progress::global().finalize(); + + if args.output.is_some() || !matches!(command, Command::Eval(_)) { + write_examples(&examples, output.as_ref()); + } + + match &command { + Command::Predict(args) => predict::sync_batches(&args.provider).await?, + Command::Eval(_) => score::print_report(&examples), + _ => (), + }; + + anyhow::Ok(()) + } + .await; + + if let Err(e) = result { + panic!("Fatal error: {:?}", e); + } + + let _ = cx.update(|cx| cx.quit()); + }) + .detach(); + }); +} diff --git a/crates/edit_prediction_cli/src/metrics.rs b/crates/edit_prediction_cli/src/metrics.rs new file mode 100644 index 0000000000000000000000000000000000000000..b3e5eb8688724c821953a56c4fe82e67c75e13b6 --- /dev/null +++ b/crates/edit_prediction_cli/src/metrics.rs @@ -0,0 +1,371 @@ +use collections::{HashMap, HashSet}; +use edit_prediction::udiff::DiffLine; +use serde::{Deserialize, Serialize}; + +type Counts = HashMap; +type CountsDelta = HashMap; + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct ClassificationMetrics { + pub true_positives: usize, + pub false_positives: usize, + pub false_negatives: usize, +} + +impl ClassificationMetrics { + pub fn from_sets( + expected: &HashSet, + actual: &HashSet, + ) -> ClassificationMetrics { + let true_positives = expected.intersection(actual).count(); + let false_positives = actual.difference(expected).count(); + let false_negatives = expected.difference(actual).count(); + + ClassificationMetrics { + true_positives, + false_positives, + false_negatives, + } + } + + pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics { + let mut true_positives = 0; + let mut false_positives = 0; + let mut false_negatives = 0; + + for (ngram, &expected_count) in expected { + let actual_count = *actual.get(ngram).unwrap_or(&0); + if actual_count > expected_count { + false_positives += actual_count - expected_count; + } else { + false_negatives += expected_count - actual_count; + } + true_positives += expected_count.min(actual_count); + } + + for (ngram, &actual_count) in actual { + if !expected.contains_key(ngram) { + false_positives += actual_count; + } + } + + ClassificationMetrics { + true_positives, + false_positives, + false_negatives, + } + } + + pub fn aggregate<'a>( + scores: impl Iterator, + ) -> ClassificationMetrics { + let mut true_positives = 0; + let mut false_positives = 0; + let mut false_negatives = 0; + + for score in scores { + true_positives += score.true_positives; + false_positives += score.false_positives; + false_negatives += score.false_negatives; + } + + ClassificationMetrics { + true_positives, + false_positives, + false_negatives, + } + } + + pub fn precision(&self) -> f64 { + if self.true_positives + self.false_positives == 0 { + 0.0 + } else { + self.true_positives as f64 / (self.true_positives + self.false_positives) as f64 + } + } + + pub fn recall(&self) -> f64 { + if self.true_positives + self.false_negatives == 0 { + 0.0 + } else { + self.true_positives as f64 / (self.true_positives + self.false_negatives) as f64 + } + } + + pub fn f1_score(&self) -> f64 { + let recall = self.recall(); + let precision = self.precision(); + if precision + recall == 0.0 { + 0.0 + } else { + 2.0 * precision * recall / (precision + recall) + } + } +} + +pub fn line_match_score( + expected_patch: &[DiffLine], + actual_patch: &[DiffLine], +) -> ClassificationMetrics { + let expected_change_lines = expected_patch + .iter() + .filter(|line| matches!(line, DiffLine::Addition(_) | DiffLine::Deletion(_))) + .map(|line| line.to_string()) + .collect(); + + let actual_change_lines = actual_patch + .iter() + .filter(|line| matches!(line, DiffLine::Addition(_) | DiffLine::Deletion(_))) + .map(|line| line.to_string()) + .collect(); + + ClassificationMetrics::from_sets(&expected_change_lines, &actual_change_lines) +} + +enum ChrfWhitespace { + #[allow(unused)] + Unchanged, + Ignore, +} + +const CHR_F_CHAR_ORDER: usize = 6; +const CHR_F_BETA: f64 = 2.0; +const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Ignore; + +/// Computes a delta-chrF score that compares two sets of edits. +/// +/// This metric works by: +/// 1. Reconstructing original, golden (expected result), and actual texts from diffs +/// 2. Computing n-gram count differences (deltas) between original→golden and original→actual +/// 3. Comparing these deltas to measure how well actual edits match expected edits +pub fn delta_chr_f(expected: &[DiffLine], actual: &[DiffLine]) -> f64 { + // Reconstruct texts from diffs + let mut original_text = String::new(); // state of the text before any edits + let mut golden_text = String::new(); // text after applying golden edits + let mut actual_text = String::new(); // text after applying actual edits + + for line in expected { + match line { + DiffLine::Context(s) => { + original_text.push_str(s); + golden_text.push_str(s); + } + DiffLine::Deletion(s) => { + original_text.push_str(s); + } + DiffLine::Addition(s) => { + golden_text.push_str(s); + } + _ => {} + } + } + + for line in actual { + match line { + DiffLine::Context(s) | DiffLine::Addition(s) => { + actual_text.push_str(s); + } + _ => {} + } + } + + // Edge case + if original_text == golden_text && golden_text == actual_text { + return 100.0; + } + + // Compute the metric + let original_ngrams = chr_f_ngram_counts(&original_text); + let golden_ngrams = chr_f_ngram_counts(&golden_text); + let actual_ngrams = chr_f_ngram_counts(&actual_text); + + let mut total_precision = 0.0; + let mut total_recall = 0.0; + + for order in 0..CHR_F_CHAR_ORDER { + let expected_delta = compute_ngram_delta(&golden_ngrams[order], &original_ngrams[order]); + let actual_delta = compute_ngram_delta(&actual_ngrams[order], &original_ngrams[order]); + + if expected_delta.is_empty() && actual_delta.is_empty() { + total_precision += 1.0; + total_recall += 1.0; + continue; + } + + let expected_counts = ngram_delta_to_counts(&expected_delta); + let actual_counts = ngram_delta_to_counts(&actual_delta); + + let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); + total_precision += score.precision(); + total_recall += score.recall(); + } + + let prec = total_precision / CHR_F_CHAR_ORDER as f64; + let recall = total_recall / CHR_F_CHAR_ORDER as f64; + let f_score = if prec + recall == 0.0 { + 0.0 + } else { + (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall) + }; + + f_score * 100.0 +} + +fn chr_f_ngram_counts(text: &str) -> Vec { + // Ignore whitespace. The original chrF implementation skips all + // whitespace. We should consider compressing multiple consecutive + // spaces into one -- this may reflect our task more closely. + let text = match CHR_F_WHITESPACE { + ChrfWhitespace::Unchanged => text.to_string(), + ChrfWhitespace::Ignore => text + .chars() + .filter(|c| !c.is_whitespace()) + .collect::(), + }; + + (1..=CHR_F_CHAR_ORDER) + .map(|order| count_ngrams(&text, order)) + .collect() +} + +fn compute_ngram_delta(after: &Counts, before: &Counts) -> CountsDelta { + let mut delta = CountsDelta::default(); + + for (ngram, &before_count) in before { + let after_count = *after.get(ngram).unwrap_or(&0); + delta.insert(ngram.clone(), after_count as isize - before_count as isize); + } + + for (ngram, &after_count) in after { + if !before.contains_key(ngram) { + delta.insert(ngram.clone(), after_count as isize); + } + } + + delta +} + +/// Convert negative counts to special deletion tokens. +/// For example, if expected delta is {"foo": -1} and actual delta is {"bar": -1}, +/// we convert it to {"¬foo": +1} and {"¬bar": +1}. This way _not_ deleting "foo" +/// will result in a false negative, and mistakenly deleting "bar" will result in a false positive. +fn ngram_delta_to_counts(delta: &CountsDelta) -> Counts { + let mut counts = Counts::default(); + + for (ngram, &delta) in delta { + if delta > 0 { + counts.insert(ngram.clone(), delta as usize); + } else { + counts.insert(format!("¬{ngram}"), delta.unsigned_abs()); + } + } + + counts +} + +fn count_ngrams(text: &str, n: usize) -> Counts { + let chars: Vec = text.chars().collect(); + let mut counts = Counts::default(); + + for window in chars.windows(n) { + let ngram: String = window.iter().collect(); + *counts.entry(ngram).or_insert(0) += 1; + } + + counts +} + +#[cfg(test)] +mod test { + use super::*; + use edit_prediction::udiff::DiffLine; + + #[test] + fn test_delta_chr_f_perfect_match() { + let diff = vec![ + DiffLine::Context("fn main() {"), + DiffLine::Deletion(" println!(\"Hello\");"), + DiffLine::Addition(" println!(\"Hello, World!\");"), + DiffLine::Context("}"), + ]; + + let score = delta_chr_f(&diff, &diff); + assert!((score - 100.0).abs() < 1e-2); + } + + #[test] + fn test_delta_chr_f_wrong_edit() { + // When the edit is wrong + let expected = vec![ + DiffLine::Context("one "), + DiffLine::Deletion("two "), + DiffLine::Context("three"), + ]; + + let actual = vec![ + DiffLine::Context("one "), + DiffLine::Context("two "), + DiffLine::Deletion("three"), + DiffLine::Addition("four"), + ]; + + // Then the score should be low + let score = delta_chr_f(&expected, &actual); + assert!(score > 20.0 && score < 40.0); + } + + #[test] + fn test_delta_chr_f_partial_match() { + let expected = vec![ + DiffLine::Deletion("let x = 42;"), + DiffLine::Addition("let x = 100;"), + ]; + + let actual = vec![ + DiffLine::Deletion("let x = 42;"), + DiffLine::Addition("let x = 99;"), + ]; + + // We got the edit location right, but the replacement text is wrong. + // Deleted ngrams will match, bringing the score somewhere in the middle. + let score = delta_chr_f(&expected, &actual); + assert!(score > 40.0 && score < 60.0); + } + + #[test] + fn test_delta_chr_f_missed_edit() { + // When predictions makes no changes + let expected = vec![ + DiffLine::Context("prefix "), + DiffLine::Deletion("old"), + DiffLine::Addition("new"), + DiffLine::Context(" suffix"), + ]; + + let actual = vec![ + DiffLine::Context("prefix "), + DiffLine::Context("old"), + DiffLine::Context(" suffix"), + ]; + + // Then the score should be low (all expected changes are false negatives) + let score = delta_chr_f(&expected, &actual); + assert!(score < 20.0); + } + + #[test] + fn test_delta_chr_f_extra_edit() { + // When adding unexpected content + let expected = vec![DiffLine::Context("hello"), DiffLine::Context("world")]; + + let actual = vec![ + DiffLine::Context("hello"), + DiffLine::Addition("extra"), + DiffLine::Context("world"), + ]; + + // Then the score should be low (all actual changes are false positives) + let score = delta_chr_f(&expected, &actual); + assert!(score < 20.0); + } +} diff --git a/crates/edit_prediction_cli/src/paths.rs b/crates/edit_prediction_cli/src/paths.rs new file mode 100644 index 0000000000000000000000000000000000000000..e5d420d0e3dbeda9c50b8e5a3683238149dbc604 --- /dev/null +++ b/crates/edit_prediction_cli/src/paths.rs @@ -0,0 +1,27 @@ +use std::{ + path::{Path, PathBuf}, + sync::LazyLock, +}; + +pub static DATA_DIR: LazyLock = LazyLock::new(|| { + let dir = dirs::home_dir().unwrap().join(".zed_ep"); + ensure_dir(&dir) +}); +pub static CACHE_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("cache"))); +pub static REPOS_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("repos"))); +pub static WORKTREES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&DATA_DIR.join("worktrees"))); +pub static RUN_DIR: LazyLock = LazyLock::new(|| { + DATA_DIR + .join("runs") + .join(chrono::Local::now().format("%d-%m-%y-%H_%M_%S").to_string()) +}); +pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = LazyLock::new(|| DATA_DIR.join("latest")); +pub static LLM_CACHE_DB: LazyLock = LazyLock::new(|| CACHE_DIR.join("llm_cache.sqlite")); +pub static FAILED_EXAMPLES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&RUN_DIR.join("failed"))); + +fn ensure_dir(path: &Path) -> PathBuf { + std::fs::create_dir_all(path).expect("Failed to create directory"); + path.to_path_buf() +} diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs new file mode 100644 index 0000000000000000000000000000000000000000..aa93c5415dea091164a68b76a34242697aac70e3 --- /dev/null +++ b/crates/edit_prediction_cli/src/predict.rs @@ -0,0 +1,291 @@ +use crate::{ + PredictionProvider, PromptFormat, + anthropic_client::AnthropicClient, + example::{Example, ExamplePrediction}, + format_prompt::{TeacherPrompt, run_format_prompt}, + headless::EpAppState, + load_project::run_load_project, + paths::{LATEST_EXAMPLE_RUN_DIR, RUN_DIR}, + progress::{InfoStyle, Progress, Step}, + retrieve_context::run_context_retrieval, +}; +use anyhow::Context as _; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, future::Shared}; +use gpui::{AppContext as _, AsyncApp, Task}; +use std::{ + fs, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicUsize, Ordering::SeqCst}, + }, +}; + +pub async fn run_prediction( + example: &mut Example, + provider: Option, + repetition_count: usize, + app_state: Arc, + mut cx: AsyncApp, +) -> anyhow::Result<()> { + if !example.predictions.is_empty() { + return Ok(()); + } + + let provider = provider.context("provider is required")?; + + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; + + if matches!( + provider, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching + ) { + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); + + if example.prompt.is_none() { + run_format_prompt(example, PromptFormat::Teacher, app_state.clone(), cx).await?; + } + + let batched = matches!(provider, PredictionProvider::Teacher); + return predict_anthropic(example, repetition_count, batched).await; + } + + run_load_project(example, app_state.clone(), cx.clone()).await?; + + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); + + if matches!( + provider, + PredictionProvider::Zeta1 | PredictionProvider::Zeta2 + ) { + static AUTHENTICATED: OnceLock>> = OnceLock::new(); + AUTHENTICATED + .get_or_init(|| { + let client = app_state.client.clone(); + cx.spawn(async move |cx| { + if let Err(e) = client.sign_in_with_optional_connect(true, cx).await { + eprintln!("Authentication failed: {}", e); + } + }) + .shared() + }) + .clone() + .await; + } + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + ep_store.update(&mut cx, |store, _cx| { + let model = match provider { + PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, + PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, + PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, + PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching => { + unreachable!() + } + }; + store.set_edit_prediction_model(model); + })?; + let state = example.state.as_ref().context("state must be set")?; + let run_dir = RUN_DIR.join(&example.spec.name); + + let updated_example = Arc::new(Mutex::new(example.clone())); + let current_run_ix = Arc::new(AtomicUsize::new(0)); + + let mut debug_rx = + ep_store.update(&mut cx, |store, cx| store.debug_info(&state.project, cx))?; + let debug_task = cx.background_spawn({ + let updated_example = updated_example.clone(); + let current_run_ix = current_run_ix.clone(); + let run_dir = run_dir.clone(); + async move { + while let Some(event) = debug_rx.next().await { + let run_ix = current_run_ix.load(SeqCst); + let mut updated_example = updated_example.lock().unwrap(); + + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", run_ix)) + } else { + run_dir.clone() + }; + + match event { + DebugEvent::EditPredictionStarted(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(prompt) = request.prompt { + fs::write(run_dir.join("prediction_prompt.md"), &prompt)?; + } + } + DebugEvent::EditPredictionFinished(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(output) = request.model_output { + fs::write(run_dir.join("prediction_response.md"), &output)?; + updated_example + .predictions + .last_mut() + .unwrap() + .actual_output = output; + } + if run_ix >= repetition_count { + break; + } + } + _ => {} + } + } + anyhow::Ok(()) + } + }); + + for ix in 0..repetition_count { + current_run_ix.store(ix, SeqCst); + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", ix)) + } else { + run_dir.clone() + }; + + fs::create_dir_all(&run_dir)?; + if LATEST_EXAMPLE_RUN_DIR.is_symlink() { + fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR)?; + } + #[cfg(unix)] + std::os::unix::fs::symlink(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; + + updated_example + .lock() + .unwrap() + .predictions + .push(ExamplePrediction { + actual_patch: String::new(), + actual_output: String::new(), + provider, + }); + + let prediction = ep_store + .update(&mut cx, |store, cx| { + store.request_prediction( + &state.project, + &state.buffer, + state.cursor_position, + cloud_llm_client::PredictEditsRequestTrigger::Cli, + cx, + ) + })? + .await?; + + let actual_patch = prediction + .and_then(|prediction| { + let prediction = prediction.prediction.ok()?; + prediction.edit_preview.as_unified_diff(&prediction.edits) + }) + .unwrap_or_default(); + + let has_prediction = !actual_patch.is_empty(); + + updated_example + .lock() + .unwrap() + .predictions + .last_mut() + .unwrap() + .actual_patch = actual_patch; + + if ix == repetition_count - 1 { + let (info, style) = if has_prediction { + ("predicted", InfoStyle::Normal) + } else { + ("no prediction", InfoStyle::Warning) + }; + _step_progress.set_info(info, style); + } + } + + ep_store.update(&mut cx, |store, _| { + store.remove_project(&state.project); + })?; + debug_task.await?; + + *example = Arc::into_inner(updated_example) + .ok_or_else(|| anyhow::anyhow!("Failed to unwrap Arc"))? + .into_inner() + .map_err(|_| anyhow::anyhow!("Failed to unwrap Mutex"))?; + Ok(()) +} + +async fn predict_anthropic( + example: &mut Example, + _repetition_count: usize, + batched: bool, +) -> anyhow::Result<()> { + let llm_model_name = "claude-sonnet-4-5"; + let max_tokens = 16384; + let llm_client = if batched { + AnthropicClient::batch(&crate::paths::LLM_CACHE_DB.as_ref()) + } else { + AnthropicClient::plain() + }; + let llm_client = llm_client.context("Failed to create LLM client")?; + + let prompt = example.prompt.as_ref().context("Prompt is required")?; + + let messages = vec![anthropic::Message { + role: anthropic::Role::User, + content: vec![anthropic::RequestContent::Text { + text: prompt.input.clone(), + cache_control: None, + }], + }]; + + let Some(response) = llm_client + .generate(llm_model_name, max_tokens, messages) + .await? + else { + // Request stashed for batched processing + return Ok(()); + }; + + let actual_output = response + .content + .into_iter() + .filter_map(|content| match content { + anthropic::ResponseContent::Text { text } => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); + + let actual_patch = TeacherPrompt::parse(example, &actual_output)?; + + let prediction = ExamplePrediction { + actual_patch, + actual_output, + provider: PredictionProvider::Teacher, + }; + + example.predictions.push(prediction); + Ok(()) +} + +pub async fn sync_batches(provider: &PredictionProvider) -> anyhow::Result<()> { + match provider { + PredictionProvider::Teacher => { + let cache_path = crate::paths::LLM_CACHE_DB.as_ref(); + let llm_client = + AnthropicClient::batch(cache_path).context("Failed to create LLM client")?; + llm_client + .sync_batches() + .await + .context("Failed to sync batches")?; + } + _ => (), + }; + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/progress.rs b/crates/edit_prediction_cli/src/progress.rs new file mode 100644 index 0000000000000000000000000000000000000000..ddc710f202cc98e5932c234cb6bebcc93b28171c --- /dev/null +++ b/crates/edit_prediction_cli/src/progress.rs @@ -0,0 +1,508 @@ +use std::{ + borrow::Cow, + collections::HashMap, + io::{IsTerminal, Write}, + sync::{Arc, Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use log::{Level, Log, Metadata, Record}; + +pub struct Progress { + inner: Mutex, +} + +struct ProgressInner { + completed: Vec, + in_progress: HashMap, + is_tty: bool, + terminal_width: usize, + max_example_name_len: usize, + status_lines_displayed: usize, + total_examples: usize, + failed_examples: usize, + last_line_is_logging: bool, +} + +#[derive(Clone)] +struct InProgressTask { + step: Step, + started_at: Instant, + substatus: Option, + info: Option<(String, InfoStyle)>, +} + +struct CompletedTask { + step: Step, + example_name: String, + duration: Duration, + info: Option<(String, InfoStyle)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Step { + LoadProject, + Context, + FormatPrompt, + Predict, + Score, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InfoStyle { + Normal, + Warning, +} + +impl Step { + pub fn label(&self) -> &'static str { + match self { + Step::LoadProject => "Load", + Step::Context => "Context", + Step::FormatPrompt => "Format", + Step::Predict => "Predict", + Step::Score => "Score", + } + } + + fn color_code(&self) -> &'static str { + match self { + Step::LoadProject => "\x1b[33m", + Step::Context => "\x1b[35m", + Step::FormatPrompt => "\x1b[34m", + Step::Predict => "\x1b[32m", + Step::Score => "\x1b[31m", + } + } +} + +static GLOBAL: OnceLock> = OnceLock::new(); +static LOGGER: ProgressLogger = ProgressLogger; + +const MARGIN: usize = 4; +const MAX_STATUS_LINES: usize = 10; + +impl Progress { + /// Returns the global Progress instance, initializing it if necessary. + pub fn global() -> Arc { + GLOBAL + .get_or_init(|| { + let progress = Arc::new(Self { + inner: Mutex::new(ProgressInner { + completed: Vec::new(), + in_progress: HashMap::new(), + is_tty: std::io::stderr().is_terminal(), + terminal_width: get_terminal_width(), + max_example_name_len: 0, + status_lines_displayed: 0, + total_examples: 0, + failed_examples: 0, + last_line_is_logging: false, + }), + }); + let _ = log::set_logger(&LOGGER); + log::set_max_level(log::LevelFilter::Error); + progress + }) + .clone() + } + + pub fn set_total_examples(&self, total: usize) { + let mut inner = self.inner.lock().unwrap(); + inner.total_examples = total; + } + + pub fn increment_failed(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.failed_examples += 1; + } + + /// Prints a message to stderr, clearing and redrawing status lines to avoid corruption. + /// This should be used for any output that needs to appear above the status lines. + fn log(&self, message: &str) { + let mut inner = self.inner.lock().unwrap(); + Self::clear_status_lines(&mut inner); + + if !inner.last_line_is_logging { + let reset = "\x1b[0m"; + let dim = "\x1b[2m"; + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); + eprintln!("{dim}{divider}{reset}"); + inner.last_line_is_logging = true; + } + + eprintln!("{}", message); + } + + pub fn start(self: &Arc, step: Step, example_name: &str) -> StepProgress { + let mut inner = self.inner.lock().unwrap(); + + Self::clear_status_lines(&mut inner); + + inner.max_example_name_len = inner.max_example_name_len.max(example_name.len()); + inner.in_progress.insert( + example_name.to_string(), + InProgressTask { + step, + started_at: Instant::now(), + substatus: None, + info: None, + }, + ); + + Self::print_status_lines(&mut inner); + + StepProgress { + progress: self.clone(), + step, + example_name: example_name.to_string(), + } + } + + fn finish(&self, step: Step, example_name: &str) { + let mut inner = self.inner.lock().unwrap(); + + let Some(task) = inner.in_progress.remove(example_name) else { + return; + }; + + if task.step == step { + inner.completed.push(CompletedTask { + step: task.step, + example_name: example_name.to_string(), + duration: task.started_at.elapsed(), + info: task.info, + }); + + Self::clear_status_lines(&mut inner); + Self::print_logging_closing_divider(&mut inner); + Self::print_completed(&inner, inner.completed.last().unwrap()); + Self::print_status_lines(&mut inner); + } else { + inner.in_progress.insert(example_name.to_string(), task); + } + } + + fn print_logging_closing_divider(inner: &mut ProgressInner) { + if inner.last_line_is_logging { + let reset = "\x1b[0m"; + let dim = "\x1b[2m"; + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); + eprintln!("{dim}{divider}{reset}"); + inner.last_line_is_logging = false; + } + } + + fn clear_status_lines(inner: &mut ProgressInner) { + if inner.is_tty && inner.status_lines_displayed > 0 { + // Move up and clear each line we previously displayed + for _ in 0..inner.status_lines_displayed { + eprint!("\x1b[A\x1b[K"); + } + let _ = std::io::stderr().flush(); + inner.status_lines_displayed = 0; + } + } + + fn print_completed(inner: &ProgressInner, task: &CompletedTask) { + let duration = format_duration(task.duration); + let name_width = inner.max_example_name_len; + + if inner.is_tty { + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + let dim = "\x1b[2m"; + + let yellow = "\x1b[33m"; + let info_part = task + .info + .as_ref() + .map(|(s, style)| { + if *style == InfoStyle::Warning { + format!("{yellow}{s}{reset}") + } else { + s.to_string() + } + }) + .unwrap_or_default(); + + let prefix = format!( + "{bold}{color}{label:>12}{reset} {name:12} {name: 0 { + format!(" {} failed ", failed_count) + } else { + String::new() + }; + + let range_label = format!( + " {}/{}/{} ", + done_count, in_progress_count, inner.total_examples + ); + + // Print a divider line with failed count on left, range label on right + let failed_visible_len = strip_ansi_len(&failed_label); + let range_visible_len = range_label.len(); + let middle_divider_len = inner + .terminal_width + .saturating_sub(MARGIN * 2) + .saturating_sub(failed_visible_len) + .saturating_sub(range_visible_len); + let left_divider = "─".repeat(MARGIN); + let middle_divider = "─".repeat(middle_divider_len); + let right_divider = "─".repeat(MARGIN); + eprintln!( + "{dim}{left_divider}{reset}{failed_label}{dim}{middle_divider}{reset}{range_label}{dim}{right_divider}{reset}" + ); + + let mut tasks: Vec<_> = inner.in_progress.iter().collect(); + tasks.sort_by_key(|(name, _)| *name); + + let total_tasks = tasks.len(); + let mut lines_printed = 0; + + for (name, task) in tasks.iter().take(MAX_STATUS_LINES) { + let elapsed = format_duration(task.started_at.elapsed()); + let substatus_part = task + .substatus + .as_ref() + .map(|s| truncate_with_ellipsis(s, 30)) + .unwrap_or_default(); + + let step_label = task.step.label(); + let step_color = task.step.color_code(); + let name_width = inner.max_example_name_len; + + let prefix = format!( + "{bold}{step_color}{step_label:>12}{reset} {name: MAX_STATUS_LINES { + let remaining = total_tasks - MAX_STATUS_LINES; + eprintln!("{:>12} +{remaining} more", ""); + lines_printed += 1; + } + + inner.status_lines_displayed = lines_printed + 1; // +1 for the divider line + let _ = std::io::stderr().flush(); + } + + pub fn finalize(&self) { + let mut inner = self.inner.lock().unwrap(); + Self::clear_status_lines(&mut inner); + + // Print summary if there were failures + if inner.failed_examples > 0 { + let total_processed = inner.completed.len() + inner.failed_examples; + let percentage = if total_processed > 0 { + inner.failed_examples as f64 / total_processed as f64 * 100.0 + } else { + 0.0 + }; + eprintln!( + "\n{} of {} examples failed ({:.1}%)", + inner.failed_examples, total_processed, percentage + ); + } + } +} + +pub struct StepProgress { + progress: Arc, + step: Step, + example_name: String, +} + +impl StepProgress { + pub fn set_substatus(&self, substatus: impl Into>) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.substatus = Some(substatus.into().into_owned()); + Progress::clear_status_lines(&mut inner); + Progress::print_status_lines(&mut inner); + } + } + + pub fn clear_substatus(&self) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.substatus = None; + Progress::clear_status_lines(&mut inner); + Progress::print_status_lines(&mut inner); + } + } + + pub fn set_info(&self, info: impl Into, style: InfoStyle) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.info = Some((info.into(), style)); + } + } +} + +impl Drop for StepProgress { + fn drop(&mut self) { + self.progress.finish(self.step, &self.example_name); + } +} + +struct ProgressLogger; + +impl Log for ProgressLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= Level::Info + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + + let level_color = match record.level() { + Level::Error => "\x1b[31m", + Level::Warn => "\x1b[33m", + Level::Info => "\x1b[32m", + Level::Debug => "\x1b[34m", + Level::Trace => "\x1b[35m", + }; + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + + let level_label = match record.level() { + Level::Error => "Error", + Level::Warn => "Warn", + Level::Info => "Info", + Level::Debug => "Debug", + Level::Trace => "Trace", + }; + + let message = format!( + "{bold}{level_color}{level_label:>12}{reset} {}", + record.args() + ); + + if let Some(progress) = GLOBAL.get() { + progress.log(&message); + } else { + eprintln!("{}", message); + } + } + + fn flush(&self) { + let _ = std::io::stderr().flush(); + } +} + +#[cfg(unix)] +fn get_terminal_width() -> usize { + unsafe { + let mut winsize: libc::winsize = std::mem::zeroed(); + if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut winsize) == 0 + && winsize.ws_col > 0 + { + winsize.ws_col as usize + } else { + 80 + } + } +} + +#[cfg(not(unix))] +fn get_terminal_width() -> usize { + 80 +} + +fn strip_ansi_len(s: &str) -> usize { + let mut len = 0; + let mut in_escape = false; + for c in s.chars() { + if c == '\x1b' { + in_escape = true; + } else if in_escape { + if c == 'm' { + in_escape = false; + } + } else { + len += 1; + } + } + len +} + +fn truncate_with_ellipsis(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}…", &s[..max_len.saturating_sub(1)]) + } +} + +fn format_duration(duration: Duration) -> String { + const MINUTE_IN_MILLIS: f32 = 60. * 1000.; + + let millis = duration.as_millis() as f32; + if millis < 1000.0 { + format!("{}ms", millis) + } else if millis < MINUTE_IN_MILLIS { + format!("{:.1}s", millis / 1_000.0) + } else { + format!("{:.1}m", millis / MINUTE_IN_MILLIS) + } +} diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..abba4504edc6c0733ffd8c0677e2e3304d8100fa --- /dev/null +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -0,0 +1,192 @@ +use crate::{ + example::{Example, ExampleContext}, + headless::EpAppState, + load_project::run_load_project, + progress::{InfoStyle, Progress, Step, StepProgress}, +}; +use anyhow::Context as _; +use collections::HashSet; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, channel::mpsc}; +use gpui::{AsyncApp, Entity}; +use language::Buffer; +use project::Project; +use std::sync::Arc; +use std::time::Duration; + +pub async fn run_context_retrieval( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> anyhow::Result<()> { + if example.context.is_some() { + return Ok(()); + } + + run_load_project(example, app_state.clone(), cx.clone()).await?; + + let step_progress: Arc = Progress::global() + .start(Step::Context, &example.spec.name) + .into(); + + let state = example.state.as_ref().unwrap(); + let project = state.project.clone(); + + let _lsp_handle = project.update(&mut cx, |project, cx| { + project.register_buffer_with_language_servers(&state.buffer, cx) + })?; + wait_for_language_servers_to_start(&project, &state.buffer, &step_progress, &mut cx).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let mut events = ep_store.update(&mut cx, |store, cx| { + store.register_buffer(&state.buffer, &project, cx); + store.set_use_context(true); + store.refresh_context(&project, &state.buffer, state.cursor_position, cx); + store.debug_info(&project, cx) + })?; + + while let Some(event) = events.next().await { + match event { + DebugEvent::ContextRetrievalFinished(_) => { + break; + } + _ => {} + } + } + + let context_files = + ep_store.update(&mut cx, |store, cx| store.context_for_project(&project, cx))?; + + let excerpt_count: usize = context_files.iter().map(|f| f.excerpts.len()).sum(); + step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal); + + example.context = Some(ExampleContext { + files: context_files, + }); + Ok(()) +} + +async fn wait_for_language_servers_to_start( + project: &Entity, + buffer: &Entity, + step_progress: &Arc, + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + let lsp_store = project.read_with(cx, |project, _| project.lsp_store())?; + + let (language_server_ids, mut starting_language_server_ids) = buffer + .update(cx, |buffer, cx| { + lsp_store.update(cx, |lsp_store, cx| { + let ids = lsp_store.language_servers_for_local_buffer(buffer, cx); + let starting_ids = ids + .iter() + .copied() + .filter(|id| !lsp_store.language_server_statuses.contains_key(&id)) + .collect::>(); + (ids, starting_ids) + }) + }) + .unwrap_or_default(); + + step_progress.set_substatus(format!("waiting for {} LSPs", language_server_ids.len())); + + let timeout = cx + .background_executor() + .timer(Duration::from_secs(60 * 5)) + .shared(); + + let (mut tx, mut rx) = mpsc::channel(language_server_ids.len()); + let added_subscription = cx.subscribe(project, { + let step_progress = step_progress.clone(); + move |_, event, _| match event { + project::Event::LanguageServerAdded(language_server_id, name, _) => { + step_progress.set_substatus(format!("LSP started: {}", name)); + tx.try_send(*language_server_id).ok(); + } + _ => {} + } + }); + + while !starting_language_server_ids.is_empty() { + futures::select! { + language_server_id = rx.next() => { + if let Some(id) = language_server_id { + starting_language_server_ids.remove(&id); + } + }, + _ = timeout.clone().fuse() => { + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); + } + } + } + + drop(added_subscription); + + if !language_server_ids.is_empty() { + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .detach(); + } + + let (mut tx, mut rx) = mpsc::channel(language_server_ids.len()); + let subscriptions = [ + cx.subscribe(&lsp_store, { + let step_progress = step_progress.clone(); + move |_, event, _| { + if let project::LspStoreEvent::LanguageServerUpdate { + message: + client::proto::update_language_server::Variant::WorkProgress( + client::proto::LspWorkProgress { + message: Some(message), + .. + }, + ), + .. + } = event + { + step_progress.set_substatus(message.clone()); + } + } + }), + cx.subscribe(project, { + let step_progress = step_progress.clone(); + move |_, event, cx| match event { + project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + let lsp_store = lsp_store.read(cx); + let name = lsp_store + .language_server_adapter_for_id(*language_server_id) + .unwrap() + .name(); + step_progress.set_substatus(format!("LSP idle: {}", name)); + tx.try_send(*language_server_id).ok(); + } + _ => {} + } + }), + ]; + + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await?; + + let mut pending_language_server_ids = HashSet::from_iter(language_server_ids.into_iter()); + while !pending_language_server_ids.is_empty() { + futures::select! { + language_server_id = rx.next() => { + if let Some(id) = language_server_id { + pending_language_server_ids.remove(&id); + } + }, + _ = timeout.clone().fuse() => { + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); + } + } + } + + drop(subscriptions); + step_progress.clear_substatus(); + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs new file mode 100644 index 0000000000000000000000000000000000000000..7b507e6d19c943de92eb0b22c7d24d4026789fed --- /dev/null +++ b/crates/edit_prediction_cli/src/score.rs @@ -0,0 +1,123 @@ +use crate::{ + PredictArgs, + example::{Example, ExampleScore}, + headless::EpAppState, + metrics::{self, ClassificationMetrics}, + predict::run_prediction, + progress::{Progress, Step}, +}; +use edit_prediction::udiff::DiffLine; +use gpui::AsyncApp; +use std::sync::Arc; + +pub async fn run_scoring( + example: &mut Example, + args: &PredictArgs, + app_state: Arc, + cx: AsyncApp, +) -> anyhow::Result<()> { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state, + cx, + ) + .await?; + + let _progress = Progress::global().start(Step::Score, &example.spec.name); + + let expected_patch = parse_patch(&example.spec.expected_patch); + + let mut scores = vec![]; + + for pred in &example.predictions { + let actual_patch = parse_patch(&pred.actual_patch); + let line_match = metrics::line_match_score(&expected_patch, &actual_patch); + let delta_chr_f = metrics::delta_chr_f(&expected_patch, &actual_patch) as f32; + + scores.push(ExampleScore { + delta_chr_f, + line_match, + }); + } + + example.score = scores; + Ok(()) +} + +fn parse_patch(patch: &str) -> Vec> { + patch.lines().map(DiffLine::parse).collect() +} + +pub fn print_report(examples: &[Example]) { + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>10} {:>8} {:>8} {:>10}", + "Example name", "TP", "FP", "FN", "Precision", "Recall", "F1", "DeltaChrF" + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + let mut all_line_match_scores = Vec::new(); + let mut all_delta_chr_f_scores = Vec::new(); + + for example in examples { + for score in example.score.iter() { + let line_match = &score.line_match; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + truncate_name(&example.spec.name, 30), + line_match.true_positives, + line_match.false_positives, + line_match.false_negatives, + line_match.precision() * 100.0, + line_match.recall() * 100.0, + line_match.f1_score() * 100.0, + score.delta_chr_f + ); + + all_line_match_scores.push(line_match.clone()); + all_delta_chr_f_scores.push(score.delta_chr_f); + } + } + + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + if !all_line_match_scores.is_empty() { + let total_line_match = ClassificationMetrics::aggregate(all_line_match_scores.iter()); + let avg_delta_chr_f: f32 = + all_delta_chr_f_scores.iter().sum::() / all_delta_chr_f_scores.len() as f32; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + "TOTAL", + total_line_match.true_positives, + total_line_match.false_positives, + total_line_match.false_negatives, + total_line_match.precision() * 100.0, + total_line_match.recall() * 100.0, + total_line_match.f1_score() * 100.0, + avg_delta_chr_f + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + } + + eprintln!("\n"); +} + +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else { + format!("{}...", &name[..max_len - 3]) + } +} diff --git a/crates/edit_prediction_cli/src/teacher.prompt.md b/crates/edit_prediction_cli/src/teacher.prompt.md new file mode 100644 index 0000000000000000000000000000000000000000..d629152da6739ec1d603857f6a9ee556c8986fe8 --- /dev/null +++ b/crates/edit_prediction_cli/src/teacher.prompt.md @@ -0,0 +1,53 @@ +# Instructions + +You are a code completion assistant helping a programmer finish their work. Your task is to: + +1. Analyze the edit history to understand what the programmer is trying to achieve +2. Identify any incomplete refactoring or changes that need to be finished +3. Make the remaining edits that a human programmer would logically make next (by rewriting the corresponding code sections) +4. Apply systematic changes consistently across the entire codebase - if you see a pattern starting, complete it everywhere. + +Focus on: +- Understanding the intent behind the changes (e.g., improving error handling, refactoring APIs, fixing bugs) +- Completing any partially-applied changes across the codebase +- Ensuring consistency with the programming style and patterns already established +- Making edits that maintain or improve code quality +- If the programmer started refactoring one instance of a pattern, find and update ALL similar instances +- Don't write a lot of code if you're not sure what to do + +Rules: +- Do not just mechanically apply patterns - reason about what changes make sense given the context and the programmer's apparent goals. +- Do not just fix syntax errors - look for the broader refactoring pattern and apply it systematically throughout the code. +- Keep existing formatting unless it's absolutely necessary + +Input format: +- You receive small code fragments called context (structs, field definitions, function signatures, etc.). They may or may not be relevant. +- Never modify the context code. +- You also receive a code snippet between <|editable_region_start|> and <|editable_region_end|>. This is the editable region. +- The cursor position is marked with <|user_cursor|>. + +Output format: +- Return the entire editable region, applying any edits you make. +- Remove the <|user_cursor|> marker. +- Wrap the edited code in a block of exactly five backticks. + +Output example: +````` + // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass + if let Some(socket) = &args.askpass {{ + askpass::main(socket); + return Ok(()); + }} +````` + +## User Edits History + +{{edit_history}} + +## Code Context + +{{context}} + +## Editable region + +{{editable_region}} diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index 6976831b8cbbe2b998f713ff65f1585f28fc3005..731ffc85d159e285ad497c29fba2f74179d4149b 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -12,41 +12,33 @@ workspace = true path = "src/edit_prediction_context.rs" [dependencies] +parking_lot.workspace = true anyhow.workspace = true -arrayvec.workspace = true cloud_llm_client.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true -hashbrown.workspace = true -indoc.workspace = true -itertools.workspace = true language.workspace = true -log.workspace = true -ordered-float.workspace = true -postage.workspace = true +lsp.workspace = true project.workspace = true -regex.workspace = true +log.workspace = true serde.workspace = true -slotmap.workspace = true -strum.workspace = true -text.workspace = true +smallvec.workspace = true tree-sitter.workspace = true util.workspace = true +zeta_prompt.workspace = true [dev-dependencies] -clap.workspace = true +env_logger.workspace = true +indoc.workspace = true futures.workspace = true gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true language = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = {workspace= true, features = ["test-support"]} serde_json.workspace = true settings = {workspace= true, features = ["test-support"]} text = { workspace = true, features = ["test-support"] } -tree-sitter-c.workspace = true -tree-sitter-cpp.workspace = true -tree-sitter-go.workspace = true util = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/edit_prediction_context/src/assemble_excerpts.rs b/crates/edit_prediction_context/src/assemble_excerpts.rs new file mode 100644 index 0000000000000000000000000000000000000000..e337211cf90f0e4fbcb481f836e512b1ceb6477f --- /dev/null +++ b/crates/edit_prediction_context/src/assemble_excerpts.rs @@ -0,0 +1,156 @@ +use language::{BufferSnapshot, OffsetRangeExt as _, Point}; +use std::ops::Range; +use zeta_prompt::RelatedExcerpt; + +#[cfg(not(test))] +const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 512; +#[cfg(test)] +const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 24; + +pub fn assemble_excerpts( + buffer: &BufferSnapshot, + mut input_ranges: Vec>, +) -> Vec { + merge_ranges(&mut input_ranges); + + let mut outline_ranges = Vec::new(); + let outline_items = buffer.outline_items_as_points_containing(0..buffer.len(), false, None); + let mut outline_ix = 0; + for input_range in &mut input_ranges { + *input_range = clip_range_to_lines(input_range, false, buffer); + + while let Some(outline_item) = outline_items.get(outline_ix) { + let item_range = clip_range_to_lines(&outline_item.range, false, buffer); + + if item_range.start > input_range.start { + break; + } + + if item_range.end > input_range.start { + let body_range = outline_item + .body_range(buffer) + .map(|body| clip_range_to_lines(&body, true, buffer)) + .filter(|body_range| { + body_range.to_offset(buffer).len() > MAX_OUTLINE_ITEM_BODY_SIZE + }); + + add_outline_item( + item_range.clone(), + body_range.clone(), + buffer, + &mut outline_ranges, + ); + + if let Some(body_range) = body_range + && input_range.start < body_range.start + { + let mut child_outline_ix = outline_ix + 1; + while let Some(next_outline_item) = outline_items.get(child_outline_ix) { + if next_outline_item.range.end > body_range.end { + break; + } + if next_outline_item.depth == outline_item.depth + 1 { + let next_item_range = + clip_range_to_lines(&next_outline_item.range, false, buffer); + + add_outline_item( + next_item_range, + next_outline_item + .body_range(buffer) + .map(|body| clip_range_to_lines(&body, true, buffer)), + buffer, + &mut outline_ranges, + ); + } + child_outline_ix += 1; + } + } + } + + outline_ix += 1; + } + } + + input_ranges.extend_from_slice(&outline_ranges); + merge_ranges(&mut input_ranges); + + input_ranges + .into_iter() + .map(|range| RelatedExcerpt { + row_range: range.start.row..range.end.row, + text: buffer.text_for_range(range).collect(), + }) + .collect() +} + +fn clip_range_to_lines( + range: &Range, + inward: bool, + buffer: &BufferSnapshot, +) -> Range { + let mut range = range.clone(); + if inward { + if range.start.column > 0 { + range.start.column = buffer.line_len(range.start.row); + } + range.end.column = 0; + } else { + range.start.column = 0; + if range.end.column > 0 { + range.end.column = buffer.line_len(range.end.row); + } + } + range +} + +fn add_outline_item( + mut item_range: Range, + body_range: Option>, + buffer: &BufferSnapshot, + outline_ranges: &mut Vec>, +) { + if let Some(mut body_range) = body_range { + if body_range.start.column > 0 { + body_range.start.column = buffer.line_len(body_range.start.row); + } + body_range.end.column = 0; + + let head_range = item_range.start..body_range.start; + if head_range.start < head_range.end { + outline_ranges.push(head_range); + } + + let tail_range = body_range.end..item_range.end; + if tail_range.start < tail_range.end { + outline_ranges.push(tail_range); + } + } else { + item_range.start.column = 0; + item_range.end.column = buffer.line_len(item_range.end.row); + outline_ranges.push(item_range); + } +} + +pub fn merge_ranges(ranges: &mut Vec>) { + ranges.sort_unstable_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + + let mut index = 1; + while index < ranges.len() { + let mut prev_range_end = ranges[index - 1].end; + if prev_range_end.column > 0 { + prev_range_end += Point::new(1, 0); + } + + if (prev_range_end + Point::new(1, 0)) + .cmp(&ranges[index].start) + .is_ge() + { + let removed = ranges.remove(index); + if removed.end.cmp(&ranges[index - 1].end).is_gt() { + ranges[index - 1].end = removed.end; + } + } else { + index += 1; + } + } +} diff --git a/crates/edit_prediction_context/src/declaration.rs b/crates/edit_prediction_context/src/declaration.rs deleted file mode 100644 index cc32640425ecc563b1f24a6c695be1c13199cd73..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/declaration.rs +++ /dev/null @@ -1,350 +0,0 @@ -use cloud_llm_client::predict_edits_v3::{self, Line}; -use language::{Language, LanguageId}; -use project::ProjectEntryId; -use std::ops::Range; -use std::sync::Arc; -use std::{borrow::Cow, path::Path}; -use text::{Bias, BufferId, Rope}; -use util::paths::{path_ends_with, strip_path_suffix}; -use util::rel_path::RelPath; - -use crate::outline::OutlineDeclaration; - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct Identifier { - pub name: Arc, - pub language_id: LanguageId, -} - -slotmap::new_key_type! { - pub struct DeclarationId; -} - -#[derive(Debug, Clone)] -pub enum Declaration { - File { - project_entry_id: ProjectEntryId, - declaration: FileDeclaration, - cached_path: CachedDeclarationPath, - }, - Buffer { - project_entry_id: ProjectEntryId, - buffer_id: BufferId, - rope: Rope, - declaration: BufferDeclaration, - cached_path: CachedDeclarationPath, - }, -} - -const ITEM_TEXT_TRUNCATION_LENGTH: usize = 1024; - -impl Declaration { - pub fn identifier(&self) -> &Identifier { - match self { - Declaration::File { declaration, .. } => &declaration.identifier, - Declaration::Buffer { declaration, .. } => &declaration.identifier, - } - } - - pub fn parent(&self) -> Option { - match self { - Declaration::File { declaration, .. } => declaration.parent, - Declaration::Buffer { declaration, .. } => declaration.parent, - } - } - - pub fn as_buffer(&self) -> Option<&BufferDeclaration> { - match self { - Declaration::File { .. } => None, - Declaration::Buffer { declaration, .. } => Some(declaration), - } - } - - pub fn as_file(&self) -> Option<&FileDeclaration> { - match self { - Declaration::Buffer { .. } => None, - Declaration::File { declaration, .. } => Some(declaration), - } - } - - pub fn project_entry_id(&self) -> ProjectEntryId { - match self { - Declaration::File { - project_entry_id, .. - } => *project_entry_id, - Declaration::Buffer { - project_entry_id, .. - } => *project_entry_id, - } - } - - pub fn cached_path(&self) -> &CachedDeclarationPath { - match self { - Declaration::File { cached_path, .. } => cached_path, - Declaration::Buffer { cached_path, .. } => cached_path, - } - } - - pub fn item_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.item_range.clone(), - Declaration::Buffer { declaration, .. } => declaration.item_range.clone(), - } - } - - pub fn item_line_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.item_line_range.clone(), - Declaration::Buffer { - declaration, rope, .. - } => { - Line(rope.offset_to_point(declaration.item_range.start).row) - ..Line(rope.offset_to_point(declaration.item_range.end).row) - } - } - } - - pub fn item_text(&self) -> (Cow<'_, str>, bool) { - match self { - Declaration::File { declaration, .. } => ( - declaration.text.as_ref().into(), - declaration.text_is_truncated, - ), - Declaration::Buffer { - rope, declaration, .. - } => ( - rope.chunks_in_range(declaration.item_range.clone()) - .collect::>(), - declaration.item_range_is_truncated, - ), - } - } - - pub fn signature_text(&self) -> (Cow<'_, str>, bool) { - match self { - Declaration::File { declaration, .. } => ( - declaration.text[self.signature_range_in_item_text()].into(), - declaration.signature_is_truncated, - ), - Declaration::Buffer { - rope, declaration, .. - } => ( - rope.chunks_in_range(declaration.signature_range.clone()) - .collect::>(), - declaration.signature_range_is_truncated, - ), - } - } - - pub fn signature_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.signature_range.clone(), - Declaration::Buffer { declaration, .. } => declaration.signature_range.clone(), - } - } - - pub fn signature_line_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.signature_line_range.clone(), - Declaration::Buffer { - declaration, rope, .. - } => { - Line(rope.offset_to_point(declaration.signature_range.start).row) - ..Line(rope.offset_to_point(declaration.signature_range.end).row) - } - } - } - - pub fn signature_range_in_item_text(&self) -> Range { - let signature_range = self.signature_range(); - let item_range = self.item_range(); - signature_range.start.saturating_sub(item_range.start) - ..(signature_range.end.saturating_sub(item_range.start)).min(item_range.len()) - } -} - -fn expand_range_to_line_boundaries_and_truncate( - range: &Range, - limit: usize, - rope: &Rope, -) -> (Range, Range, bool) { - let mut point_range = rope.offset_to_point(range.start)..rope.offset_to_point(range.end); - point_range.start.column = 0; - point_range.end.row += 1; - point_range.end.column = 0; - - let mut item_range = - rope.point_to_offset(point_range.start)..rope.point_to_offset(point_range.end); - let is_truncated = item_range.len() > limit; - if is_truncated { - item_range.end = item_range.start + limit; - } - item_range.end = rope.clip_offset(item_range.end, Bias::Left); - - let line_range = - predict_edits_v3::Line(point_range.start.row)..predict_edits_v3::Line(point_range.end.row); - (item_range, line_range, is_truncated) -} - -#[derive(Debug, Clone)] -pub struct FileDeclaration { - pub parent: Option, - pub identifier: Identifier, - /// offset range of the declaration in the file, expanded to line boundaries and truncated - pub item_range: Range, - /// line range of the declaration in the file, potentially truncated - pub item_line_range: Range, - /// text of `item_range` - pub text: Arc, - /// whether `text` was truncated - pub text_is_truncated: bool, - /// offset range of the signature in the file, expanded to line boundaries and truncated - pub signature_range: Range, - /// line range of the signature in the file, truncated - pub signature_line_range: Range, - /// whether `signature` was truncated - pub signature_is_truncated: bool, -} - -impl FileDeclaration { - pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> FileDeclaration { - let (item_range_in_file, item_line_range_in_file, text_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.item_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - - let (mut signature_range_in_file, signature_line_range, mut signature_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.signature_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - - if signature_range_in_file.start < item_range_in_file.start { - signature_range_in_file.start = item_range_in_file.start; - signature_is_truncated = true; - } - if signature_range_in_file.end > item_range_in_file.end { - signature_range_in_file.end = item_range_in_file.end; - signature_is_truncated = true; - } - - FileDeclaration { - parent: None, - identifier: declaration.identifier, - signature_range: signature_range_in_file, - signature_line_range, - signature_is_truncated, - text: rope - .chunks_in_range(item_range_in_file.clone()) - .collect::() - .into(), - text_is_truncated, - item_range: item_range_in_file, - item_line_range: item_line_range_in_file, - } - } -} - -#[derive(Debug, Clone)] -pub struct BufferDeclaration { - pub parent: Option, - pub identifier: Identifier, - pub item_range: Range, - pub item_range_is_truncated: bool, - pub signature_range: Range, - pub signature_range_is_truncated: bool, -} - -impl BufferDeclaration { - pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> Self { - let (item_range, _item_line_range, item_range_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.item_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - let (signature_range, _signature_line_range, signature_range_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.signature_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - Self { - parent: None, - identifier: declaration.identifier, - item_range, - item_range_is_truncated, - signature_range, - signature_range_is_truncated, - } - } -} - -#[derive(Debug, Clone)] -pub struct CachedDeclarationPath { - pub worktree_abs_path: Arc, - pub rel_path: Arc, - /// The relative path of the file, possibly stripped according to `import_path_strip_regex`. - pub rel_path_after_regex_stripping: Arc, -} - -impl CachedDeclarationPath { - pub fn new( - worktree_abs_path: Arc, - path: &Arc, - language: Option<&Arc>, - ) -> Self { - let rel_path = path.clone(); - let rel_path_after_regex_stripping = if let Some(language) = language - && let Some(strip_regex) = language.config().import_path_strip_regex.as_ref() - && let Ok(stripped) = RelPath::unix(&Path::new( - strip_regex.replace_all(rel_path.as_unix_str(), "").as_ref(), - )) { - Arc::from(stripped) - } else { - rel_path.clone() - }; - CachedDeclarationPath { - worktree_abs_path, - rel_path, - rel_path_after_regex_stripping, - } - } - - #[cfg(test)] - pub fn new_for_test(worktree_abs_path: &str, rel_path: &str) -> Self { - let rel_path: Arc = util::rel_path::rel_path(rel_path).into(); - CachedDeclarationPath { - worktree_abs_path: std::path::PathBuf::from(worktree_abs_path).into(), - rel_path_after_regex_stripping: rel_path.clone(), - rel_path, - } - } - - pub fn ends_with_posix_path(&self, path: &Path) -> bool { - if path.as_os_str().len() <= self.rel_path_after_regex_stripping.as_unix_str().len() { - path_ends_with(self.rel_path_after_regex_stripping.as_std_path(), path) - } else { - if let Some(remaining) = - strip_path_suffix(path, self.rel_path_after_regex_stripping.as_std_path()) - { - path_ends_with(&self.worktree_abs_path, remaining) - } else { - false - } - } - } - - pub fn equals_absolute_path(&self, path: &Path) -> bool { - if let Some(remaining) = - strip_path_suffix(path, &self.rel_path_after_regex_stripping.as_std_path()) - { - self.worktree_abs_path.as_ref() == remaining - } else { - false - } - } -} diff --git a/crates/edit_prediction_context/src/declaration_scoring.rs b/crates/edit_prediction_context/src/declaration_scoring.rs deleted file mode 100644 index 48a823362769770c836b44e7d8a6c1942d3a1196..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/declaration_scoring.rs +++ /dev/null @@ -1,539 +0,0 @@ -use cloud_llm_client::predict_edits_v3::DeclarationScoreComponents; -use collections::HashMap; -use language::BufferSnapshot; -use ordered_float::OrderedFloat; -use project::ProjectEntryId; -use serde::Serialize; -use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc}; -use strum::EnumIter; -use text::{Point, ToPoint}; -use util::RangeExt as _; - -use crate::{ - CachedDeclarationPath, Declaration, EditPredictionExcerpt, Identifier, - imports::{Import, Imports, Module}, - reference::{Reference, ReferenceRegion}, - syntax_index::SyntaxIndexState, - text_similarity::{Occurrences, jaccard_similarity, weighted_overlap_coefficient}, -}; - -const MAX_IDENTIFIER_DECLARATION_COUNT: usize = 16; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct EditPredictionScoreOptions { - pub omit_excerpt_overlaps: bool, -} - -#[derive(Clone, Debug)] -pub struct ScoredDeclaration { - /// identifier used by the local reference - pub identifier: Identifier, - pub declaration: Declaration, - pub components: DeclarationScoreComponents, -} - -#[derive(EnumIter, Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub enum DeclarationStyle { - Signature, - Declaration, -} - -#[derive(Clone, Debug, Serialize, Default)] -pub struct DeclarationScores { - pub signature: f32, - pub declaration: f32, - pub retrieval: f32, -} - -impl ScoredDeclaration { - /// Returns the score for this declaration with the specified style. - pub fn score(&self, style: DeclarationStyle) -> f32 { - // TODO: handle truncation - - // Score related to how likely this is the correct declaration, range 0 to 1 - let retrieval = self.retrieval_score(); - - // Score related to the distance between the reference and cursor, range 0 to 1 - let distance_score = if self.components.is_referenced_nearby { - 1.0 / (1.0 + self.components.reference_line_distance as f32 / 10.0).powf(2.0) - } else { - // same score as ~14 lines away, rationale is to not overly penalize references from parent signatures - 0.5 - }; - - // For now instead of linear combination, the scores are just multiplied together. - let combined_score = 10.0 * retrieval * distance_score; - - match style { - DeclarationStyle::Signature => { - combined_score * self.components.excerpt_vs_signature_weighted_overlap - } - DeclarationStyle::Declaration => { - 2.0 * combined_score * self.components.excerpt_vs_item_weighted_overlap - } - } - } - - pub fn retrieval_score(&self) -> f32 { - let mut score = if self.components.is_same_file { - 10.0 / self.components.same_file_declaration_count as f32 - } else if self.components.path_import_match_count > 0 { - 3.0 - } else if self.components.wildcard_path_import_match_count > 0 { - 1.0 - } else if self.components.normalized_import_similarity > 0.0 { - self.components.normalized_import_similarity - } else if self.components.normalized_wildcard_import_similarity > 0.0 { - 0.5 * self.components.normalized_wildcard_import_similarity - } else { - 1.0 / self.components.declaration_count as f32 - }; - score *= 1. + self.components.included_by_others as f32 / 2.; - score *= 1. + self.components.includes_others as f32 / 4.; - score - } - - pub fn size(&self, style: DeclarationStyle) -> usize { - match &self.declaration { - Declaration::File { declaration, .. } => match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.text.len(), - }, - Declaration::Buffer { declaration, .. } => match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.item_range.len(), - }, - } - } - - pub fn score_density(&self, style: DeclarationStyle) -> f32 { - self.score(style) / self.size(style) as f32 - } -} - -pub fn scored_declarations( - options: &EditPredictionScoreOptions, - index: &SyntaxIndexState, - excerpt: &EditPredictionExcerpt, - excerpt_occurrences: &Occurrences, - adjacent_occurrences: &Occurrences, - imports: &Imports, - identifier_to_references: HashMap>, - cursor_offset: usize, - current_buffer: &BufferSnapshot, -) -> Vec { - let cursor_point = cursor_offset.to_point(¤t_buffer); - - let mut wildcard_import_occurrences = Vec::new(); - let mut wildcard_import_paths = Vec::new(); - for wildcard_import in imports.wildcard_modules.iter() { - match wildcard_import { - Module::Namespace(namespace) => { - wildcard_import_occurrences.push(namespace.occurrences()) - } - Module::SourceExact(path) => wildcard_import_paths.push(path), - Module::SourceFuzzy(path) => { - wildcard_import_occurrences.push(Occurrences::from_path(&path)) - } - } - } - - let mut scored_declarations = Vec::new(); - let mut project_entry_id_to_outline_ranges: HashMap>> = - HashMap::default(); - for (identifier, references) in identifier_to_references { - let mut import_occurrences = Vec::new(); - let mut import_paths = Vec::new(); - let mut found_external_identifier: Option<&Identifier> = None; - - if let Some(imports) = imports.identifier_to_imports.get(&identifier) { - // only use alias when it's the only import, could be generalized if some language - // has overlapping aliases - // - // TODO: when an aliased declaration is included in the prompt, should include the - // aliasing in the prompt. - // - // TODO: For SourceFuzzy consider having componentwise comparison that pays - // attention to ordering. - if let [ - Import::Alias { - module, - external_identifier, - }, - ] = imports.as_slice() - { - match module { - Module::Namespace(namespace) => { - import_occurrences.push(namespace.occurrences()) - } - Module::SourceExact(path) => import_paths.push(path), - Module::SourceFuzzy(path) => { - import_occurrences.push(Occurrences::from_path(&path)) - } - } - found_external_identifier = Some(&external_identifier); - } else { - for import in imports { - match import { - Import::Direct { module } => match module { - Module::Namespace(namespace) => { - import_occurrences.push(namespace.occurrences()) - } - Module::SourceExact(path) => import_paths.push(path), - Module::SourceFuzzy(path) => { - import_occurrences.push(Occurrences::from_path(&path)) - } - }, - Import::Alias { .. } => {} - } - } - } - } - - let identifier_to_lookup = found_external_identifier.unwrap_or(&identifier); - // TODO: update this to be able to return more declarations? Especially if there is the - // ability to quickly filter a large list (based on imports) - let identifier_declarations = index - .declarations_for_identifier::(&identifier_to_lookup); - let declaration_count = identifier_declarations.len(); - - if declaration_count == 0 { - continue; - } - - // TODO: option to filter out other candidates when same file / import match - let mut checked_declarations = Vec::with_capacity(declaration_count); - for (declaration_id, declaration) in identifier_declarations { - match declaration { - Declaration::Buffer { - buffer_id, - declaration: buffer_declaration, - .. - } => { - if buffer_id == ¤t_buffer.remote_id() { - let already_included_in_prompt = - range_intersection(&buffer_declaration.item_range, &excerpt.range) - .is_some() - || excerpt - .parent_declarations - .iter() - .any(|(excerpt_parent, _)| excerpt_parent == &declaration_id); - if !options.omit_excerpt_overlaps || !already_included_in_prompt { - let declaration_line = buffer_declaration - .item_range - .start - .to_point(current_buffer) - .row; - let declaration_line_distance = - (cursor_point.row as i32 - declaration_line as i32).unsigned_abs(); - checked_declarations.push(CheckedDeclaration { - declaration, - same_file_line_distance: Some(declaration_line_distance), - path_import_match_count: 0, - wildcard_path_import_match_count: 0, - }); - } - continue; - } else { - } - } - Declaration::File { .. } => {} - } - let declaration_path = declaration.cached_path(); - let path_import_match_count = import_paths - .iter() - .filter(|import_path| { - declaration_path_matches_import(&declaration_path, import_path) - }) - .count(); - let wildcard_path_import_match_count = wildcard_import_paths - .iter() - .filter(|import_path| { - declaration_path_matches_import(&declaration_path, import_path) - }) - .count(); - checked_declarations.push(CheckedDeclaration { - declaration, - same_file_line_distance: None, - path_import_match_count, - wildcard_path_import_match_count, - }); - } - - let mut max_import_similarity = 0.0; - let mut max_wildcard_import_similarity = 0.0; - - let mut scored_declarations_for_identifier = Vec::with_capacity(checked_declarations.len()); - for checked_declaration in checked_declarations { - let same_file_declaration_count = - index.file_declaration_count(checked_declaration.declaration); - - let declaration = score_declaration( - &identifier, - &references, - checked_declaration, - same_file_declaration_count, - declaration_count, - &excerpt_occurrences, - &adjacent_occurrences, - &import_occurrences, - &wildcard_import_occurrences, - cursor_point, - current_buffer, - ); - - if declaration.components.import_similarity > max_import_similarity { - max_import_similarity = declaration.components.import_similarity; - } - - if declaration.components.wildcard_import_similarity > max_wildcard_import_similarity { - max_wildcard_import_similarity = declaration.components.wildcard_import_similarity; - } - - project_entry_id_to_outline_ranges - .entry(declaration.declaration.project_entry_id()) - .or_default() - .push(declaration.declaration.item_range()); - scored_declarations_for_identifier.push(declaration); - } - - if max_import_similarity > 0.0 || max_wildcard_import_similarity > 0.0 { - for declaration in scored_declarations_for_identifier.iter_mut() { - if max_import_similarity > 0.0 { - declaration.components.max_import_similarity = max_import_similarity; - declaration.components.normalized_import_similarity = - declaration.components.import_similarity / max_import_similarity; - } - if max_wildcard_import_similarity > 0.0 { - declaration.components.normalized_wildcard_import_similarity = - declaration.components.wildcard_import_similarity - / max_wildcard_import_similarity; - } - } - } - - scored_declarations.extend(scored_declarations_for_identifier); - } - - // TODO: Inform this via import / retrieval scores of outline items - // TODO: Consider using a sweepline - for scored_declaration in scored_declarations.iter_mut() { - let project_entry_id = scored_declaration.declaration.project_entry_id(); - let Some(ranges) = project_entry_id_to_outline_ranges.get(&project_entry_id) else { - continue; - }; - for range in ranges { - if range.contains_inclusive(&scored_declaration.declaration.item_range()) { - scored_declaration.components.included_by_others += 1 - } else if scored_declaration - .declaration - .item_range() - .contains_inclusive(range) - { - scored_declaration.components.includes_others += 1 - } - } - } - - scored_declarations.sort_unstable_by_key(|declaration| { - Reverse(OrderedFloat( - declaration.score(DeclarationStyle::Declaration), - )) - }); - - scored_declarations -} - -struct CheckedDeclaration<'a> { - declaration: &'a Declaration, - same_file_line_distance: Option, - path_import_match_count: usize, - wildcard_path_import_match_count: usize, -} - -fn declaration_path_matches_import( - declaration_path: &CachedDeclarationPath, - import_path: &Arc, -) -> bool { - if import_path.is_absolute() { - declaration_path.equals_absolute_path(import_path) - } else { - declaration_path.ends_with_posix_path(import_path) - } -} - -fn range_intersection(a: &Range, b: &Range) -> Option> { - let start = a.start.clone().max(b.start.clone()); - let end = a.end.clone().min(b.end.clone()); - if start < end { - Some(Range { start, end }) - } else { - None - } -} - -fn score_declaration( - identifier: &Identifier, - references: &[Reference], - checked_declaration: CheckedDeclaration, - same_file_declaration_count: usize, - declaration_count: usize, - excerpt_occurrences: &Occurrences, - adjacent_occurrences: &Occurrences, - import_occurrences: &[Occurrences], - wildcard_import_occurrences: &[Occurrences], - cursor: Point, - current_buffer: &BufferSnapshot, -) -> ScoredDeclaration { - let CheckedDeclaration { - declaration, - same_file_line_distance, - path_import_match_count, - wildcard_path_import_match_count, - } = checked_declaration; - - let is_referenced_nearby = references - .iter() - .any(|r| r.region == ReferenceRegion::Nearby); - let is_referenced_in_breadcrumb = references - .iter() - .any(|r| r.region == ReferenceRegion::Breadcrumb); - let reference_count = references.len(); - let reference_line_distance = references - .iter() - .map(|r| { - let reference_line = r.range.start.to_point(current_buffer).row as i32; - (cursor.row as i32 - reference_line).unsigned_abs() - }) - .min() - .unwrap(); - - let is_same_file = same_file_line_distance.is_some(); - let declaration_line_distance = same_file_line_distance.unwrap_or(u32::MAX); - - let item_source_occurrences = Occurrences::within_string(&declaration.item_text().0); - let item_signature_occurrences = Occurrences::within_string(&declaration.signature_text().0); - let excerpt_vs_item_jaccard = jaccard_similarity(excerpt_occurrences, &item_source_occurrences); - let excerpt_vs_signature_jaccard = - jaccard_similarity(excerpt_occurrences, &item_signature_occurrences); - let adjacent_vs_item_jaccard = - jaccard_similarity(adjacent_occurrences, &item_source_occurrences); - let adjacent_vs_signature_jaccard = - jaccard_similarity(adjacent_occurrences, &item_signature_occurrences); - - let excerpt_vs_item_weighted_overlap = - weighted_overlap_coefficient(excerpt_occurrences, &item_source_occurrences); - let excerpt_vs_signature_weighted_overlap = - weighted_overlap_coefficient(excerpt_occurrences, &item_signature_occurrences); - let adjacent_vs_item_weighted_overlap = - weighted_overlap_coefficient(adjacent_occurrences, &item_source_occurrences); - let adjacent_vs_signature_weighted_overlap = - weighted_overlap_coefficient(adjacent_occurrences, &item_signature_occurrences); - - let mut import_similarity = 0f32; - let mut wildcard_import_similarity = 0f32; - if !import_occurrences.is_empty() || !wildcard_import_occurrences.is_empty() { - let cached_path = declaration.cached_path(); - let path_occurrences = Occurrences::from_worktree_path( - cached_path - .worktree_abs_path - .file_name() - .map(|f| f.to_string_lossy()), - &cached_path.rel_path, - ); - import_similarity = import_occurrences - .iter() - .map(|namespace_occurrences| { - OrderedFloat(jaccard_similarity(namespace_occurrences, &path_occurrences)) - }) - .max() - .map(|similarity| similarity.into_inner()) - .unwrap_or_default(); - - // TODO: Consider something other than max - wildcard_import_similarity = wildcard_import_occurrences - .iter() - .map(|namespace_occurrences| { - OrderedFloat(jaccard_similarity(namespace_occurrences, &path_occurrences)) - }) - .max() - .map(|similarity| similarity.into_inner()) - .unwrap_or_default(); - } - - // TODO: Consider adding declaration_file_count - let score_components = DeclarationScoreComponents { - is_same_file, - is_referenced_nearby, - is_referenced_in_breadcrumb, - reference_line_distance, - declaration_line_distance, - reference_count, - same_file_declaration_count, - declaration_count, - excerpt_vs_item_jaccard, - excerpt_vs_signature_jaccard, - adjacent_vs_item_jaccard, - adjacent_vs_signature_jaccard, - excerpt_vs_item_weighted_overlap, - excerpt_vs_signature_weighted_overlap, - adjacent_vs_item_weighted_overlap, - adjacent_vs_signature_weighted_overlap, - path_import_match_count, - wildcard_path_import_match_count, - import_similarity, - max_import_similarity: 0.0, - normalized_import_similarity: 0.0, - wildcard_import_similarity, - normalized_wildcard_import_similarity: 0.0, - included_by_others: 0, - includes_others: 0, - }; - - ScoredDeclaration { - identifier: identifier.clone(), - declaration: declaration.clone(), - components: score_components, - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_declaration_path_matches() { - let declaration_path = - CachedDeclarationPath::new_for_test("/home/user/project", "src/maths.ts"); - - assert!(declaration_path_matches_import( - &declaration_path, - &Path::new("maths.ts").into() - )); - - assert!(declaration_path_matches_import( - &declaration_path, - &Path::new("project/src/maths.ts").into() - )); - - assert!(declaration_path_matches_import( - &declaration_path, - &Path::new("user/project/src/maths.ts").into() - )); - - assert!(declaration_path_matches_import( - &declaration_path, - &Path::new("/home/user/project/src/maths.ts").into() - )); - - assert!(!declaration_path_matches_import( - &declaration_path, - &Path::new("other.ts").into() - )); - - assert!(!declaration_path_matches_import( - &declaration_path, - &Path::new("/home/user/project/src/other.ts").into() - )); - } -} diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 65623a825c2f7e2db42b98174748e5f04fb91d2a..15576a835d9b4b0781b1e3979edbed443fa40f62 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -1,335 +1,474 @@ -mod declaration; -mod declaration_scoring; +use crate::assemble_excerpts::assemble_excerpts; +use anyhow::Result; +use collections::HashMap; +use futures::{FutureExt, StreamExt as _, channel::mpsc, future}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity}; +use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _}; +use project::{LocationLink, Project, ProjectPath}; +use smallvec::SmallVec; +use std::{ + collections::hash_map, + ops::Range, + path::Path, + sync::Arc, + time::{Duration, Instant}, +}; +use util::{RangeExt as _, ResultExt}; + +mod assemble_excerpts; +#[cfg(test)] +mod edit_prediction_context_tests; mod excerpt; -mod imports; -mod outline; -mod reference; -mod syntax_index; -pub mod text_similarity; +#[cfg(test)] +mod fake_definition_lsp; -use std::{path::Path, sync::Arc}; +pub use cloud_llm_client::predict_edits_v3::Line; +pub use excerpt::{EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionExcerptText}; +pub use zeta_prompt::{RelatedExcerpt, RelatedFile}; -use cloud_llm_client::predict_edits_v3; -use collections::HashMap; -use gpui::{App, AppContext as _, Entity, Task}; -use language::BufferSnapshot; -use text::{Point, ToOffset as _}; - -pub use declaration::*; -pub use declaration_scoring::*; -pub use excerpt::*; -pub use imports::*; -pub use reference::*; -pub use syntax_index::*; - -pub use predict_edits_v3::Line; - -#[derive(Clone, Debug, PartialEq)] -pub struct EditPredictionContextOptions { - pub use_imports: bool, - pub excerpt: EditPredictionExcerptOptions, - pub score: EditPredictionScoreOptions, - pub max_retrieved_declarations: u8, +const IDENTIFIER_LINE_COUNT: u32 = 3; + +pub struct RelatedExcerptStore { + project: WeakEntity, + related_files: Arc<[RelatedFile]>, + related_file_buffers: Vec>, + cache: HashMap>, + update_tx: mpsc::UnboundedSender<(Entity, Anchor)>, + identifier_line_count: u32, +} + +pub enum RelatedExcerptStoreEvent { + StartedRefresh, + FinishedRefresh { + cache_hit_count: usize, + cache_miss_count: usize, + mean_definition_latency: Duration, + max_definition_latency: Duration, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct Identifier { + pub name: String, + pub range: Range, +} + +enum DefinitionTask { + CacheHit(Arc), + CacheMiss(Task>>>), +} + +#[derive(Debug)] +struct CacheEntry { + definitions: SmallVec<[CachedDefinition; 1]>, } #[derive(Clone, Debug)] -pub struct EditPredictionContext { - pub excerpt: EditPredictionExcerpt, - pub excerpt_text: EditPredictionExcerptText, - pub cursor_point: Point, - pub declarations: Vec, +struct CachedDefinition { + path: ProjectPath, + buffer: Entity, + anchor_range: Range, } -impl EditPredictionContext { - pub fn gather_context_in_background( - cursor_point: Point, - buffer: BufferSnapshot, - options: EditPredictionContextOptions, - syntax_index: Option>, - cx: &mut App, - ) -> Task> { - let parent_abs_path = project::File::from_dyn(buffer.file()).and_then(|f| { - let mut path = f.worktree.read(cx).absolutize(&f.path); - if path.pop() { Some(path) } else { None } - }); +const DEBOUNCE_DURATION: Duration = Duration::from_millis(100); + +impl EventEmitter for RelatedExcerptStore {} + +impl RelatedExcerptStore { + pub fn new(project: &Entity, cx: &mut Context) -> Self { + let (update_tx, mut update_rx) = mpsc::unbounded::<(Entity, Anchor)>(); + cx.spawn(async move |this, cx| { + let executor = cx.background_executor().clone(); + while let Some((mut buffer, mut position)) = update_rx.next().await { + let mut timer = executor.timer(DEBOUNCE_DURATION).fuse(); + loop { + futures::select_biased! { + next = update_rx.next() => { + if let Some((new_buffer, new_position)) = next { + buffer = new_buffer; + position = new_position; + timer = executor.timer(DEBOUNCE_DURATION).fuse(); + } else { + return anyhow::Ok(()); + } + } + _ = timer => break, + } + } - if let Some(syntax_index) = syntax_index { - let index_state = - syntax_index.read_with(cx, |index, _cx| Arc::downgrade(index.state())); - cx.background_spawn(async move { - let parent_abs_path = parent_abs_path.as_deref(); - let index_state = index_state.upgrade()?; - let index_state = index_state.lock().await; - Self::gather_context( - cursor_point, - &buffer, - parent_abs_path, - &options, - Some(&index_state), - ) - }) - } else { - cx.background_spawn(async move { - let parent_abs_path = parent_abs_path.as_deref(); - Self::gather_context(cursor_point, &buffer, parent_abs_path, &options, None) - }) + Self::fetch_excerpts(this.clone(), buffer, position, cx).await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + RelatedExcerptStore { + project: project.downgrade(), + update_tx, + related_files: Vec::new().into(), + related_file_buffers: Vec::new(), + cache: Default::default(), + identifier_line_count: IDENTIFIER_LINE_COUNT, } } - pub fn gather_context( - cursor_point: Point, - buffer: &BufferSnapshot, - parent_abs_path: Option<&Path>, - options: &EditPredictionContextOptions, - index_state: Option<&SyntaxIndexState>, - ) -> Option { - let imports = if options.use_imports { - Imports::gather(&buffer, parent_abs_path) - } else { - Imports::default() - }; - Self::gather_context_with_references_fn( - cursor_point, - buffer, - &imports, - options, - index_state, - references_in_excerpt, - ) + pub fn set_identifier_line_count(&mut self, count: u32) { + self.identifier_line_count = count; } - pub fn gather_context_with_references_fn( - cursor_point: Point, - buffer: &BufferSnapshot, - imports: &Imports, - options: &EditPredictionContextOptions, - index_state: Option<&SyntaxIndexState>, - get_references: impl FnOnce( - &EditPredictionExcerpt, - &EditPredictionExcerptText, - &BufferSnapshot, - ) -> HashMap>, - ) -> Option { - let excerpt = EditPredictionExcerpt::select_from_buffer( - cursor_point, - buffer, - &options.excerpt, - index_state, - )?; - let excerpt_text = excerpt.text(buffer); - - let declarations = if options.max_retrieved_declarations > 0 - && let Some(index_state) = index_state - { - let excerpt_occurrences = - text_similarity::Occurrences::within_string(&excerpt_text.body); - - let adjacent_start = Point::new(cursor_point.row.saturating_sub(2), 0); - let adjacent_end = Point::new(cursor_point.row + 1, 0); - let adjacent_occurrences = text_similarity::Occurrences::within_string( - &buffer - .text_for_range(adjacent_start..adjacent_end) - .collect::(), - ); + pub fn refresh(&mut self, buffer: Entity, position: Anchor, _: &mut Context) { + self.update_tx.unbounded_send((buffer, position)).ok(); + } - let cursor_offset_in_file = cursor_point.to_offset(buffer); + pub fn related_files(&self) -> Arc<[RelatedFile]> { + self.related_files.clone() + } - let references = get_references(&excerpt, &excerpt_text, buffer); + pub fn related_files_with_buffers( + &self, + ) -> impl Iterator)> { + self.related_files + .iter() + .cloned() + .zip(self.related_file_buffers.iter().cloned()) + } - let mut declarations = scored_declarations( - &options.score, - &index_state, - &excerpt, - &excerpt_occurrences, - &adjacent_occurrences, - &imports, - references, - cursor_offset_in_file, - buffer, - ); - // TODO [zeta2] if we need this when we ship, we should probably do it in a smarter way - declarations.truncate(options.max_retrieved_declarations as usize); - declarations - } else { - vec![] + pub fn set_related_files(&mut self, files: Vec) { + self.related_files = files.into(); + } + + async fn fetch_excerpts( + this: WeakEntity, + buffer: Entity, + position: Anchor, + cx: &mut AsyncApp, + ) -> Result<()> { + let (project, snapshot, identifier_line_count) = this.read_with(cx, |this, cx| { + ( + this.project.upgrade(), + buffer.read(cx).snapshot(), + this.identifier_line_count, + ) + })?; + let Some(project) = project else { + return Ok(()); }; - Some(Self { - excerpt, - excerpt_text, - cursor_point, - declarations, - }) - } -} + let file = snapshot.file().cloned(); + if let Some(file) = &file { + log::debug!("retrieving_context buffer:{}", file.path().as_unix_str()); + } -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - use gpui::{Entity, TestAppContext}; - use indoc::indoc; - use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - use crate::{EditPredictionExcerptOptions, SyntaxIndex}; - - #[gpui::test] - async fn test_call_site(cx: &mut TestAppContext) { - let (project, index, _rust_lang_id) = init_test(cx).await; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - // first process_data call site - let cursor_point = language::Point::new(8, 21); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let context = cx - .update(|cx| { - EditPredictionContext::gather_context_in_background( - cursor_point, - buffer_snapshot, - EditPredictionContextOptions { - use_imports: true, - excerpt: EditPredictionExcerptOptions { - max_bytes: 60, - min_bytes: 10, - target_before_cursor_over_total_bytes: 0.5, - }, - score: EditPredictionScoreOptions { - omit_excerpt_overlaps: true, - }, - max_retrieved_declarations: u8::MAX, - }, - Some(index.clone()), - cx, - ) + this.update(cx, |_, cx| { + cx.emit(RelatedExcerptStoreEvent::StartedRefresh); + })?; + + let identifiers = cx + .background_spawn(async move { + identifiers_for_position(&snapshot, position, identifier_line_count) }) - .await - .unwrap(); + .await; + + let async_cx = cx.clone(); + let start_time = Instant::now(); + let futures = this.update(cx, |this, cx| { + identifiers + .into_iter() + .filter_map(|identifier| { + let task = if let Some(entry) = this.cache.get(&identifier) { + DefinitionTask::CacheHit(entry.clone()) + } else { + DefinitionTask::CacheMiss( + this.project + .update(cx, |project, cx| { + project.definitions(&buffer, identifier.range.start, cx) + }) + .ok()?, + ) + }; + + let cx = async_cx.clone(); + let project = project.clone(); + Some(async move { + match task { + DefinitionTask::CacheHit(cache_entry) => { + Some((identifier, cache_entry, None)) + } + DefinitionTask::CacheMiss(task) => { + let locations = task.await.log_err()??; + let duration = start_time.elapsed(); + cx.update(|cx| { + ( + identifier, + Arc::new(CacheEntry { + definitions: locations + .into_iter() + .filter_map(|location| { + process_definition(location, &project, cx) + }) + .collect(), + }), + Some(duration), + ) + }) + .ok() + } + } + }) + }) + .collect::>() + })?; + + let mut cache_hit_count = 0; + let mut cache_miss_count = 0; + let mut mean_definition_latency = Duration::ZERO; + let mut max_definition_latency = Duration::ZERO; + let mut new_cache = HashMap::default(); + new_cache.reserve(futures.len()); + for (identifier, entry, duration) in future::join_all(futures).await.into_iter().flatten() { + new_cache.insert(identifier, entry); + if let Some(duration) = duration { + cache_miss_count += 1; + mean_definition_latency += duration; + max_definition_latency = max_definition_latency.max(duration); + } else { + cache_hit_count += 1; + } + } + mean_definition_latency /= cache_miss_count.max(1) as u32; - let mut snippet_identifiers = context - .declarations - .iter() - .map(|snippet| snippet.identifier.name.as_ref()) - .collect::>(); - snippet_identifiers.sort(); - assert_eq!(snippet_identifiers, vec!["main", "process_data"]); - drop(buffer); - } + let (new_cache, related_files, related_file_buffers) = + rebuild_related_files(&project, new_cache, cx).await?; - async fn init_test( - cx: &mut TestAppContext, - ) -> (Entity, Entity, LanguageId) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); + if let Some(file) = &file { + log::debug!( + "finished retrieving context buffer:{}, latency:{:?}", + file.path().as_unix_str(), + start_time.elapsed() + ); + } - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "a.rs": indoc! {r#" - fn main() { - let x = 1; - let y = 2; - let z = add(x, y); - println!("Result: {}", z); - } + this.update(cx, |this, cx| { + this.cache = new_cache; + this.related_files = related_files.into(); + this.related_file_buffers = related_file_buffers; + cx.emit(RelatedExcerptStoreEvent::FinishedRefresh { + cache_hit_count, + cache_miss_count, + mean_definition_latency, + max_definition_latency, + }); + })?; + + anyhow::Ok(()) + } +} - fn add(a: i32, b: i32) -> i32 { - a + b - } - "#}, - "b.rs": indoc! {" - pub struct Config { - pub name: String, - pub value: i32, +async fn rebuild_related_files( + project: &Entity, + new_entries: HashMap>, + cx: &mut AsyncApp, +) -> Result<( + HashMap>, + Vec, + Vec>, +)> { + let mut snapshots = HashMap::default(); + let mut worktree_root_names = HashMap::default(); + for entry in new_entries.values() { + for definition in &entry.definitions { + if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) { + definition + .buffer + .read_with(cx, |buffer, _| buffer.parsing_idle())? + .await; + e.insert( + definition + .buffer + .read_with(cx, |buffer, _| buffer.snapshot())?, + ); + } + let worktree_id = definition.path.worktree_id; + if let hash_map::Entry::Vacant(e) = + worktree_root_names.entry(definition.path.worktree_id) + { + project.read_with(cx, |project, cx| { + if let Some(worktree) = project.worktree_for_id(worktree_id, cx) { + e.insert(worktree.read(cx).root_name().as_unix_str().to_string()); } + })?; + } + } + } - impl Config { - pub fn new(name: String, value: i32) -> Self { - Config { name, value } - } - } - "}, - "c.rs": indoc! {r#" - use std::collections::HashMap; - - fn main() { - let args: Vec = std::env::args().collect(); - let data: Vec = args[1..] - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - let result = process_data(data); - println!("{:?}", result); - } + Ok(cx + .background_spawn(async move { + let mut files = Vec::new(); + let mut ranges_by_buffer = HashMap::<_, Vec>>::default(); + let mut paths_by_buffer = HashMap::default(); + for entry in new_entries.values() { + for definition in &entry.definitions { + let Some(snapshot) = snapshots.get(&definition.buffer.entity_id()) else { + continue; + }; + paths_by_buffer.insert(definition.buffer.entity_id(), definition.path.clone()); + ranges_by_buffer + .entry(definition.buffer.clone()) + .or_default() + .push(definition.anchor_range.to_point(snapshot)); + } + } + + for (buffer, ranges) in ranges_by_buffer { + let Some(snapshot) = snapshots.get(&buffer.entity_id()) else { + continue; + }; + let Some(project_path) = paths_by_buffer.get(&buffer.entity_id()) else { + continue; + }; + let excerpts = assemble_excerpts(snapshot, ranges); + let Some(root_name) = worktree_root_names.get(&project_path.worktree_id) else { + continue; + }; + + let path = Path::new(&format!( + "{}/{}", + root_name, + project_path.path.as_unix_str() + )) + .into(); + + files.push(( + buffer, + RelatedFile { + path, + excerpts, + max_row: snapshot.max_point().row, + }, + )); + } - fn process_data(data: Vec) -> HashMap { - let mut counts = HashMap::new(); - for value in data { - *counts.entry(value).or_insert(0) += 1; - } - counts - } + files.sort_by_key(|(_, file)| file.path.clone()); + let (related_buffers, related_files) = files.into_iter().unzip(); - #[cfg(test)] - mod tests { - use super::*; + (new_entries, related_files, related_buffers) + }) + .await) +} - #[test] - fn test_process_data() { - let data = vec![1, 2, 2, 3]; - let result = process_data(data); - assert_eq!(result.get(&2), Some(&2)); - } - } - "#} - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let lang = rust_lang(); - let lang_id = lang.id(); - language_registry.add(Arc::new(lang)); - - let file_indexing_parallelism = 2; - let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx)); - cx.run_until_parked(); - - (project, index, lang_id) +const MAX_TARGET_LEN: usize = 128; + +fn process_definition( + location: LocationLink, + project: &Entity, + cx: &mut App, +) -> Option { + let buffer = location.target.buffer.read(cx); + let anchor_range = location.target.range; + let file = buffer.file()?; + let worktree = project.read(cx).worktree_for_id(file.worktree_id(cx), cx)?; + if worktree.read(cx).is_single_file() { + return None; } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_highlights_query(include_str!("../../languages/src/rust/highlights.scm")) - .unwrap() - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() + // If the target range is large, it likely means we requested the definition of an entire module. + // For individual definitions, the target range should be small as it only covers the symbol. + let buffer = location.target.buffer.read(cx); + let target_len = anchor_range.to_offset(&buffer).len(); + if target_len > MAX_TARGET_LEN { + return None; } + + Some(CachedDefinition { + path: ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }, + buffer: location.target.buffer, + anchor_range, + }) +} + +/// Gets all of the identifiers that are present in the given line, and its containing +/// outline items. +fn identifiers_for_position( + buffer: &BufferSnapshot, + position: Anchor, + identifier_line_count: u32, +) -> Vec { + let offset = position.to_offset(buffer); + let point = buffer.offset_to_point(offset); + + // Search for identifiers on lines adjacent to the cursor. + let start = Point::new(point.row.saturating_sub(identifier_line_count), 0); + let end = Point::new(point.row + identifier_line_count + 1, 0).min(buffer.max_point()); + let line_range = start..end; + let mut ranges = vec![line_range.to_offset(&buffer)]; + + // Search for identifiers mentioned in headers/signatures of containing outline items. + let outline_items = buffer.outline_items_as_offsets_containing(offset..offset, false, None); + for item in outline_items { + if let Some(body_range) = item.body_range(&buffer) { + ranges.push(item.range.start..body_range.start.to_offset(&buffer)); + } else { + ranges.push(item.range.clone()); + } + } + + ranges.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + ranges.dedup_by(|a, b| { + if a.start <= b.end { + b.start = b.start.min(a.start); + b.end = b.end.max(a.end); + true + } else { + false + } + }); + + let mut identifiers = Vec::new(); + let outer_range = + ranges.first().map_or(0, |r| r.start)..ranges.last().map_or(buffer.len(), |r| r.end); + + let mut captures = buffer + .syntax + .captures(outer_range.clone(), &buffer.text, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) + }); + + for range in ranges { + captures.set_byte_range(range.start..outer_range.end); + + let mut last_range = None; + while let Some(capture) = captures.peek() { + let node_range = capture.node.byte_range(); + if node_range.start > range.end { + break; + } + let config = captures.grammars()[capture.grammar_index] + .highlights_config + .as_ref(); + + if let Some(config) = config + && config.identifier_capture_indices.contains(&capture.index) + && range.contains_inclusive(&node_range) + && Some(&node_range) != last_range.as_ref() + { + let name = buffer.text_for_range(node_range.clone()).collect(); + identifiers.push(Identifier { + range: buffer.anchor_after(node_range.start) + ..buffer.anchor_before(node_range.end), + name, + }); + last_range = Some(node_range); + } + + captures.advance(); + } + } + + identifiers } diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..d93a66081164a3fc70f7e1072d91a02bd9adbd37 --- /dev/null +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -0,0 +1,510 @@ +use super::*; +use futures::channel::mpsc::UnboundedReceiver; +use gpui::TestAppContext; +use indoc::indoc; +use language::{Point, ToPoint as _, rust_lang}; +use lsp::FakeLanguageServer; +use project::{FakeFs, LocationLink, Project}; +use serde_json::json; +use settings::SettingsStore; +use std::fmt::Write as _; +use util::{path, test::marked_text_ranges}; + +#[gpui::test] +async fn test_edit_prediction_context(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), test_project_1()).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let mut servers = setup_fake_lsp(&project, cx); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + let _server = servers.next().await.unwrap(); + cx.run_until_parked(); + + let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(&project, cx)); + related_excerpt_store.update(cx, |store, cx| { + let position = { + let buffer = buffer.read(cx); + let offset = buffer.text().find("todo").unwrap(); + buffer.anchor_before(offset) + }; + + store.set_identifier_line_count(0); + store.refresh(buffer.clone(), position, cx); + }); + + cx.executor().advance_clock(DEBOUNCE_DURATION); + related_excerpt_store.update(cx, |store, _| { + let excerpts = store.related_files(); + assert_related_files( + &excerpts, + &[ + ( + "root/src/company.rs", + &[indoc! {" + pub struct Company { + owner: Arc, + address: Address, + }"}], + ), + ( + "root/src/main.rs", + &[ + indoc! {" + pub struct Session { + company: Arc, + } + + impl Session { + pub fn set_company(&mut self, company: Arc) {"}, + indoc! {" + } + }"}, + ], + ), + ( + "root/src/person.rs", + &[ + indoc! {" + impl Person { + pub fn get_first_name(&self) -> &str { + &self.first_name + }"}, + "}", + ], + ), + ], + ); + }); +} + +#[gpui::test] +fn test_assemble_excerpts(cx: &mut TestAppContext) { + let table = [ + ( + indoc! {r#" + struct User { + first_name: String, + «last_name»: String, + age: u32, + email: String, + create_at: Instant, + } + + impl User { + pub fn first_name(&self) -> String { + self.first_name.clone() + } + + pub fn full_name(&self) -> String { + « format!("{} {}", self.first_name, self.last_name) + » } + } + "#}, + indoc! {r#" + struct User { + first_name: String, + last_name: String, + … + } + + impl User { + … + pub fn full_name(&self) -> String { + format!("{} {}", self.first_name, self.last_name) + } + } + "#}, + ), + ( + indoc! {r#" + struct «User» { + first_name: String, + last_name: String, + age: u32, + } + + impl User { + // methods + } + "#}, + indoc! {r#" + struct User { + first_name: String, + last_name: String, + age: u32, + } + … + "#}, + ), + ( + indoc! {r#" + trait «FooProvider» { + const NAME: &'static str; + + fn provide_foo(&self, id: usize) -> Foo; + + fn provide_foo_batched(&self, ids: &[usize]) -> Vec { + ids.iter() + .map(|id| self.provide_foo(*id)) + .collect() + } + + fn sync(&self); + } + "# + }, + indoc! {r#" + trait FooProvider { + const NAME: &'static str; + + fn provide_foo(&self, id: usize) -> Foo; + + fn provide_foo_batched(&self, ids: &[usize]) -> Vec { + … + } + + fn sync(&self); + } + "#}, + ), + ( + indoc! {r#" + trait «Something» { + fn method1(&self, id: usize) -> Foo; + + fn method2(&self, ids: &[usize]) -> Vec { + struct Helper1 { + field1: usize, + } + + struct Helper2 { + field2: usize, + } + + struct Helper3 { + filed2: usize, + } + } + + fn sync(&self); + } + "# + }, + indoc! {r#" + trait Something { + fn method1(&self, id: usize) -> Foo; + + fn method2(&self, ids: &[usize]) -> Vec { + … + } + + fn sync(&self); + } + "#}, + ), + ]; + + for (input, expected_output) in table { + let (input, ranges) = marked_text_ranges(&input, false); + let buffer = cx.new(|cx| Buffer::local(input, cx).with_language(rust_lang(), cx)); + buffer.read_with(cx, |buffer, _cx| { + let ranges: Vec> = ranges + .into_iter() + .map(|range| range.to_point(&buffer)) + .collect(); + + let excerpts = assemble_excerpts(&buffer.snapshot(), ranges); + + let output = format_excerpts(buffer, &excerpts); + assert_eq!(output, expected_output); + }); + } +} + +#[gpui::test] +async fn test_fake_definition_lsp(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), test_project_1()).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let mut servers = setup_fake_lsp(&project, cx); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + let _server = servers.next().await.unwrap(); + cx.run_until_parked(); + + let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("Address {").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub struct Address {"], cx); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("State::CA").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub enum State {"], cx); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("to_string()").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub fn to_string(&self) -> String {"], cx); +} + +fn init_test(cx: &mut TestAppContext) { + let settings_store = cx.update(|cx| SettingsStore::test(cx)); + cx.set_global(settings_store); + env_logger::try_init().ok(); +} + +fn setup_fake_lsp( + project: &Entity, + cx: &mut TestAppContext, +) -> UnboundedReceiver { + let (language_registry, fs) = project.read_with(cx, |project, _| { + (project.languages().clone(), project.fs().clone()) + }); + let language = rust_lang(); + language_registry.add(language.clone()); + fake_definition_lsp::register_fake_definition_server(&language_registry, language, fs) +} + +fn test_project_1() -> serde_json::Value { + let person_rs = indoc! {r#" + pub struct Person { + first_name: String, + last_name: String, + email: String, + age: u32, + } + + impl Person { + pub fn get_first_name(&self) -> &str { + &self.first_name + } + + pub fn get_last_name(&self) -> &str { + &self.last_name + } + + pub fn get_email(&self) -> &str { + &self.email + } + + pub fn get_age(&self) -> u32 { + self.age + } + } + "#}; + + let address_rs = indoc! {r#" + pub struct Address { + street: String, + city: String, + state: State, + zip: u32, + } + + pub enum State { + CA, + OR, + WA, + TX, + // ... + } + + impl Address { + pub fn get_street(&self) -> &str { + &self.street + } + + pub fn get_city(&self) -> &str { + &self.city + } + + pub fn get_state(&self) -> State { + self.state + } + + pub fn get_zip(&self) -> u32 { + self.zip + } + } + "#}; + + let company_rs = indoc! {r#" + use super::person::Person; + use super::address::Address; + + pub struct Company { + owner: Arc, + address: Address, + } + + impl Company { + pub fn get_owner(&self) -> &Person { + &self.owner + } + + pub fn get_address(&self) -> &Address { + &self.address + } + + pub fn to_string(&self) -> String { + format!("{} ({})", self.owner.first_name, self.address.city) + } + } + "#}; + + let main_rs = indoc! {r#" + use std::sync::Arc; + use super::person::Person; + use super::address::Address; + use super::company::Company; + + pub struct Session { + company: Arc, + } + + impl Session { + pub fn set_company(&mut self, company: Arc) { + self.company = company; + if company.owner != self.company.owner { + log("new owner", company.owner.get_first_name()); todo(); + } + } + } + + fn main() { + let company = Company { + owner: Arc::new(Person { + first_name: "John".to_string(), + last_name: "Doe".to_string(), + email: "john@example.com".to_string(), + age: 30, + }), + address: Address { + street: "123 Main St".to_string(), + city: "Anytown".to_string(), + state: State::CA, + zip: 12345, + }, + }; + + println!("Company: {}", company.to_string()); + } + "#}; + + json!({ + "src": { + "person.rs": person_rs, + "address.rs": address_rs, + "company.rs": company_rs, + "main.rs": main_rs, + }, + }) +} + +fn assert_related_files(actual_files: &[RelatedFile], expected_files: &[(&str, &[&str])]) { + let actual_files = actual_files + .iter() + .map(|file| { + let excerpts = file + .excerpts + .iter() + .map(|excerpt| excerpt.text.to_string()) + .collect::>(); + (file.path.to_str().unwrap(), excerpts) + }) + .collect::>(); + let expected_excerpts = expected_files + .iter() + .map(|(path, texts)| { + ( + *path, + texts + .iter() + .map(|line| line.to_string()) + .collect::>(), + ) + }) + .collect::>(); + pretty_assertions::assert_eq!(actual_files, expected_excerpts) +} + +fn assert_definitions(definitions: &[LocationLink], first_lines: &[&str], cx: &mut TestAppContext) { + let actual_first_lines = definitions + .iter() + .map(|definition| { + definition.target.buffer.read_with(cx, |buffer, _| { + let mut start = definition.target.range.start.to_point(&buffer); + start.column = 0; + let end = Point::new(start.row, buffer.line_len(start.row)); + buffer + .text_for_range(start..end) + .collect::() + .trim() + .to_string() + }) + }) + .collect::>(); + + assert_eq!(actual_first_lines, first_lines); +} + +fn format_excerpts(buffer: &Buffer, excerpts: &[RelatedExcerpt]) -> String { + let mut output = String::new(); + let file_line_count = buffer.max_point().row; + let mut current_row = 0; + for excerpt in excerpts { + if excerpt.text.is_empty() { + continue; + } + if current_row < excerpt.row_range.start { + writeln!(&mut output, "…").unwrap(); + } + current_row = excerpt.row_range.start; + + for line in excerpt.text.to_string().lines() { + output.push_str(line); + output.push('\n'); + current_row += 1; + } + } + if current_row < file_line_count { + writeln!(&mut output, "…").unwrap(); + } + output +} diff --git a/crates/edit_prediction_context/src/excerpt.rs b/crates/edit_prediction_context/src/excerpt.rs index 7a4bb73edfa131b620a930d7f0e1c0da77e0afe6..3fc7eed4ace5a83992bf496aef3e364aea96e215 100644 --- a/crates/edit_prediction_context/src/excerpt.rs +++ b/crates/edit_prediction_context/src/excerpt.rs @@ -1,11 +1,9 @@ -use language::{BufferSnapshot, LanguageId}; +use cloud_llm_client::predict_edits_v3::Line; +use language::{BufferSnapshot, LanguageId, Point, ToOffset as _, ToPoint as _}; use std::ops::Range; -use text::{Point, ToOffset as _, ToPoint as _}; use tree_sitter::{Node, TreeCursor}; use util::RangeExt; -use crate::{BufferDeclaration, Line, declaration::DeclarationId, syntax_index::SyntaxIndexState}; - // TODO: // // - Test parent signatures @@ -31,19 +29,16 @@ pub struct EditPredictionExcerptOptions { pub target_before_cursor_over_total_bytes: f32, } -// TODO: consider merging these #[derive(Debug, Clone)] pub struct EditPredictionExcerpt { pub range: Range, pub line_range: Range, - pub parent_declarations: Vec<(DeclarationId, Range)>, pub size: usize, } #[derive(Debug, Clone)] pub struct EditPredictionExcerptText { pub body: String, - pub parent_signatures: Vec, pub language_id: Option, } @@ -52,17 +47,8 @@ impl EditPredictionExcerpt { let body = buffer .text_for_range(self.range.clone()) .collect::(); - let parent_signatures = self - .parent_declarations - .iter() - .map(|(_, range)| buffer.text_for_range(range.clone()).collect::()) - .collect(); let language_id = buffer.language().map(|l| l.id()); - EditPredictionExcerptText { - body, - parent_signatures, - language_id, - } + EditPredictionExcerptText { body, language_id } } /// Selects an excerpt around a buffer position, attempting to choose logical boundaries based @@ -79,7 +65,6 @@ impl EditPredictionExcerpt { query_point: Point, buffer: &BufferSnapshot, options: &EditPredictionExcerptOptions, - syntax_index: Option<&SyntaxIndexState>, ) -> Option { if buffer.len() <= options.max_bytes { log::debug!( @@ -89,11 +74,7 @@ impl EditPredictionExcerpt { ); let offset_range = 0..buffer.len(); let line_range = Line(0)..Line(buffer.max_point().row); - return Some(EditPredictionExcerpt::new( - offset_range, - line_range, - Vec::new(), - )); + return Some(EditPredictionExcerpt::new(offset_range, line_range)); } let query_offset = query_point.to_offset(buffer); @@ -104,19 +85,10 @@ impl EditPredictionExcerpt { return None; } - let parent_declarations = if let Some(syntax_index) = syntax_index { - syntax_index - .buffer_declarations_containing_range(buffer.remote_id(), query_range.clone()) - .collect() - } else { - Vec::new() - }; - let excerpt_selector = ExcerptSelector { query_offset, query_range, query_line_range: Line(query_line_range.start)..Line(query_line_range.end), - parent_declarations: &parent_declarations, buffer, options, }; @@ -139,20 +111,10 @@ impl EditPredictionExcerpt { excerpt_selector.select_lines() } - fn new( - range: Range, - line_range: Range, - parent_declarations: Vec<(DeclarationId, Range)>, - ) -> Self { - let size = range.len() - + parent_declarations - .iter() - .map(|(_, range)| range.len()) - .sum::(); + fn new(range: Range, line_range: Range) -> Self { Self { + size: range.len(), range, - parent_declarations, - size, line_range, } } @@ -162,14 +124,7 @@ impl EditPredictionExcerpt { // this is an issue because parent_signature_ranges may be incorrect log::error!("bug: with_expanded_range called with disjoint range"); } - let mut parent_declarations = Vec::with_capacity(self.parent_declarations.len()); - for (declaration_id, range) in &self.parent_declarations { - if !range.contains_inclusive(&new_range) { - break; - } - parent_declarations.push((*declaration_id, range.clone())); - } - Self::new(new_range, new_line_range, parent_declarations) + Self::new(new_range, new_line_range) } fn parent_signatures_size(&self) -> usize { @@ -181,7 +136,6 @@ struct ExcerptSelector<'a> { query_offset: usize, query_range: Range, query_line_range: Range, - parent_declarations: &'a [(DeclarationId, &'a BufferDeclaration)], buffer: &'a BufferSnapshot, options: &'a EditPredictionExcerptOptions, } @@ -409,13 +363,7 @@ impl<'a> ExcerptSelector<'a> { } fn make_excerpt(&self, range: Range, line_range: Range) -> EditPredictionExcerpt { - let parent_declarations = self - .parent_declarations - .iter() - .filter(|(_, declaration)| declaration.item_range.contains_inclusive(&range)) - .map(|(id, declaration)| (*id, declaration.signature_range.clone())) - .collect(); - EditPredictionExcerpt::new(range, line_range, parent_declarations) + EditPredictionExcerpt::new(range, line_range) } /// Returns `true` if the `forward` excerpt is a better choice than the `backward` excerpt. @@ -471,30 +419,14 @@ fn node_line_end(node: Node) -> Point { mod tests { use super::*; use gpui::{AppContext, TestAppContext}; - use language::{Buffer, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; + use language::Buffer; use util::test::{generate_marked_text, marked_text_offsets_by}; fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot { - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang().into(), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language::rust_lang(), cx)); buffer.read_with(cx, |buffer, _| buffer.snapshot()) } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } - fn cursor_and_excerpt_range(text: &str) -> (String, usize, Range) { let (text, offsets) = marked_text_offsets_by(text, vec!['ˇ', '«', '»']); (text, offsets[&'ˇ'][0], offsets[&'«'][0]..offsets[&'»'][0]) @@ -506,9 +438,8 @@ mod tests { let buffer = create_buffer(&text, cx); let cursor_point = cursor.to_point(&buffer); - let excerpt = - EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &options, None) - .expect("Should select an excerpt"); + let excerpt = EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &options) + .expect("Should select an excerpt"); pretty_assertions::assert_eq!( generate_marked_text(&text, std::slice::from_ref(&excerpt.range), false), generate_marked_text(&text, &[expected_excerpt], false) diff --git a/crates/edit_prediction_context/src/fake_definition_lsp.rs b/crates/edit_prediction_context/src/fake_definition_lsp.rs new file mode 100644 index 0000000000000000000000000000000000000000..31fb681309c610a37c7f886390ef5adb92ee78ef --- /dev/null +++ b/crates/edit_prediction_context/src/fake_definition_lsp.rs @@ -0,0 +1,329 @@ +use collections::HashMap; +use futures::channel::mpsc::UnboundedReceiver; +use language::{Language, LanguageRegistry}; +use lsp::{ + FakeLanguageServer, LanguageServerBinary, TextDocumentSyncCapability, TextDocumentSyncKind, Uri, +}; +use parking_lot::Mutex; +use project::Fs; +use std::{ops::Range, path::PathBuf, sync::Arc}; +use tree_sitter::{Parser, QueryCursor, StreamingIterator, Tree}; + +/// Registers a fake language server that implements go-to-definition using tree-sitter, +/// making the assumption that all names are unique, and all variables' types are +/// explicitly declared. +pub fn register_fake_definition_server( + language_registry: &Arc, + language: Arc, + fs: Arc, +) -> UnboundedReceiver { + let index = Arc::new(Mutex::new(DefinitionIndex::new(language.clone()))); + + language_registry.register_fake_lsp( + language.name(), + language::FakeLspAdapter { + name: "fake-definition-lsp", + initialization_options: None, + prettier_plugins: Vec::new(), + disk_based_diagnostics_progress_token: None, + disk_based_diagnostics_sources: Vec::new(), + language_server_binary: LanguageServerBinary { + path: PathBuf::from("fake-definition-lsp"), + arguments: Vec::new(), + env: None, + }, + capabilities: lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + ..Default::default() + }, + label_for_completion: None, + initializer: Some(Box::new({ + move |server| { + server.handle_notification::({ + let index = index.clone(); + move |params, _cx| { + index + .lock() + .open_buffer(params.text_document.uri, ¶ms.text_document.text); + } + }); + + server.handle_notification::({ + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let uri = params.text_document.uri; + let path = uri.to_file_path().ok(); + index.lock().mark_buffer_closed(&uri); + + if let Some(path) = path { + let index = index.clone(); + let fs = fs.clone(); + cx.spawn(async move |_cx| { + if let Ok(content) = fs.load(&path).await { + index.lock().index_file(uri, &content); + } + }) + .detach(); + } + } + }); + + server.handle_notification::({ + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let index = index.clone(); + let fs = fs.clone(); + cx.spawn(async move |_cx| { + for event in params.changes { + if index.lock().is_buffer_open(&event.uri) { + continue; + } + + match event.typ { + lsp::FileChangeType::DELETED => { + index.lock().remove_definitions_for_file(&event.uri); + } + lsp::FileChangeType::CREATED + | lsp::FileChangeType::CHANGED => { + if let Some(path) = event.uri.to_file_path().ok() { + if let Ok(content) = fs.load(&path).await { + index.lock().index_file(event.uri, &content); + } + } + } + _ => {} + } + } + }) + .detach(); + } + }); + + server.handle_notification::({ + let index = index.clone(); + move |params, _cx| { + if let Some(change) = params.content_changes.into_iter().last() { + index + .lock() + .index_file(params.text_document.uri, &change.text); + } + } + }); + + server.handle_notification::( + { + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let index = index.clone(); + let fs = fs.clone(); + let files = fs.as_fake().files(); + cx.spawn(async move |_cx| { + for folder in params.event.added { + let Ok(path) = folder.uri.to_file_path() else { + continue; + }; + for file in &files { + if let Some(uri) = Uri::from_file_path(&file).ok() + && file.starts_with(&path) + && let Ok(content) = fs.load(&file).await + { + index.lock().index_file(uri, &content); + } + } + } + }) + .detach(); + } + }, + ); + + server.set_request_handler::({ + let index = index.clone(); + move |params, _cx| { + let result = index.lock().get_definitions( + params.text_document_position_params.text_document.uri, + params.text_document_position_params.position, + ); + async move { Ok(result) } + } + }); + } + })), + }, + ) +} + +struct DefinitionIndex { + language: Arc, + definitions: HashMap>, + files: HashMap, +} + +#[derive(Debug)] +struct FileEntry { + contents: String, + is_open_in_buffer: bool, +} + +impl DefinitionIndex { + fn new(language: Arc) -> Self { + Self { + language, + definitions: HashMap::default(), + files: HashMap::default(), + } + } + + fn remove_definitions_for_file(&mut self, uri: &Uri) { + self.definitions.retain(|_, locations| { + locations.retain(|loc| &loc.uri != uri); + !locations.is_empty() + }); + self.files.remove(uri); + } + + fn open_buffer(&mut self, uri: Uri, content: &str) { + self.index_file_inner(uri, content, true); + } + + fn mark_buffer_closed(&mut self, uri: &Uri) { + if let Some(entry) = self.files.get_mut(uri) { + entry.is_open_in_buffer = false; + } + } + + fn is_buffer_open(&self, uri: &Uri) -> bool { + self.files + .get(uri) + .map(|entry| entry.is_open_in_buffer) + .unwrap_or(false) + } + + fn index_file(&mut self, uri: Uri, content: &str) { + self.index_file_inner(uri, content, false); + } + + fn index_file_inner(&mut self, uri: Uri, content: &str, is_open_in_buffer: bool) -> Option<()> { + self.remove_definitions_for_file(&uri); + let grammar = self.language.grammar()?; + let outline_config = grammar.outline_config.as_ref()?; + let mut parser = Parser::new(); + parser.set_language(&grammar.ts_language).ok()?; + let tree = parser.parse(content, None)?; + let declarations = extract_declarations_from_tree(&tree, content, outline_config); + for (name, byte_range) in declarations { + let range = byte_range_to_lsp_range(content, byte_range); + let location = lsp::Location { + uri: uri.clone(), + range, + }; + self.definitions + .entry(name) + .or_insert_with(Vec::new) + .push(location); + } + self.files.insert( + uri, + FileEntry { + contents: content.to_string(), + is_open_in_buffer, + }, + ); + + Some(()) + } + + fn get_definitions( + &mut self, + uri: Uri, + position: lsp::Position, + ) -> Option { + let entry = self.files.get(&uri)?; + let name = word_at_position(&entry.contents, position)?; + let locations = self.definitions.get(name).cloned()?; + Some(lsp::GotoDefinitionResponse::Array(locations)) + } +} + +fn extract_declarations_from_tree( + tree: &Tree, + content: &str, + outline_config: &language::OutlineConfig, +) -> Vec<(String, Range)> { + let mut cursor = QueryCursor::new(); + let mut declarations = Vec::new(); + let mut matches = cursor.matches(&outline_config.query, tree.root_node(), content.as_bytes()); + while let Some(query_match) = matches.next() { + let mut name_range: Option> = None; + let mut has_item_range = false; + + for capture in query_match.captures { + let range = capture.node.byte_range(); + if capture.index == outline_config.name_capture_ix { + name_range = Some(range); + } else if capture.index == outline_config.item_capture_ix { + has_item_range = true; + } + } + + if let Some(name_range) = name_range + && has_item_range + { + let name = content[name_range.clone()].to_string(); + if declarations.iter().any(|(n, _)| n == &name) { + continue; + } + declarations.push((name, name_range)); + } + } + declarations +} + +fn byte_range_to_lsp_range(content: &str, byte_range: Range) -> lsp::Range { + let start = byte_offset_to_position(content, byte_range.start); + let end = byte_offset_to_position(content, byte_range.end); + lsp::Range { start, end } +} + +fn byte_offset_to_position(content: &str, offset: usize) -> lsp::Position { + let mut line = 0; + let mut character = 0; + let mut current_offset = 0; + for ch in content.chars() { + if current_offset >= offset { + break; + } + if ch == '\n' { + line += 1; + character = 0; + } else { + character += 1; + } + current_offset += ch.len_utf8(); + } + lsp::Position { line, character } +} + +fn word_at_position(content: &str, position: lsp::Position) -> Option<&str> { + let mut lines = content.lines(); + let line = lines.nth(position.line as usize)?; + let column = position.character as usize; + if column > line.len() { + return None; + } + let start = line[..column] + .rfind(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + 1) + .unwrap_or(0); + let end = line[column..] + .find(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + column) + .unwrap_or(line.len()); + Some(&line[start..end]).filter(|word| !word.is_empty()) +} diff --git a/crates/edit_prediction_context/src/imports.rs b/crates/edit_prediction_context/src/imports.rs deleted file mode 100644 index 70f175159340ddb9a6f26f23db0c1b3c843e7b96..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/imports.rs +++ /dev/null @@ -1,1319 +0,0 @@ -use collections::HashMap; -use language::BufferSnapshot; -use language::ImportsConfig; -use language::Language; -use std::ops::Deref; -use std::path::Path; -use std::sync::Arc; -use std::{borrow::Cow, ops::Range}; -use text::OffsetRangeExt as _; -use util::RangeExt; -use util::paths::PathStyle; - -use crate::Identifier; -use crate::text_similarity::Occurrences; - -// TODO: Write documentation for extension authors. The @import capture must match before or in the -// same pattern as all all captures it contains - -// Future improvements to consider: -// -// * Distinguish absolute vs relative paths in captures. `#include "maths.h"` is relative whereas -// `#include ` is not. -// -// * Provide the name used when importing whole modules (see tests with "named_module" in the name). -// To be useful, will require parsing of identifier qualification. -// -// * Scoping for imports that aren't at the top level -// -// * Only scan a prefix of the file, when possible. This could look like having query matches that -// indicate it reached a declaration that is not allowed in the import section. -// -// * Support directly parsing to occurrences instead of storing namespaces / paths. Types should be -// generic on this, so that tests etc can still use strings. Could do similar in syntax index. -// -// * Distinguish different types of namespaces when known. E.g. "name.type" capture. Once capture -// names are more open-ended like this may make sense to build and cache a jump table (direct -// dispatch from capture index). -// -// * There are a few "Language specific:" comments on behavior that gets applied to all languages. -// Would be cleaner to be conditional on the language or otherwise configured. - -#[derive(Debug, Clone, Default)] -pub struct Imports { - pub identifier_to_imports: HashMap>, - pub wildcard_modules: Vec, -} - -#[derive(Debug, Clone)] -pub enum Import { - Direct { - module: Module, - }, - Alias { - module: Module, - external_identifier: Identifier, - }, -} - -#[derive(Debug, Clone)] -pub enum Module { - SourceExact(Arc), - SourceFuzzy(Arc), - Namespace(Namespace), -} - -impl Module { - fn empty() -> Self { - Module::Namespace(Namespace::default()) - } - - fn push_range( - &mut self, - range: &ModuleRange, - snapshot: &BufferSnapshot, - language: &Language, - parent_abs_path: Option<&Path>, - ) -> usize { - if range.is_empty() { - return 0; - } - - match range { - ModuleRange::Source(range) => { - if let Self::Namespace(namespace) = self - && namespace.0.is_empty() - { - let path = snapshot.text_for_range(range.clone()).collect::>(); - - let path = if let Some(strip_regex) = - language.config().import_path_strip_regex.as_ref() - { - strip_regex.replace_all(&path, "") - } else { - path - }; - - let path = Path::new(path.as_ref()); - if (path.starts_with(".") || path.starts_with("..")) - && let Some(parent_abs_path) = parent_abs_path - && let Ok(abs_path) = - util::paths::normalize_lexically(&parent_abs_path.join(path)) - { - *self = Self::SourceExact(abs_path.into()); - } else { - *self = Self::SourceFuzzy(path.into()); - }; - } else if matches!(self, Self::SourceExact(_)) - || matches!(self, Self::SourceFuzzy(_)) - { - log::warn!("bug in imports query: encountered multiple @source matches"); - } else { - log::warn!( - "bug in imports query: encountered both @namespace and @source match" - ); - } - } - ModuleRange::Namespace(range) => { - if let Self::Namespace(namespace) = self { - let segment = range_text(snapshot, range); - if language.config().ignored_import_segments.contains(&segment) { - return 0; - } else { - namespace.0.push(segment); - return 1; - } - } else { - log::warn!( - "bug in imports query: encountered both @namespace and @source match" - ); - } - } - } - 0 - } -} - -#[derive(Debug, Clone)] -enum ModuleRange { - Source(Range), - Namespace(Range), -} - -impl Deref for ModuleRange { - type Target = Range; - - fn deref(&self) -> &Self::Target { - match self { - ModuleRange::Source(range) => range, - ModuleRange::Namespace(range) => range, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct Namespace(pub Vec>); - -impl Namespace { - pub fn occurrences(&self) -> Occurrences { - Occurrences::from_identifiers(&self.0) - } -} - -impl Imports { - pub fn gather(snapshot: &BufferSnapshot, parent_abs_path: Option<&Path>) -> Self { - // Query to match different import patterns - let mut matches = snapshot - .syntax - .matches(0..snapshot.len(), &snapshot.text, |grammar| { - grammar.imports_config().map(|imports| &imports.query) - }); - - let mut detached_nodes: Vec = Vec::new(); - let mut identifier_to_imports = HashMap::default(); - let mut wildcard_modules = Vec::new(); - let mut import_range = None; - - while let Some(query_match) = matches.peek() { - let ImportsConfig { - query: _, - import_ix, - name_ix, - namespace_ix, - source_ix, - list_ix, - wildcard_ix, - alias_ix, - } = matches.grammars()[query_match.grammar_index] - .imports_config() - .unwrap(); - - let mut new_import_range = None; - let mut alias_range = None; - let mut modules = Vec::new(); - let mut content: Option<(Range, ContentKind)> = None; - for capture in query_match.captures { - let capture_range = capture.node.byte_range(); - - if capture.index == *import_ix { - new_import_range = Some(capture_range); - } else if Some(capture.index) == *namespace_ix { - modules.push(ModuleRange::Namespace(capture_range)); - } else if Some(capture.index) == *source_ix { - modules.push(ModuleRange::Source(capture_range)); - } else if Some(capture.index) == *alias_ix { - alias_range = Some(capture_range); - } else { - let mut found_content = None; - if Some(capture.index) == *name_ix { - found_content = Some((capture_range, ContentKind::Name)); - } else if Some(capture.index) == *list_ix { - found_content = Some((capture_range, ContentKind::List)); - } else if Some(capture.index) == *wildcard_ix { - found_content = Some((capture_range, ContentKind::Wildcard)); - } - if let Some((found_content_range, found_kind)) = found_content { - if let Some((_, old_kind)) = content { - let point = found_content_range.to_point(snapshot); - log::warn!( - "bug in {} imports query: unexpected multiple captures of {} and {} ({}:{}:{})", - query_match.language.name(), - old_kind.capture_name(), - found_kind.capture_name(), - snapshot - .file() - .map(|p| p.path().display(PathStyle::Posix)) - .unwrap_or_default(), - point.start.row + 1, - point.start.column + 1 - ); - } - content = Some((found_content_range, found_kind)); - } - } - } - - if let Some(new_import_range) = new_import_range { - log::trace!("starting new import {:?}", new_import_range); - Self::gather_from_import_statement( - &detached_nodes, - &snapshot, - parent_abs_path, - &mut identifier_to_imports, - &mut wildcard_modules, - ); - detached_nodes.clear(); - import_range = Some(new_import_range.clone()); - } - - if let Some((content, content_kind)) = content { - if import_range - .as_ref() - .is_some_and(|import_range| import_range.contains_inclusive(&content)) - { - detached_nodes.push(DetachedNode { - modules, - content: content.clone(), - content_kind, - alias: alias_range.unwrap_or(0..0), - language: query_match.language.clone(), - }); - } else { - log::trace!( - "filtered out match not inside import range: {content_kind:?} at {content:?}" - ); - } - } - - matches.advance(); - } - - Self::gather_from_import_statement( - &detached_nodes, - &snapshot, - parent_abs_path, - &mut identifier_to_imports, - &mut wildcard_modules, - ); - - Imports { - identifier_to_imports, - wildcard_modules, - } - } - - fn gather_from_import_statement( - detached_nodes: &[DetachedNode], - snapshot: &BufferSnapshot, - parent_abs_path: Option<&Path>, - identifier_to_imports: &mut HashMap>, - wildcard_modules: &mut Vec, - ) { - let mut trees = Vec::new(); - - for detached_node in detached_nodes { - if let Some(node) = Self::attach_node(detached_node.into(), &mut trees) { - trees.push(node); - } - log::trace!( - "Attached node to tree\n{:#?}\nAttach result:\n{:#?}", - detached_node, - trees - .iter() - .map(|tree| tree.debug(snapshot)) - .collect::>() - ); - } - - for tree in &trees { - let mut module = Module::empty(); - Self::gather_from_tree( - tree, - snapshot, - parent_abs_path, - &mut module, - identifier_to_imports, - wildcard_modules, - ); - } - } - - fn attach_node(mut node: ImportTree, trees: &mut Vec) -> Option { - let mut tree_index = 0; - while tree_index < trees.len() { - let tree = &mut trees[tree_index]; - if !node.content.is_empty() && node.content == tree.content { - // multiple matches can apply to the same name/list/wildcard. This keeps the queries - // simpler by combining info from these matches. - if tree.module.is_empty() { - tree.module = node.module; - tree.module_children = node.module_children; - } - if tree.alias.is_empty() { - tree.alias = node.alias; - } - return None; - } else if !node.module.is_empty() && node.module.contains_inclusive(&tree.range()) { - node.module_children.push(trees.remove(tree_index)); - continue; - } else if !node.content.is_empty() && node.content.contains_inclusive(&tree.content) { - node.content_children.push(trees.remove(tree_index)); - continue; - } else if !tree.content.is_empty() && tree.content.contains_inclusive(&node.content) { - if let Some(node) = Self::attach_node(node, &mut tree.content_children) { - tree.content_children.push(node); - } - return None; - } - tree_index += 1; - } - Some(node) - } - - fn gather_from_tree( - tree: &ImportTree, - snapshot: &BufferSnapshot, - parent_abs_path: Option<&Path>, - current_module: &mut Module, - identifier_to_imports: &mut HashMap>, - wildcard_modules: &mut Vec, - ) { - let mut pop_count = 0; - - if tree.module_children.is_empty() { - pop_count += - current_module.push_range(&tree.module, snapshot, &tree.language, parent_abs_path); - } else { - for child in &tree.module_children { - pop_count += Self::extend_namespace_from_tree( - child, - snapshot, - parent_abs_path, - current_module, - ); - } - }; - - if tree.content_children.is_empty() && !tree.content.is_empty() { - match tree.content_kind { - ContentKind::Name | ContentKind::List => { - if tree.alias.is_empty() { - identifier_to_imports - .entry(Identifier { - language_id: tree.language.id(), - name: range_text(snapshot, &tree.content), - }) - .or_default() - .push(Import::Direct { - module: current_module.clone(), - }); - } else { - let alias_name: Arc = range_text(snapshot, &tree.alias); - let external_name = range_text(snapshot, &tree.content); - // Language specific: skip "_" aliases for Rust - if alias_name.as_ref() != "_" { - identifier_to_imports - .entry(Identifier { - language_id: tree.language.id(), - name: alias_name, - }) - .or_default() - .push(Import::Alias { - module: current_module.clone(), - external_identifier: Identifier { - language_id: tree.language.id(), - name: external_name, - }, - }); - } - } - } - ContentKind::Wildcard => wildcard_modules.push(current_module.clone()), - } - } else { - for child in &tree.content_children { - Self::gather_from_tree( - child, - snapshot, - parent_abs_path, - current_module, - identifier_to_imports, - wildcard_modules, - ); - } - } - - if pop_count > 0 { - match current_module { - Module::SourceExact(_) | Module::SourceFuzzy(_) => { - log::warn!( - "bug in imports query: encountered both @namespace and @source match" - ); - } - Module::Namespace(namespace) => { - namespace.0.drain(namespace.0.len() - pop_count..); - } - } - } - } - - fn extend_namespace_from_tree( - tree: &ImportTree, - snapshot: &BufferSnapshot, - parent_abs_path: Option<&Path>, - module: &mut Module, - ) -> usize { - let mut pop_count = 0; - if tree.module_children.is_empty() { - pop_count += module.push_range(&tree.module, snapshot, &tree.language, parent_abs_path); - } else { - for child in &tree.module_children { - pop_count += - Self::extend_namespace_from_tree(child, snapshot, parent_abs_path, module); - } - } - if tree.content_children.is_empty() { - pop_count += module.push_range( - &ModuleRange::Namespace(tree.content.clone()), - snapshot, - &tree.language, - parent_abs_path, - ); - } else { - for child in &tree.content_children { - pop_count += - Self::extend_namespace_from_tree(child, snapshot, parent_abs_path, module); - } - } - pop_count - } -} - -fn range_text(snapshot: &BufferSnapshot, range: &Range) -> Arc { - snapshot - .text_for_range(range.clone()) - .collect::>() - .into() -} - -#[derive(Debug)] -struct DetachedNode { - modules: Vec, - content: Range, - content_kind: ContentKind, - alias: Range, - language: Arc, -} - -#[derive(Debug, Clone, Copy)] -enum ContentKind { - Name, - Wildcard, - List, -} - -impl ContentKind { - fn capture_name(&self) -> &'static str { - match self { - ContentKind::Name => "name", - ContentKind::Wildcard => "wildcard", - ContentKind::List => "list", - } - } -} - -#[derive(Debug)] -struct ImportTree { - module: ModuleRange, - /// When non-empty, provides namespace / source info which should be used instead of `module`. - module_children: Vec, - content: Range, - /// When non-empty, provides content which should be used instead of `content`. - content_children: Vec, - content_kind: ContentKind, - alias: Range, - language: Arc, -} - -impl ImportTree { - fn range(&self) -> Range { - self.module.start.min(self.content.start)..self.module.end.max(self.content.end) - } - - #[allow(dead_code)] - fn debug<'a>(&'a self, snapshot: &'a BufferSnapshot) -> ImportTreeDebug<'a> { - ImportTreeDebug { - tree: self, - snapshot, - } - } - - fn from_module_range(module: &ModuleRange, language: Arc) -> Self { - ImportTree { - module: module.clone(), - module_children: Vec::new(), - content: 0..0, - content_children: Vec::new(), - content_kind: ContentKind::Name, - alias: 0..0, - language, - } - } -} - -impl From<&DetachedNode> for ImportTree { - fn from(value: &DetachedNode) -> Self { - let module; - let module_children; - match value.modules.len() { - 0 => { - module = ModuleRange::Namespace(0..0); - module_children = Vec::new(); - } - 1 => { - module = value.modules[0].clone(); - module_children = Vec::new(); - } - _ => { - module = ModuleRange::Namespace( - value.modules.first().unwrap().start..value.modules.last().unwrap().end, - ); - module_children = value - .modules - .iter() - .map(|module| ImportTree::from_module_range(module, value.language.clone())) - .collect(); - } - } - - ImportTree { - module, - module_children, - content: value.content.clone(), - content_children: Vec::new(), - content_kind: value.content_kind, - alias: value.alias.clone(), - language: value.language.clone(), - } - } -} - -struct ImportTreeDebug<'a> { - tree: &'a ImportTree, - snapshot: &'a BufferSnapshot, -} - -impl std::fmt::Debug for ImportTreeDebug<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ImportTree") - .field("module_range", &self.tree.module) - .field("module_text", &range_text(self.snapshot, &self.tree.module)) - .field( - "module_children", - &self - .tree - .module_children - .iter() - .map(|child| child.debug(&self.snapshot)) - .collect::>(), - ) - .field("content_range", &self.tree.content) - .field( - "content_text", - &range_text(self.snapshot, &self.tree.content), - ) - .field( - "content_children", - &self - .tree - .content_children - .iter() - .map(|child| child.debug(&self.snapshot)) - .collect::>(), - ) - .field("content_kind", &self.tree.content_kind) - .field("alias_range", &self.tree.alias) - .field("alias_text", &range_text(self.snapshot, &self.tree.alias)) - .finish() - } -} - -#[cfg(test)] -mod test { - use std::path::PathBuf; - use std::sync::{Arc, LazyLock}; - - use super::*; - use collections::HashSet; - use gpui::{TestAppContext, prelude::*}; - use indoc::indoc; - use language::{ - Buffer, Language, LanguageConfig, tree_sitter_python, tree_sitter_rust, - tree_sitter_typescript, - }; - use regex::Regex; - - #[gpui::test] - fn test_rust_simple(cx: &mut TestAppContext) { - check_imports( - &RUST, - "use std::collections::HashMap;", - &[&["std", "collections", "HashMap"]], - cx, - ); - - check_imports( - &RUST, - "pub use std::collections::HashMap;", - &[&["std", "collections", "HashMap"]], - cx, - ); - - check_imports( - &RUST, - "use std::collections::{HashMap, HashSet};", - &[ - &["std", "collections", "HashMap"], - &["std", "collections", "HashSet"], - ], - cx, - ); - } - - #[gpui::test] - fn test_rust_nested(cx: &mut TestAppContext) { - check_imports( - &RUST, - "use std::{any::TypeId, collections::{HashMap, HashSet}};", - &[ - &["std", "any", "TypeId"], - &["std", "collections", "HashMap"], - &["std", "collections", "HashSet"], - ], - cx, - ); - - check_imports( - &RUST, - "use a::b::c::{d::e::F, g::h::I};", - &[ - &["a", "b", "c", "d", "e", "F"], - &["a", "b", "c", "g", "h", "I"], - ], - cx, - ); - } - - #[gpui::test] - fn test_rust_multiple_imports(cx: &mut TestAppContext) { - check_imports( - &RUST, - indoc! {" - use std::collections::HashMap; - use std::any::{TypeId, Any}; - "}, - &[ - &["std", "collections", "HashMap"], - &["std", "any", "TypeId"], - &["std", "any", "Any"], - ], - cx, - ); - - check_imports( - &RUST, - indoc! {" - use std::collections::HashSet; - - fn main() { - let unqualified = HashSet::new(); - let qualified = std::collections::HashMap::new(); - } - - use std::any::TypeId; - "}, - &[ - &["std", "collections", "HashSet"], - &["std", "any", "TypeId"], - ], - cx, - ); - } - - #[gpui::test] - fn test_rust_wildcard(cx: &mut TestAppContext) { - check_imports(&RUST, "use prelude::*;", &[&["prelude", "WILDCARD"]], cx); - - check_imports( - &RUST, - "use zed::prelude::*;", - &[&["zed", "prelude", "WILDCARD"]], - cx, - ); - - check_imports(&RUST, "use prelude::{*};", &[&["prelude", "WILDCARD"]], cx); - - check_imports( - &RUST, - "use prelude::{File, *};", - &[&["prelude", "File"], &["prelude", "WILDCARD"]], - cx, - ); - - check_imports( - &RUST, - "use zed::{App, prelude::*};", - &[&["zed", "App"], &["zed", "prelude", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_rust_alias(cx: &mut TestAppContext) { - check_imports( - &RUST, - "use std::io::Result as IoResult;", - &[&["std", "io", "Result AS IoResult"]], - cx, - ); - } - - #[gpui::test] - fn test_rust_crate_and_super(cx: &mut TestAppContext) { - check_imports(&RUST, "use crate::a::b::c;", &[&["a", "b", "c"]], cx); - check_imports(&RUST, "use super::a::b::c;", &[&["a", "b", "c"]], cx); - // TODO: Consider stripping leading "::". Not done for now because for the text similarity matching usecase this - // is fine. - check_imports(&RUST, "use ::a::b::c;", &[&["::a", "b", "c"]], cx); - } - - #[gpui::test] - fn test_typescript_imports(cx: &mut TestAppContext) { - let parent_abs_path = PathBuf::from("/home/user/project"); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import "./maths.js";"#, - &[&["SOURCE /home/user/project/maths", "WILDCARD"]], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import "../maths.js";"#, - &[&["SOURCE /home/user/maths", "WILDCARD"]], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import RandomNumberGenerator, { pi as π } from "./maths.js";"#, - &[ - &["SOURCE /home/user/project/maths", "RandomNumberGenerator"], - &["SOURCE /home/user/project/maths", "pi AS π"], - ], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { pi, phi, absolute } from "./maths.js";"#, - &[ - &["SOURCE /home/user/project/maths", "pi"], - &["SOURCE /home/user/project/maths", "phi"], - &["SOURCE /home/user/project/maths", "absolute"], - ], - cx, - ); - - // index.js is removed by import_path_strip_regex - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { pi, phi, absolute } from "./maths/index.js";"#, - &[ - &["SOURCE /home/user/project/maths", "pi"], - &["SOURCE /home/user/project/maths", "phi"], - &["SOURCE /home/user/project/maths", "absolute"], - ], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import type { SomeThing } from "./some-module.js";"#, - &[&["SOURCE /home/user/project/some-module", "SomeThing"]], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { type SomeThing, OtherThing } from "./some-module.js";"#, - &[ - &["SOURCE /home/user/project/some-module", "SomeThing"], - &["SOURCE /home/user/project/some-module", "OtherThing"], - ], - cx, - ); - - // index.js is removed by import_path_strip_regex - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { type SomeThing, OtherThing } from "./some-module/index.js";"#, - &[ - &["SOURCE /home/user/project/some-module", "SomeThing"], - &["SOURCE /home/user/project/some-module", "OtherThing"], - ], - cx, - ); - - // fuzzy paths - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { type SomeThing, OtherThing } from "@my-app/some-module.js";"#, - &[ - &["SOURCE FUZZY @my-app/some-module", "SomeThing"], - &["SOURCE FUZZY @my-app/some-module", "OtherThing"], - ], - cx, - ); - } - - #[gpui::test] - fn test_typescript_named_module_imports(cx: &mut TestAppContext) { - let parent_abs_path = PathBuf::from("/home/user/project"); - - // TODO: These should provide the name that the module is bound to. - // For now instead these are treated as unqualified wildcard imports. - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import * as math from "./maths.js";"#, - // &[&["/home/user/project/maths.js", "WILDCARD AS math"]], - &[&["SOURCE /home/user/project/maths", "WILDCARD"]], - cx, - ); - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import math = require("./maths");"#, - // &[&["/home/user/project/maths", "WILDCARD AS math"]], - &[&["SOURCE /home/user/project/maths", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_python_imports(cx: &mut TestAppContext) { - check_imports(&PYTHON, "from math import pi", &[&["math", "pi"]], cx); - - check_imports( - &PYTHON, - "from math import pi, sin, cos", - &[&["math", "pi"], &["math", "sin"], &["math", "cos"]], - cx, - ); - - check_imports(&PYTHON, "from math import *", &[&["math", "WILDCARD"]], cx); - - check_imports( - &PYTHON, - "from math import foo.bar.baz", - &[&["math", "foo", "bar", "baz"]], - cx, - ); - - check_imports( - &PYTHON, - "from math import pi as PI", - &[&["math", "pi AS PI"]], - cx, - ); - - check_imports( - &PYTHON, - "from serializers.json import JsonSerializer", - &[&["serializers", "json", "JsonSerializer"]], - cx, - ); - - check_imports( - &PYTHON, - "from custom.serializers import json, xml, yaml", - &[ - &["custom", "serializers", "json"], - &["custom", "serializers", "xml"], - &["custom", "serializers", "yaml"], - ], - cx, - ); - } - - #[gpui::test] - fn test_python_named_module_imports(cx: &mut TestAppContext) { - // TODO: These should provide the name that the module is bound to. - // For now instead these are treated as unqualified wildcard imports. - // - // check_imports(&PYTHON, "import math", &[&["math", "WILDCARD as math"]], cx); - // check_imports(&PYTHON, "import math as maths", &[&["math", "WILDCARD AS maths"]], cx); - // - // Something like: - // - // (import_statement - // name: [ - // (dotted_name - // (identifier)* @namespace - // (identifier) @name.module .) - // (aliased_import - // name: (dotted_name - // ((identifier) ".")* @namespace - // (identifier) @name.module .) - // alias: (identifier) @alias) - // ]) @import - - check_imports(&PYTHON, "import math", &[&["math", "WILDCARD"]], cx); - - check_imports( - &PYTHON, - "import math as maths", - &[&["math", "WILDCARD"]], - cx, - ); - - check_imports(&PYTHON, "import a.b.c", &[&["a", "b", "c", "WILDCARD"]], cx); - - check_imports( - &PYTHON, - "import a.b.c as d", - &[&["a", "b", "c", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_python_package_relative_imports(cx: &mut TestAppContext) { - // TODO: These should provide info about the dir they are relative to, to provide more - // precise resolution. Instead, fuzzy matching is used as usual. - - check_imports(&PYTHON, "from . import math", &[&["math"]], cx); - - check_imports(&PYTHON, "from .a import math", &[&["a", "math"]], cx); - - check_imports( - &PYTHON, - "from ..a.b import math", - &[&["a", "b", "math"]], - cx, - ); - - check_imports( - &PYTHON, - "from ..a.b import *", - &[&["a", "b", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_c_imports(cx: &mut TestAppContext) { - let parent_abs_path = PathBuf::from("/home/user/project"); - - // TODO: Distinguish that these are not relative to current path - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &C, - r#"#include "#, - &[&["SOURCE FUZZY math.h", "WILDCARD"]], - cx, - ); - - // TODO: These should be treated as relative, but don't start with ./ or ../ - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &C, - r#"#include "math.h""#, - &[&["SOURCE FUZZY math.h", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_cpp_imports(cx: &mut TestAppContext) { - let parent_abs_path = PathBuf::from("/home/user/project"); - - // TODO: Distinguish that these are not relative to current path - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &CPP, - r#"#include "#, - &[&["SOURCE FUZZY math.h", "WILDCARD"]], - cx, - ); - - // TODO: These should be treated as relative, but don't start with ./ or ../ - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &CPP, - r#"#include "math.h""#, - &[&["SOURCE FUZZY math.h", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_go_imports(cx: &mut TestAppContext) { - check_imports( - &GO, - r#"import . "lib/math""#, - &[&["lib/math", "WILDCARD"]], - cx, - ); - - // not included, these are only for side-effects - check_imports(&GO, r#"import _ "lib/math""#, &[], cx); - } - - #[gpui::test] - fn test_go_named_module_imports(cx: &mut TestAppContext) { - // TODO: These should provide the name that the module is bound to. - // For now instead these are treated as unqualified wildcard imports. - - check_imports( - &GO, - r#"import "lib/math""#, - &[&["lib/math", "WILDCARD"]], - cx, - ); - check_imports( - &GO, - r#"import m "lib/math""#, - &[&["lib/math", "WILDCARD"]], - cx, - ); - } - - #[track_caller] - fn check_imports( - language: &Arc, - source: &str, - expected: &[&[&str]], - cx: &mut TestAppContext, - ) { - check_imports_with_file_abs_path(None, language, source, expected, cx); - } - - #[track_caller] - fn check_imports_with_file_abs_path( - parent_abs_path: Option<&Path>, - language: &Arc, - source: &str, - expected: &[&[&str]], - cx: &mut TestAppContext, - ) { - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(source, cx); - buffer.set_language(Some(language.clone()), cx); - buffer - }); - cx.run_until_parked(); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - - let imports = Imports::gather(&snapshot, parent_abs_path); - let mut actual_symbols = imports - .identifier_to_imports - .iter() - .flat_map(|(identifier, imports)| { - imports - .iter() - .map(|import| import.to_identifier_parts(identifier.name.as_ref())) - }) - .chain( - imports - .wildcard_modules - .iter() - .map(|module| module.to_identifier_parts("WILDCARD")), - ) - .collect::>(); - let mut expected_symbols = expected - .iter() - .map(|expected| expected.iter().map(|s| s.to_string()).collect::>()) - .collect::>(); - actual_symbols.sort(); - expected_symbols.sort(); - if actual_symbols != expected_symbols { - let top_layer = snapshot.syntax_layers().next().unwrap(); - panic!( - "Expected imports: {:?}\n\ - Actual imports: {:?}\n\ - Tree:\n{}", - expected_symbols, - actual_symbols, - tree_to_string(&top_layer.node()), - ); - } - } - - fn tree_to_string(node: &tree_sitter::Node) -> String { - let mut cursor = node.walk(); - let mut result = String::new(); - let mut depth = 0; - 'outer: loop { - result.push_str(&" ".repeat(depth)); - if let Some(field_name) = cursor.field_name() { - result.push_str(field_name); - result.push_str(": "); - } - if cursor.node().is_named() { - result.push_str(cursor.node().kind()); - } else { - result.push('"'); - result.push_str(cursor.node().kind()); - result.push('"'); - } - result.push('\n'); - - if cursor.goto_first_child() { - depth += 1; - continue; - } - if cursor.goto_next_sibling() { - continue; - } - while cursor.goto_parent() { - depth -= 1; - if cursor.goto_next_sibling() { - continue 'outer; - } - } - break; - } - result - } - - static RUST: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "Rust".into(), - ignored_import_segments: HashSet::from_iter(["crate".into(), "super".into()]), - import_path_strip_regex: Some(Regex::new("/(lib|mod)\\.rs$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/rust/imports.scm")) - .unwrap(), - ) - }); - - static TYPESCRIPT: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "TypeScript".into(), - import_path_strip_regex: Some(Regex::new("(?:/index)?\\.[jt]s$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), - ) - .with_imports_query(include_str!("../../languages/src/typescript/imports.scm")) - .unwrap(), - ) - }); - - static PYTHON: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "Python".into(), - import_path_strip_regex: Some(Regex::new("/__init__\\.py$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_python::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/python/imports.scm")) - .unwrap(), - ) - }); - - // TODO: Ideally should use actual language configurations - static C: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "C".into(), - import_path_strip_regex: Some(Regex::new("^<|>$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_c::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/c/imports.scm")) - .unwrap(), - ) - }); - - static CPP: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "C++".into(), - import_path_strip_regex: Some(Regex::new("^<|>$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_cpp::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/cpp/imports.scm")) - .unwrap(), - ) - }); - - static GO: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "Go".into(), - ..Default::default() - }, - Some(tree_sitter_go::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/go/imports.scm")) - .unwrap(), - ) - }); - - impl Import { - fn to_identifier_parts(&self, identifier: &str) -> Vec { - match self { - Import::Direct { module } => module.to_identifier_parts(identifier), - Import::Alias { - module, - external_identifier: external_name, - } => { - module.to_identifier_parts(&format!("{} AS {}", external_name.name, identifier)) - } - } - } - } - - impl Module { - fn to_identifier_parts(&self, identifier: &str) -> Vec { - match self { - Self::Namespace(namespace) => namespace.to_identifier_parts(identifier), - Self::SourceExact(path) => { - vec![ - format!("SOURCE {}", path.display().to_string().replace("\\", "/")), - identifier.to_string(), - ] - } - Self::SourceFuzzy(path) => { - vec![ - format!( - "SOURCE FUZZY {}", - path.display().to_string().replace("\\", "/") - ), - identifier.to_string(), - ] - } - } - } - } - - impl Namespace { - fn to_identifier_parts(&self, identifier: &str) -> Vec { - self.0 - .iter() - .map(|chunk| chunk.to_string()) - .chain(std::iter::once(identifier.to_string())) - .collect::>() - } - } -} diff --git a/crates/edit_prediction_context/src/outline.rs b/crates/edit_prediction_context/src/outline.rs deleted file mode 100644 index ec02c869dfae4cb861206cb801c285462e734f36..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/outline.rs +++ /dev/null @@ -1,126 +0,0 @@ -use language::{BufferSnapshot, SyntaxMapMatches}; -use std::{cmp::Reverse, ops::Range}; - -use crate::declaration::Identifier; - -// TODO: -// -// * how to handle multiple name captures? for now last one wins -// -// * annotation ranges -// -// * new "signature" capture for outline queries -// -// * Check parent behavior of "int x, y = 0" declarations in a test - -pub struct OutlineDeclaration { - pub parent_index: Option, - pub identifier: Identifier, - pub item_range: Range, - pub signature_range: Range, -} - -pub fn declarations_in_buffer(buffer: &BufferSnapshot) -> Vec { - declarations_overlapping_range(0..buffer.len(), buffer) -} - -pub fn declarations_overlapping_range( - range: Range, - buffer: &BufferSnapshot, -) -> Vec { - let mut declarations = OutlineIterator::new(range, buffer).collect::>(); - declarations.sort_unstable_by_key(|item| (item.item_range.start, Reverse(item.item_range.end))); - - let mut parent_stack: Vec<(usize, Range)> = Vec::new(); - for (index, declaration) in declarations.iter_mut().enumerate() { - while let Some((top_parent_index, top_parent_range)) = parent_stack.last() { - if declaration.item_range.start >= top_parent_range.end { - parent_stack.pop(); - } else { - declaration.parent_index = Some(*top_parent_index); - break; - } - } - parent_stack.push((index, declaration.item_range.clone())); - } - declarations -} - -/// Iterates outline items without being ordered w.r.t. nested items and without populating -/// `parent`. -pub struct OutlineIterator<'a> { - buffer: &'a BufferSnapshot, - matches: SyntaxMapMatches<'a>, -} - -impl<'a> OutlineIterator<'a> { - pub fn new(range: Range, buffer: &'a BufferSnapshot) -> Self { - let matches = buffer.syntax.matches(range, &buffer.text, |grammar| { - grammar.outline_config.as_ref().map(|c| &c.query) - }); - - Self { buffer, matches } - } -} - -impl<'a> Iterator for OutlineIterator<'a> { - type Item = OutlineDeclaration; - - fn next(&mut self) -> Option { - while let Some(mat) = self.matches.peek() { - let config = self.matches.grammars()[mat.grammar_index] - .outline_config - .as_ref() - .unwrap(); - - let mut name_range = None; - let mut item_range = None; - let mut signature_start = None; - let mut signature_end = None; - - let mut add_to_signature = |range: Range| { - if signature_start.is_none() { - signature_start = Some(range.start); - } - signature_end = Some(range.end); - }; - - for capture in mat.captures { - let range = capture.node.byte_range(); - if capture.index == config.name_capture_ix { - name_range = Some(range.clone()); - add_to_signature(range); - } else if Some(capture.index) == config.context_capture_ix - || Some(capture.index) == config.extra_context_capture_ix - { - add_to_signature(range); - } else if capture.index == config.item_capture_ix { - item_range = Some(range.clone()); - } - } - - let language_id = mat.language.id(); - self.matches.advance(); - - if let Some(name_range) = name_range - && let Some(item_range) = item_range - && let Some(signature_start) = signature_start - && let Some(signature_end) = signature_end - { - let name = self - .buffer - .text_for_range(name_range) - .collect::() - .into(); - - return Some(OutlineDeclaration { - identifier: Identifier { name, language_id }, - item_range: item_range, - signature_range: signature_start..signature_end, - parent_index: None, - }); - } - } - None - } -} diff --git a/crates/edit_prediction_context/src/reference.rs b/crates/edit_prediction_context/src/reference.rs deleted file mode 100644 index 699adf1d8036802a7a4b9e34ca8e8094e4f97458..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/reference.rs +++ /dev/null @@ -1,173 +0,0 @@ -use collections::HashMap; -use language::BufferSnapshot; -use std::ops::Range; -use util::RangeExt; - -use crate::{ - declaration::Identifier, - excerpt::{EditPredictionExcerpt, EditPredictionExcerptText}, -}; - -#[derive(Debug, Clone)] -pub struct Reference { - pub identifier: Identifier, - pub range: Range, - pub region: ReferenceRegion, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum ReferenceRegion { - Breadcrumb, - Nearby, -} - -pub fn references_in_excerpt( - excerpt: &EditPredictionExcerpt, - excerpt_text: &EditPredictionExcerptText, - snapshot: &BufferSnapshot, -) -> HashMap> { - let mut references = references_in_range( - excerpt.range.clone(), - excerpt_text.body.as_str(), - ReferenceRegion::Nearby, - snapshot, - ); - - for ((_, range), text) in excerpt - .parent_declarations - .iter() - .zip(excerpt_text.parent_signatures.iter()) - { - references.extend(references_in_range( - range.clone(), - text.as_str(), - ReferenceRegion::Breadcrumb, - snapshot, - )); - } - - let mut identifier_to_references: HashMap> = HashMap::default(); - for reference in references { - identifier_to_references - .entry(reference.identifier.clone()) - .or_insert_with(Vec::new) - .push(reference); - } - identifier_to_references -} - -/// Finds all nodes which have a "variable" match from the highlights query within the offset range. -pub fn references_in_range( - range: Range, - range_text: &str, - reference_region: ReferenceRegion, - buffer: &BufferSnapshot, -) -> Vec { - let mut matches = buffer - .syntax - .matches(range.clone(), &buffer.text, |grammar| { - grammar - .highlights_config - .as_ref() - .map(|config| &config.query) - }); - - let mut references = Vec::new(); - let mut last_added_range = None; - while let Some(mat) = matches.peek() { - let config = matches.grammars()[mat.grammar_index] - .highlights_config - .as_ref(); - - if let Some(config) = config { - for capture in mat.captures { - if config.identifier_capture_indices.contains(&capture.index) { - let node_range = capture.node.byte_range(); - - // sometimes multiple highlight queries match - this deduplicates them - if Some(node_range.clone()) == last_added_range { - continue; - } - - if !range.contains_inclusive(&node_range) { - continue; - } - - let identifier_text = - &range_text[node_range.start - range.start..node_range.end - range.start]; - - references.push(Reference { - identifier: Identifier { - name: identifier_text.into(), - language_id: mat.language.id(), - }, - range: node_range.clone(), - region: reference_region, - }); - last_added_range = Some(node_range); - } - } - } - - matches.advance(); - } - references -} - -#[cfg(test)] -mod test { - use gpui::{TestAppContext, prelude::*}; - use indoc::indoc; - use language::{BufferSnapshot, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; - - use crate::reference::{ReferenceRegion, references_in_range}; - - #[gpui::test] - fn test_identifier_node_truncated(cx: &mut TestAppContext) { - let code = indoc! { r#" - fn main() { - add(1, 2); - } - - fn add(a: i32, b: i32) -> i32 { - a + b - } - "# }; - let buffer = create_buffer(code, cx); - - let range = 0..35; - let references = references_in_range( - range.clone(), - &code[range], - ReferenceRegion::Breadcrumb, - &buffer, - ); - assert_eq!(references.len(), 2); - assert_eq!(references[0].identifier.name.as_ref(), "main"); - assert_eq!(references[1].identifier.name.as_ref(), "add"); - } - - fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot { - let buffer = - cx.new(|cx| language::Buffer::local(text, cx).with_language(rust_lang().into(), cx)); - buffer.read_with(cx, |buffer, _| buffer.snapshot()) - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_highlights_query(include_str!("../../languages/src/rust/highlights.scm")) - .unwrap() - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } -} diff --git a/crates/edit_prediction_context/src/syntax_index.rs b/crates/edit_prediction_context/src/syntax_index.rs deleted file mode 100644 index f489a083341b66c7cca3cdad76a9c7ea16fdc959..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/syntax_index.rs +++ /dev/null @@ -1,1069 +0,0 @@ -use anyhow::{Result, anyhow}; -use collections::{HashMap, HashSet}; -use futures::channel::mpsc; -use futures::lock::Mutex; -use futures::{FutureExt as _, StreamExt, future}; -use gpui::{App, AppContext as _, AsyncApp, Context, Entity, Task, WeakEntity}; -use itertools::Itertools; - -use language::{Buffer, BufferEvent}; -use postage::stream::Stream as _; -use project::buffer_store::{BufferStore, BufferStoreEvent}; -use project::worktree_store::{WorktreeStore, WorktreeStoreEvent}; -use project::{PathChange, Project, ProjectEntryId, ProjectPath}; -use slotmap::SlotMap; -use std::iter; -use std::ops::{DerefMut, Range}; -use std::sync::Arc; -use text::BufferId; -use util::{RangeExt as _, debug_panic, some_or_debug_panic}; - -use crate::CachedDeclarationPath; -use crate::declaration::{ - BufferDeclaration, Declaration, DeclarationId, FileDeclaration, Identifier, -}; -use crate::outline::declarations_in_buffer; - -// TODO -// -// * Also queue / debounce buffer changes. A challenge for this is that use of -// `buffer_declarations_containing_range` assumes that the index is always immediately up to date. -// -// * Add a per language configuration for skipping indexing. -// -// * Handle tsx / ts / js referencing each-other - -// Potential future improvements: -// -// * Prevent indexing of a large file from blocking the queue. -// -// * Send multiple selected excerpt ranges. Challenge is that excerpt ranges influence which -// references are present and their scores. -// -// * Include single-file worktrees / non visible worktrees? E.g. go to definition that resolves to a -// file in a build dependency. Should not be editable in that case - but how to distinguish the case -// where it should be editable? - -// Potential future optimizations: -// -// * Index files on multiple threads in Zed (currently only parallel for the CLI). Adding some kind -// of priority system to the background executor could help - it's single threaded for now to avoid -// interfering with other work. -// -// * Parse files directly instead of loading into a Rope. -// -// - This would allow the task handling dirty_files to be done entirely on the background executor. -// -// - Make SyntaxMap generic to handle embedded languages? Will also need to find line boundaries, -// but that can be done by scanning characters in the flat representation. -// -// * Use something similar to slotmap without key versions. -// -// * Concurrent slotmap - -pub struct SyntaxIndex { - state: Arc>, - project: WeakEntity, - initial_file_indexing_done_rx: postage::watch::Receiver, - _file_indexing_task: Option>, -} - -pub struct SyntaxIndexState { - declarations: SlotMap, - identifiers: HashMap>, - files: HashMap, - buffers: HashMap, - dirty_files: HashMap, - dirty_files_tx: mpsc::Sender<()>, -} - -#[derive(Debug, Default)] -struct FileState { - declarations: Vec, -} - -#[derive(Default)] -struct BufferState { - declarations: Vec, - task: Option>, -} - -impl SyntaxIndex { - pub fn new( - project: &Entity, - file_indexing_parallelism: usize, - cx: &mut Context, - ) -> Self { - assert!(file_indexing_parallelism > 0); - let (dirty_files_tx, mut dirty_files_rx) = mpsc::channel::<()>(1); - let (mut initial_file_indexing_done_tx, initial_file_indexing_done_rx) = - postage::watch::channel(); - - let initial_state = SyntaxIndexState { - declarations: SlotMap::default(), - identifiers: HashMap::default(), - files: HashMap::default(), - buffers: HashMap::default(), - dirty_files: HashMap::default(), - dirty_files_tx, - }; - let mut this = Self { - project: project.downgrade(), - state: Arc::new(Mutex::new(initial_state)), - initial_file_indexing_done_rx, - _file_indexing_task: None, - }; - - let worktree_store = project.read(cx).worktree_store(); - let initial_worktree_snapshots = worktree_store - .read(cx) - .worktrees() - .map(|w| w.read(cx).snapshot()) - .collect::>(); - this._file_indexing_task = Some(cx.spawn(async move |this, cx| { - let snapshots_file_count = initial_worktree_snapshots - .iter() - .map(|worktree| worktree.file_count()) - .sum::(); - if snapshots_file_count > 0 { - let chunk_size = snapshots_file_count.div_ceil(file_indexing_parallelism); - let chunk_count = snapshots_file_count.div_ceil(chunk_size); - let file_chunks = initial_worktree_snapshots - .iter() - .flat_map(|worktree| { - let worktree_id = worktree.id(); - worktree.files(false, 0).map(move |entry| { - ( - entry.id, - ProjectPath { - worktree_id, - path: entry.path.clone(), - }, - ) - }) - }) - .chunks(chunk_size); - - let mut tasks = Vec::with_capacity(chunk_count); - for chunk in file_chunks.into_iter() { - tasks.push(Self::update_dirty_files( - &this, - chunk.into_iter().collect(), - cx.clone(), - )); - } - futures::future::join_all(tasks).await; - log::info!("Finished initial file indexing"); - } - - *initial_file_indexing_done_tx.borrow_mut() = true; - - let Ok(state) = this.read_with(cx, |this, _cx| Arc::downgrade(&this.state)) else { - return; - }; - while dirty_files_rx.next().await.is_some() { - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - let was_underused = state.dirty_files.capacity() > 255 - && state.dirty_files.len() * 8 < state.dirty_files.capacity(); - let dirty_files = state.dirty_files.drain().collect::>(); - if was_underused { - state.dirty_files.shrink_to_fit(); - } - drop(state); - if dirty_files.is_empty() { - continue; - } - - let chunk_size = dirty_files.len().div_ceil(file_indexing_parallelism); - let chunk_count = dirty_files.len().div_ceil(chunk_size); - let mut tasks = Vec::with_capacity(chunk_count); - let chunks = dirty_files.into_iter().chunks(chunk_size); - for chunk in chunks.into_iter() { - tasks.push(Self::update_dirty_files( - &this, - chunk.into_iter().collect(), - cx.clone(), - )); - } - futures::future::join_all(tasks).await; - } - })); - - cx.subscribe(&worktree_store, Self::handle_worktree_store_event) - .detach(); - - let buffer_store = project.read(cx).buffer_store().clone(); - for buffer in buffer_store.read(cx).buffers().collect::>() { - this.register_buffer(&buffer, cx); - } - cx.subscribe(&buffer_store, Self::handle_buffer_store_event) - .detach(); - - this - } - - async fn update_dirty_files( - this: &WeakEntity, - dirty_files: Vec<(ProjectEntryId, ProjectPath)>, - mut cx: AsyncApp, - ) { - for (entry_id, project_path) in dirty_files { - let Ok(task) = this.update(&mut cx, |this, cx| { - this.update_file(entry_id, project_path, cx) - }) else { - return; - }; - task.await; - } - } - - pub fn wait_for_initial_file_indexing(&self, cx: &App) -> Task> { - if *self.initial_file_indexing_done_rx.borrow() { - Task::ready(Ok(())) - } else { - let mut rx = self.initial_file_indexing_done_rx.clone(); - cx.background_spawn(async move { - loop { - match rx.recv().await { - Some(true) => return Ok(()), - Some(false) => {} - None => { - return Err(anyhow!( - "SyntaxIndex dropped while waiting for initial file indexing" - )); - } - } - } - }) - } - } - - pub fn indexed_file_paths(&self, cx: &App) -> Task> { - let state = self.state.clone(); - let project = self.project.clone(); - - cx.spawn(async move |cx| { - let state = state.lock().await; - let Some(project) = project.upgrade() else { - return vec![]; - }; - project - .read_with(cx, |project, cx| { - state - .files - .keys() - .filter_map(|entry_id| project.path_for_entry(*entry_id, cx)) - .collect() - }) - .unwrap_or_default() - }) - } - - fn handle_worktree_store_event( - &mut self, - _worktree_store: Entity, - event: &WorktreeStoreEvent, - cx: &mut Context, - ) { - use WorktreeStoreEvent::*; - match event { - WorktreeUpdatedEntries(worktree_id, updated_entries_set) => { - let state = Arc::downgrade(&self.state); - let worktree_id = *worktree_id; - let updated_entries_set = updated_entries_set.clone(); - cx.background_spawn(async move { - let Some(state) = state.upgrade() else { return }; - let mut state = state.lock().await; - for (path, entry_id, path_change) in updated_entries_set.iter() { - if let PathChange::Removed = path_change { - state.files.remove(entry_id); - state.dirty_files.remove(entry_id); - } else { - let project_path = ProjectPath { - worktree_id, - path: path.clone(), - }; - state.dirty_files.insert(*entry_id, project_path); - } - } - match state.dirty_files_tx.try_send(()) { - Err(err) if err.is_disconnected() => { - log::error!("bug: syntax indexing queue is disconnected"); - } - _ => {} - } - }) - .detach(); - } - WorktreeDeletedEntry(_worktree_id, project_entry_id) => { - let project_entry_id = *project_entry_id; - self.with_state(cx, move |state| { - state.files.remove(&project_entry_id); - }) - } - _ => {} - } - } - - fn handle_buffer_store_event( - &mut self, - _buffer_store: Entity, - event: &BufferStoreEvent, - cx: &mut Context, - ) { - use BufferStoreEvent::*; - match event { - BufferAdded(buffer) => self.register_buffer(buffer, cx), - BufferOpened { .. } - | BufferChangedFilePath { .. } - | BufferDropped { .. } - | SharedBufferClosed { .. } => {} - } - } - - pub fn state(&self) -> &Arc> { - &self.state - } - - fn with_state(&self, cx: &mut App, f: impl FnOnce(&mut SyntaxIndexState) + Send + 'static) { - if let Some(mut state) = self.state.try_lock() { - f(&mut state); - return; - } - let state = Arc::downgrade(&self.state); - cx.background_spawn(async move { - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - f(&mut state) - }) - .detach(); - } - - fn register_buffer(&self, buffer: &Entity, cx: &mut Context) { - let buffer_id = buffer.read(cx).remote_id(); - cx.observe_release(buffer, move |this, _buffer, cx| { - this.with_state(cx, move |state| { - if let Some(buffer_state) = state.buffers.remove(&buffer_id) { - SyntaxIndexState::remove_buffer_declarations( - &buffer_state.declarations, - &mut state.declarations, - &mut state.identifiers, - ); - } - }) - }) - .detach(); - cx.subscribe(buffer, Self::handle_buffer_event).detach(); - - self.update_buffer(buffer.clone(), cx); - } - - fn handle_buffer_event( - &mut self, - buffer: Entity, - event: &BufferEvent, - cx: &mut Context, - ) { - match event { - BufferEvent::Edited | - // paths are cached and so should be updated - BufferEvent::FileHandleChanged => self.update_buffer(buffer, cx), - _ => {} - } - } - - fn update_buffer(&self, buffer_entity: Entity, cx: &mut Context) { - let buffer = buffer_entity.read(cx); - if buffer.language().is_none() { - return; - } - - let Some((project_entry_id, cached_path)) = project::File::from_dyn(buffer.file()) - .and_then(|f| { - let project_entry_id = f.project_entry_id()?; - let cached_path = CachedDeclarationPath::new( - f.worktree.read(cx).abs_path(), - &f.path, - buffer.language(), - ); - Some((project_entry_id, cached_path)) - }) - else { - return; - }; - let buffer_id = buffer.remote_id(); - - let mut parse_status = buffer.parse_status(); - let snapshot_task = cx.spawn({ - let weak_buffer = buffer_entity.downgrade(); - async move |_, cx| { - while *parse_status.borrow() != language::ParseStatus::Idle { - parse_status.changed().await?; - } - weak_buffer.read_with(cx, |buffer, _cx| buffer.snapshot()) - } - }); - - let state = Arc::downgrade(&self.state); - let task = cx.background_spawn(async move { - // TODO: How to handle errors? - let Ok(snapshot) = snapshot_task.await else { - return; - }; - let rope = snapshot.text.as_rope(); - - let declarations = declarations_in_buffer(&snapshot) - .into_iter() - .map(|item| { - ( - item.parent_index, - BufferDeclaration::from_outline(item, &rope), - ) - }) - .collect::>(); - - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - let state = state.deref_mut(); - - let buffer_state = state - .buffers - .entry(buffer_id) - .or_insert_with(Default::default); - - SyntaxIndexState::remove_buffer_declarations( - &buffer_state.declarations, - &mut state.declarations, - &mut state.identifiers, - ); - - let mut new_ids = Vec::with_capacity(declarations.len()); - state.declarations.reserve(declarations.len()); - for (parent_index, mut declaration) in declarations { - declaration.parent = - parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied())); - - let identifier = declaration.identifier.clone(); - let declaration_id = state.declarations.insert(Declaration::Buffer { - rope: rope.clone(), - buffer_id, - declaration, - project_entry_id, - cached_path: cached_path.clone(), - }); - new_ids.push(declaration_id); - - state - .identifiers - .entry(identifier) - .or_default() - .insert(declaration_id); - } - - buffer_state.declarations = new_ids; - }); - - self.with_state(cx, move |state| { - state - .buffers - .entry(buffer_id) - .or_insert_with(Default::default) - .task = Some(task) - }); - } - - fn update_file( - &mut self, - entry_id: ProjectEntryId, - project_path: ProjectPath, - cx: &mut Context, - ) -> Task<()> { - let Some(project) = self.project.upgrade() else { - return Task::ready(()); - }; - let project = project.read(cx); - - let language_registry = project.languages(); - let Some(available_language) = - language_registry.language_for_file_path(project_path.path.as_std_path()) - else { - return Task::ready(()); - }; - let language = if let Some(Ok(Ok(language))) = language_registry - .load_language(&available_language) - .now_or_never() - { - if language - .grammar() - .is_none_or(|grammar| grammar.outline_config.is_none()) - { - return Task::ready(()); - } - future::Either::Left(async { Ok(language) }) - } else { - let language_registry = language_registry.clone(); - future::Either::Right(async move { - anyhow::Ok( - language_registry - .load_language(&available_language) - .await??, - ) - }) - }; - - let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) else { - return Task::ready(()); - }; - - let snapshot_task = worktree.update(cx, |worktree, cx| { - let load_task = worktree.load_file(&project_path.path, cx); - let worktree_abs_path = worktree.abs_path(); - cx.spawn(async move |_this, cx| { - let loaded_file = load_task.await?; - let language = language.await?; - - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(loaded_file.text, cx); - buffer.set_language(Some(language.clone()), cx); - buffer - })?; - - let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; - while *parse_status.borrow() != language::ParseStatus::Idle { - parse_status.changed().await?; - } - - let cached_path = CachedDeclarationPath::new( - worktree_abs_path, - &project_path.path, - Some(&language), - ); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - anyhow::Ok((snapshot, cached_path)) - }) - }); - - let state = Arc::downgrade(&self.state); - cx.background_spawn(async move { - // TODO: How to handle errors? - let Ok((snapshot, cached_path)) = snapshot_task.await else { - return; - }; - let rope = snapshot.as_rope(); - let declarations = declarations_in_buffer(&snapshot) - .into_iter() - .map(|item| (item.parent_index, FileDeclaration::from_outline(item, rope))) - .collect::>(); - - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - let state = state.deref_mut(); - - let file_state = state.files.entry(entry_id).or_insert_with(Default::default); - for old_declaration_id in &file_state.declarations { - let Some(declaration) = state.declarations.remove(*old_declaration_id) else { - debug_panic!("declaration not found"); - continue; - }; - if let Some(identifier_declarations) = - state.identifiers.get_mut(declaration.identifier()) - { - identifier_declarations.remove(old_declaration_id); - } - } - - let mut new_ids = Vec::with_capacity(declarations.len()); - state.declarations.reserve(declarations.len()); - for (parent_index, mut declaration) in declarations { - declaration.parent = - parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied())); - - let identifier = declaration.identifier.clone(); - let declaration_id = state.declarations.insert(Declaration::File { - project_entry_id: entry_id, - declaration, - cached_path: cached_path.clone(), - }); - new_ids.push(declaration_id); - - state - .identifiers - .entry(identifier) - .or_default() - .insert(declaration_id); - } - file_state.declarations = new_ids; - }) - } -} - -impl SyntaxIndexState { - pub fn declaration(&self, id: DeclarationId) -> Option<&Declaration> { - self.declarations.get(id) - } - - /// Returns declarations for the identifier. If the limit is exceeded, returns an empty vector. - /// - /// TODO: Consider doing some pre-ranking and instead truncating when N is exceeded. - pub fn declarations_for_identifier( - &self, - identifier: &Identifier, - ) -> Vec<(DeclarationId, &Declaration)> { - // make sure to not have a large stack allocation - assert!(N < 32); - - let Some(declaration_ids) = self.identifiers.get(&identifier) else { - return vec![]; - }; - - let mut result = Vec::with_capacity(N); - let mut included_buffer_entry_ids = arrayvec::ArrayVec::<_, N>::new(); - let mut file_declarations = Vec::new(); - - for declaration_id in declaration_ids { - let declaration = self.declarations.get(*declaration_id); - let Some(declaration) = some_or_debug_panic(declaration) else { - continue; - }; - match declaration { - Declaration::Buffer { - project_entry_id, .. - } => { - included_buffer_entry_ids.push(*project_entry_id); - result.push((*declaration_id, declaration)); - if result.len() == N { - return Vec::new(); - } - } - Declaration::File { - project_entry_id, .. - } => { - if !included_buffer_entry_ids.contains(&project_entry_id) { - file_declarations.push((*declaration_id, declaration)); - } - } - } - } - - for (declaration_id, declaration) in file_declarations { - match declaration { - Declaration::File { - project_entry_id, .. - } => { - if !included_buffer_entry_ids.contains(&project_entry_id) { - result.push((declaration_id, declaration)); - - if result.len() == N { - return Vec::new(); - } - } - } - Declaration::Buffer { .. } => {} - } - } - - result - } - - pub fn buffer_declarations_containing_range( - &self, - buffer_id: BufferId, - range: Range, - ) -> impl Iterator { - let Some(buffer_state) = self.buffers.get(&buffer_id) else { - return itertools::Either::Left(iter::empty()); - }; - - let iter = buffer_state - .declarations - .iter() - .filter_map(move |declaration_id| { - let Some(declaration) = self - .declarations - .get(*declaration_id) - .and_then(|d| d.as_buffer()) - else { - log::error!("bug: missing buffer outline declaration"); - return None; - }; - if declaration.item_range.contains_inclusive(&range) { - return Some((*declaration_id, declaration)); - } - return None; - }); - itertools::Either::Right(iter) - } - - pub fn file_declaration_count(&self, declaration: &Declaration) -> usize { - match declaration { - Declaration::File { - project_entry_id, .. - } => self - .files - .get(project_entry_id) - .map(|file_state| file_state.declarations.len()) - .unwrap_or_default(), - Declaration::Buffer { buffer_id, .. } => self - .buffers - .get(buffer_id) - .map(|buffer_state| buffer_state.declarations.len()) - .unwrap_or_default(), - } - } - - fn remove_buffer_declarations( - old_declaration_ids: &[DeclarationId], - declarations: &mut SlotMap, - identifiers: &mut HashMap>, - ) { - for old_declaration_id in old_declaration_ids { - let Some(declaration) = declarations.remove(*old_declaration_id) else { - debug_panic!("declaration not found"); - continue; - }; - if let Some(identifier_declarations) = identifiers.get_mut(declaration.identifier()) { - identifier_declarations.remove(old_declaration_id); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - use gpui::TestAppContext; - use indoc::indoc; - use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use text::OffsetRangeExt as _; - use util::{path, rel_path::rel_path}; - - use crate::syntax_index::SyntaxIndex; - - #[gpui::test] - async fn test_unopen_indexed_files(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let main = Identifier { - name: "main".into(), - language_id: rust_lang_id, - }; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - - let decl = expect_file_decl("a.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, main); - assert_eq!(decl.item_range, 0..98); - - let decl = expect_file_decl("c.rs", &decls[1].1, &project, cx); - assert_eq!(decl.identifier, main.clone()); - assert_eq!(decl.item_range, 32..280); - }); - } - - #[gpui::test] - async fn test_parents_in_file(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let test_process_data = Identifier { - name: "test_process_data".into(), - language_id: rust_lang_id, - }; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&test_process_data); - assert_eq!(decls.len(), 1); - - let decl = expect_file_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, test_process_data); - - let parent_id = decl.parent.unwrap(); - let parent = index_state.declaration(parent_id).unwrap(); - let parent_decl = expect_file_decl("c.rs", &parent, &project, cx); - assert_eq!( - parent_decl.identifier, - Identifier { - name: "tests".into(), - language_id: rust_lang_id - } - ); - assert_eq!(parent_decl.parent, None); - }); - } - - #[gpui::test] - async fn test_parents_in_buffer(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let test_process_data = Identifier { - name: "test_process_data".into(), - language_id: rust_lang_id, - }; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&test_process_data); - assert_eq!(decls.len(), 1); - - let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, test_process_data); - - let parent_id = decl.parent.unwrap(); - let parent = index_state.declaration(parent_id).unwrap(); - let parent_decl = expect_buffer_decl("c.rs", &parent, &project, cx); - assert_eq!( - parent_decl.identifier, - Identifier { - name: "tests".into(), - language_id: rust_lang_id - } - ); - assert_eq!(parent_decl.parent, None); - }); - - drop(buffer); - } - - #[gpui::test] - async fn test_declarations_limit(cx: &mut TestAppContext) { - let (_, index, rust_lang_id) = init_test(cx).await; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - let decls = index_state.declarations_for_identifier::<1>(&Identifier { - name: "main".into(), - language_id: rust_lang_id, - }); - assert_eq!(decls.len(), 0); - } - - #[gpui::test] - async fn test_buffer_shadow(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - - let main = Identifier { - name: "main".into(), - language_id: rust_lang_id, - }; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - let index_state_arc = index.read_with(cx, |index, _cx| index.state().clone()); - { - let index_state = index_state_arc.lock().await; - - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, main); - assert_eq!(decl.item_range.to_offset(&buffer.read(cx)), 32..280); - - expect_file_decl("a.rs", &decls[1].1, &project, cx); - }); - } - - // Drop the buffer and wait for release - cx.update(|_| { - drop(buffer); - }); - cx.run_until_parked(); - - let index_state = index_state_arc.lock().await; - - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - expect_file_decl("a.rs", &decls[0].1, &project, cx); - expect_file_decl("c.rs", &decls[1].1, &project, cx); - }); - } - - fn expect_buffer_decl<'a>( - path: &str, - declaration: &'a Declaration, - project: &Entity, - cx: &App, - ) -> &'a BufferDeclaration { - if let Declaration::Buffer { - declaration, - project_entry_id, - .. - } = declaration - { - let project_path = project - .read(cx) - .path_for_entry(*project_entry_id, cx) - .unwrap(); - assert_eq!(project_path.path.as_ref(), rel_path(path),); - declaration - } else { - panic!("Expected a buffer declaration, found {:?}", declaration); - } - } - - fn expect_file_decl<'a>( - path: &str, - declaration: &'a Declaration, - project: &Entity, - cx: &App, - ) -> &'a FileDeclaration { - if let Declaration::File { - declaration, - project_entry_id: file, - .. - } = declaration - { - assert_eq!( - project - .read(cx) - .path_for_entry(*file, cx) - .unwrap() - .path - .as_ref(), - rel_path(path), - ); - declaration - } else { - panic!("Expected a file declaration, found {:?}", declaration); - } - } - - async fn init_test( - cx: &mut TestAppContext, - ) -> (Entity, Entity, LanguageId) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "a.rs": indoc! {r#" - fn main() { - let x = 1; - let y = 2; - let z = add(x, y); - println!("Result: {}", z); - } - - fn add(a: i32, b: i32) -> i32 { - a + b - } - "#}, - "b.rs": indoc! {" - pub struct Config { - pub name: String, - pub value: i32, - } - - impl Config { - pub fn new(name: String, value: i32) -> Self { - Config { name, value } - } - } - "}, - "c.rs": indoc! {r#" - use std::collections::HashMap; - - fn main() { - let args: Vec = std::env::args().collect(); - let data: Vec = args[1..] - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - let result = process_data(data); - println!("{:?}", result); - } - - fn process_data(data: Vec) -> HashMap { - let mut counts = HashMap::new(); - for value in data { - *counts.entry(value).or_insert(0) += 1; - } - counts - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn test_process_data() { - let data = vec![1, 2, 2, 3]; - let result = process_data(data); - assert_eq!(result.get(&2), Some(&2)); - } - } - "#} - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let lang = rust_lang(); - let lang_id = lang.id(); - language_registry.add(Arc::new(lang)); - - let file_indexing_parallelism = 2; - let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx)); - cx.run_until_parked(); - - (project, index, lang_id) - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } -} diff --git a/crates/edit_prediction_context/src/text_similarity.rs b/crates/edit_prediction_context/src/text_similarity.rs deleted file mode 100644 index 308a9570206084fc223c72f2e1c49109ea157714..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/text_similarity.rs +++ /dev/null @@ -1,314 +0,0 @@ -use hashbrown::HashTable; -use regex::Regex; -use std::{ - borrow::Cow, - hash::{Hash, Hasher as _}, - path::Path, - sync::LazyLock, -}; -use util::rel_path::RelPath; - -use crate::reference::Reference; - -// TODO: Consider implementing sliding window similarity matching like -// https://github.com/sourcegraph/cody-public-snapshot/blob/8e20ac6c1460c08b0db581c0204658112a246eda/vscode/src/completions/context/retrievers/jaccard-similarity/bestJaccardMatch.ts -// -// That implementation could actually be more efficient - no need to track words in the window that -// are not in the query. - -// TODO: Consider a flat sorted Vec<(String, usize)> representation. Intersection can just walk the -// two in parallel. - -static IDENTIFIER_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\b\w+\b").unwrap()); - -/// Multiset of text occurrences for text similarity that only stores hashes and counts. -#[derive(Debug, Default)] -pub struct Occurrences { - table: HashTable, - total_count: usize, -} - -#[derive(Debug)] -struct OccurrenceEntry { - hash: u64, - count: usize, -} - -impl Occurrences { - pub fn within_string(text: &str) -> Self { - Self::from_identifiers(IDENTIFIER_REGEX.find_iter(text).map(|mat| mat.as_str())) - } - - #[allow(dead_code)] - pub fn within_references(references: &[Reference]) -> Self { - Self::from_identifiers( - references - .iter() - .map(|reference| reference.identifier.name.as_ref()), - ) - } - - pub fn from_identifiers(identifiers: impl IntoIterator>) -> Self { - let mut this = Self::default(); - // TODO: Score matches that match case higher? - // - // TODO: Also include unsplit identifier? - for identifier in identifiers { - for identifier_part in split_identifier(identifier.as_ref()) { - this.add_hash(fx_hash(&identifier_part.to_lowercase())); - } - } - this - } - - pub fn from_worktree_path(worktree_name: Option>, rel_path: &RelPath) -> Self { - if let Some(worktree_name) = worktree_name { - Self::from_identifiers( - std::iter::once(worktree_name) - .chain(iter_path_without_extension(rel_path.as_std_path())), - ) - } else { - Self::from_path(rel_path.as_std_path()) - } - } - - pub fn from_path(path: &Path) -> Self { - Self::from_identifiers(iter_path_without_extension(path)) - } - - fn add_hash(&mut self, hash: u64) { - self.table - .entry( - hash, - |entry: &OccurrenceEntry| entry.hash == hash, - |entry| entry.hash, - ) - .and_modify(|entry| entry.count += 1) - .or_insert(OccurrenceEntry { hash, count: 1 }); - self.total_count += 1; - } - - fn contains_hash(&self, hash: u64) -> bool { - self.get_count(hash) != 0 - } - - fn get_count(&self, hash: u64) -> usize { - self.table - .find(hash, |entry| entry.hash == hash) - .map(|entry| entry.count) - .unwrap_or(0) - } -} - -fn iter_path_without_extension(path: &Path) -> impl Iterator> { - let last_component: Option> = path.file_stem().map(|stem| stem.to_string_lossy()); - let mut path_components = path.components(); - path_components.next_back(); - path_components - .map(|component| component.as_os_str().to_string_lossy()) - .chain(last_component) -} - -pub fn fx_hash(data: &T) -> u64 { - let mut hasher = collections::FxHasher::default(); - data.hash(&mut hasher); - hasher.finish() -} - -// Splits camelcase / snakecase / kebabcase / pascalcase -// -// TODO: Make this more efficient / elegant. -fn split_identifier(identifier: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0; - let chars: Vec = identifier.chars().collect(); - - if chars.is_empty() { - return parts; - } - - let mut i = 0; - while i < chars.len() { - let ch = chars[i]; - - // Handle explicit delimiters (underscore and hyphen) - if ch == '_' || ch == '-' { - if i > start { - parts.push(&identifier[start..i]); - } - start = i + 1; - i += 1; - continue; - } - - // Handle camelCase and PascalCase transitions - if i > 0 && i < chars.len() { - let prev_char = chars[i - 1]; - - // Transition from lowercase/digit to uppercase - if (prev_char.is_lowercase() || prev_char.is_ascii_digit()) && ch.is_uppercase() { - parts.push(&identifier[start..i]); - start = i; - } - // Handle sequences like "XMLParser" -> ["XML", "Parser"] - else if i + 1 < chars.len() - && ch.is_uppercase() - && chars[i + 1].is_lowercase() - && prev_char.is_uppercase() - { - parts.push(&identifier[start..i]); - start = i; - } - } - - i += 1; - } - - // Add the last part if there's any remaining - if start < identifier.len() { - parts.push(&identifier[start..]); - } - - // Filter out empty strings - parts.into_iter().filter(|s| !s.is_empty()).collect() -} - -pub fn jaccard_similarity<'a>(mut set_a: &'a Occurrences, mut set_b: &'a Occurrences) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - let intersection = set_a - .table - .iter() - .filter(|entry| set_b.contains_hash(entry.hash)) - .count(); - let union = set_a.table.len() + set_b.table.len() - intersection; - intersection as f32 / union as f32 -} - -// TODO -#[allow(dead_code)] -pub fn overlap_coefficient<'a>(mut set_a: &'a Occurrences, mut set_b: &'a Occurrences) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - let intersection = set_a - .table - .iter() - .filter(|entry| set_b.contains_hash(entry.hash)) - .count(); - intersection as f32 / set_a.table.len() as f32 -} - -// TODO -#[allow(dead_code)] -pub fn weighted_jaccard_similarity<'a>( - mut set_a: &'a Occurrences, - mut set_b: &'a Occurrences, -) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - - let mut numerator = 0; - let mut denominator_a = 0; - let mut used_count_b = 0; - for entry_a in set_a.table.iter() { - let count_a = entry_a.count; - let count_b = set_b.get_count(entry_a.hash); - numerator += count_a.min(count_b); - denominator_a += count_a.max(count_b); - used_count_b += count_b; - } - - let denominator = denominator_a + (set_b.total_count - used_count_b); - if denominator == 0 { - 0.0 - } else { - numerator as f32 / denominator as f32 - } -} - -pub fn weighted_overlap_coefficient<'a>( - mut set_a: &'a Occurrences, - mut set_b: &'a Occurrences, -) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - - let mut numerator = 0; - for entry_a in set_a.table.iter() { - let count_a = entry_a.count; - let count_b = set_b.get_count(entry_a.hash); - numerator += count_a.min(count_b); - } - - let denominator = set_a.total_count.min(set_b.total_count); - if denominator == 0 { - 0.0 - } else { - numerator as f32 / denominator as f32 - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_split_identifier() { - assert_eq!(split_identifier("snake_case"), vec!["snake", "case"]); - assert_eq!(split_identifier("kebab-case"), vec!["kebab", "case"]); - assert_eq!(split_identifier("PascalCase"), vec!["Pascal", "Case"]); - assert_eq!(split_identifier("camelCase"), vec!["camel", "Case"]); - assert_eq!(split_identifier("XMLParser"), vec!["XML", "Parser"]); - } - - #[test] - fn test_similarity_functions() { - // 10 identifier parts, 8 unique - // Repeats: 2 "outline", 2 "items" - let set_a = Occurrences::within_string( - "let mut outline_items = query_outline_items(&language, &tree, &source);", - ); - // 14 identifier parts, 11 unique - // Repeats: 2 "outline", 2 "language", 2 "tree" - let set_b = Occurrences::within_string( - "pub fn query_outline_items(language: &Language, tree: &Tree, source: &str) -> Vec {", - ); - - // 6 overlaps: "outline", "items", "query", "language", "tree", "source" - // 7 non-overlaps: "let", "mut", "pub", "fn", "vec", "item", "str" - assert_eq!(jaccard_similarity(&set_a, &set_b), 6.0 / (6.0 + 7.0)); - - // Numerator is one more than before due to both having 2 "outline". - // Denominator is the same except for 3 more due to the non-overlapping duplicates - assert_eq!( - weighted_jaccard_similarity(&set_a, &set_b), - 7.0 / (7.0 + 7.0 + 3.0) - ); - - // Numerator is the same as jaccard_similarity. Denominator is the size of the smaller set, 8. - assert_eq!(overlap_coefficient(&set_a, &set_b), 6.0 / 8.0); - - // Numerator is the same as weighted_jaccard_similarity. Denominator is the total weight of - // the smaller set, 10. - assert_eq!(weighted_overlap_coefficient(&set_a, &set_b), 7.0 / 10.0); - } - - #[test] - fn test_iter_path_without_extension() { - let mut iter = iter_path_without_extension(Path::new("")); - assert_eq!(iter.next(), None); - - let iter = iter_path_without_extension(Path::new("foo")); - assert_eq!(iter.collect::>(), ["foo"]); - - let iter = iter_path_without_extension(Path::new("foo/bar.txt")); - assert_eq!(iter.collect::>(), ["foo", "bar"]); - - let iter = iter_path_without_extension(Path::new("foo/bar/baz.txt")); - assert_eq!(iter.collect::>(), ["foo", "bar", "baz"]); - } -} diff --git a/crates/edit_prediction_types/Cargo.toml b/crates/edit_prediction_types/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..00a8577911af0afd012535fd324a68af8fd70391 --- /dev/null +++ b/crates/edit_prediction_types/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "edit_prediction_types" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/edit_prediction_types.rs" + +[dependencies] +client.workspace = true +gpui.workspace = true +language.workspace = true +text.workspace = true diff --git a/crates/zeta/LICENSE-GPL b/crates/edit_prediction_types/LICENSE-GPL similarity index 100% rename from crates/zeta/LICENSE-GPL rename to crates/edit_prediction_types/LICENSE-GPL diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs new file mode 100644 index 0000000000000000000000000000000000000000..5a37aba59923598b20becd91f07633e409b2bdb7 --- /dev/null +++ b/crates/edit_prediction_types/src/edit_prediction_types.rs @@ -0,0 +1,278 @@ +use std::{ops::Range, sync::Arc}; + +use client::EditPredictionUsage; +use gpui::{App, Context, Entity, SharedString}; +use language::{Anchor, Buffer, OffsetRangeExt}; + +// TODO: Find a better home for `Direction`. +// +// This should live in an ancestor crate of `editor` and `edit_prediction`, +// but at time of writing there isn't an obvious spot. +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum Direction { + Prev, + Next, +} + +#[derive(Clone)] +pub enum EditPrediction { + /// Edits within the buffer that requested the prediction + Local { + id: Option, + edits: Vec<(Range, Arc)>, + edit_preview: Option, + }, + /// Jump to a different file from the one that requested the prediction + Jump { + id: Option, + snapshot: language::BufferSnapshot, + target: language::Anchor, + }, +} + +pub enum DataCollectionState { + /// The provider doesn't support data collection. + Unsupported, + /// Data collection is enabled. + Enabled { is_project_open_source: bool }, + /// Data collection is disabled or unanswered. + Disabled { is_project_open_source: bool }, +} + +impl DataCollectionState { + pub fn is_supported(&self) -> bool { + !matches!(self, DataCollectionState::Unsupported) + } + + pub fn is_enabled(&self) -> bool { + matches!(self, DataCollectionState::Enabled { .. }) + } + + pub fn is_project_open_source(&self) -> bool { + match self { + Self::Enabled { + is_project_open_source, + } + | Self::Disabled { + is_project_open_source, + } => *is_project_open_source, + _ => false, + } + } +} + +pub trait EditPredictionDelegate: 'static + Sized { + fn name() -> &'static str; + fn display_name() -> &'static str; + fn show_predictions_in_menu() -> bool; + fn show_tab_accept_marker() -> bool { + false + } + fn supports_jump_to_edit() -> bool { + true + } + + fn data_collection_state(&self, _cx: &App) -> DataCollectionState { + DataCollectionState::Unsupported + } + + fn usage(&self, _cx: &App) -> Option { + None + } + + fn toggle_data_collection(&mut self, _cx: &mut App) {} + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool; + fn is_refreshing(&self, cx: &App) -> bool; + fn refresh( + &mut self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut Context, + ); + fn accept(&mut self, cx: &mut Context); + fn discard(&mut self, cx: &mut Context); + fn did_show(&mut self, _cx: &mut Context) {} + fn suggest( + &mut self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut Context, + ) -> Option; +} + +pub trait EditPredictionDelegateHandle { + fn name(&self) -> &'static str; + fn display_name(&self) -> &'static str; + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool; + fn show_predictions_in_menu(&self) -> bool; + fn show_tab_accept_marker(&self) -> bool; + fn supports_jump_to_edit(&self) -> bool; + fn data_collection_state(&self, cx: &App) -> DataCollectionState; + fn usage(&self, cx: &App) -> Option; + fn toggle_data_collection(&self, cx: &mut App); + fn is_refreshing(&self, cx: &App) -> bool; + fn refresh( + &self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut App, + ); + fn did_show(&self, cx: &mut App); + fn accept(&self, cx: &mut App); + fn discard(&self, cx: &mut App); + fn suggest( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut App, + ) -> Option; +} + +impl EditPredictionDelegateHandle for Entity +where + T: EditPredictionDelegate, +{ + fn name(&self) -> &'static str { + T::name() + } + + fn display_name(&self) -> &'static str { + T::display_name() + } + + fn show_predictions_in_menu(&self) -> bool { + T::show_predictions_in_menu() + } + + fn show_tab_accept_marker(&self) -> bool { + T::show_tab_accept_marker() + } + + fn supports_jump_to_edit(&self) -> bool { + T::supports_jump_to_edit() + } + + fn data_collection_state(&self, cx: &App) -> DataCollectionState { + self.read(cx).data_collection_state(cx) + } + + fn usage(&self, cx: &App) -> Option { + self.read(cx).usage(cx) + } + + fn toggle_data_collection(&self, cx: &mut App) { + self.update(cx, |this, cx| this.toggle_data_collection(cx)) + } + + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool { + self.read(cx).is_enabled(buffer, cursor_position, cx) + } + + fn is_refreshing(&self, cx: &App) -> bool { + self.read(cx).is_refreshing(cx) + } + + fn refresh( + &self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut App, + ) { + self.update(cx, |this, cx| { + this.refresh(buffer, cursor_position, debounce, cx) + }) + } + + fn accept(&self, cx: &mut App) { + self.update(cx, |this, cx| this.accept(cx)) + } + + fn discard(&self, cx: &mut App) { + self.update(cx, |this, cx| this.discard(cx)) + } + + fn did_show(&self, cx: &mut App) { + self.update(cx, |this, cx| this.did_show(cx)) + } + + fn suggest( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut App, + ) -> Option { + self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditPredictionGranularity { + Word, + Line, + Full, +} +/// Returns edits updated based on user edits since the old snapshot. None is returned if any user +/// edit is not a prefix of a predicted insertion. +pub fn interpolate_edits( + old_snapshot: &text::BufferSnapshot, + new_snapshot: &text::BufferSnapshot, + current_edits: &[(Range, Arc)], +) -> Option, Arc)>> { + let mut edits = Vec::new(); + + let mut model_edits = current_edits.iter().peekable(); + for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { + while let Some((model_old_range, _)) = model_edits.peek() { + let model_old_range = model_old_range.to_offset(old_snapshot); + if model_old_range.end < user_edit.old.start { + let (model_old_range, model_new_text) = model_edits.next().unwrap(); + edits.push((model_old_range.clone(), model_new_text.clone())); + } else { + break; + } + } + + if let Some((model_old_range, model_new_text)) = model_edits.peek() { + let model_old_offset_range = model_old_range.to_offset(old_snapshot); + if user_edit.old == model_old_offset_range { + let user_new_text = new_snapshot + .text_for_range(user_edit.new.clone()) + .collect::(); + + if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { + if !model_suffix.is_empty() { + let anchor = old_snapshot.anchor_after(user_edit.old.end); + edits.push((anchor..anchor, model_suffix.into())); + } + + model_edits.next(); + continue; + } + } + } + + return None; + } + + edits.extend(model_edits.cloned()); + + if edits.is_empty() { None } else { Some(edits) } +} diff --git a/crates/edit_prediction_button/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml similarity index 71% rename from crates/edit_prediction_button/Cargo.toml rename to crates/edit_prediction_ui/Cargo.toml index 9877b70161b3fdd16a0f667d85085520c9fe4f86..b406a450601bef908c27a48be14fe9b1f2204c08 100644 --- a/crates/edit_prediction_button/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "edit_prediction_button" +name = "edit_prediction_ui" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,33 +9,45 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/edit_prediction_button.rs" +path = "src/edit_prediction_ui.rs" doctest = false [dependencies] anyhow.workspace = true +buffer_diff.workspace = true +git.workspace = true +log.workspace = true +time.workspace = true client.workspace = true cloud_llm_client.workspace = true codestral.workspace = true +command_palette_hooks.workspace = true copilot.workspace = true +edit_prediction_types.workspace = true edit_prediction.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true +futures.workspace = true gpui.workspace = true indoc.workspace = true language.workspace = true +markdown.workspace = true +menu.workspace = true +multi_buffer.workspace = true paths.workspace = true project.workspace = true regex.workspace = true settings.workspace = true supermaven.workspace = true telemetry.workspace = true +text.workspace = true +theme.workspace = true ui.workspace = true +util.workspace = true workspace.workspace = true zed_actions.workspace = true -zeta.workspace = true -zeta2.workspace = true +zeta_prompt.workspace = true [dev-dependencies] copilot = { workspace = true, features = ["test-support"] } diff --git a/crates/zeta2/LICENSE-GPL b/crates/edit_prediction_ui/LICENSE-GPL similarity index 100% rename from crates/zeta2/LICENSE-GPL rename to crates/edit_prediction_ui/LICENSE-GPL diff --git a/crates/edit_prediction_button/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs similarity index 68% rename from crates/edit_prediction_button/src/edit_prediction_button.rs rename to crates/edit_prediction_ui/src/edit_prediction_button.rs index 051ca6e85ccb985ba6b325cda725f83029aa3193..0dcea477200eef9d1eeb6adeff98f47332d751ca 100644 --- a/crates/edit_prediction_button/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -1,17 +1,21 @@ use anyhow::Result; use client::{Client, UserStore, zed_urls}; use cloud_llm_client::UsageLimit; -use codestral::CodestralCompletionProvider; +use codestral::CodestralEditPredictionDelegate; use copilot::{Copilot, Status}; +use edit_prediction::{ + EditPredictionStore, MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag, +}; +use edit_prediction_types::EditPredictionDelegateHandle; use editor::{ Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll, }; -use feature_flags::{FeatureFlagAppExt, PredictEditsRateCompletionsFeatureFlag}; +use feature_flags::FeatureFlagAppExt; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div, - pulsating_between, + ease_in_out, pulsating_between, }; use indoc::indoc; use language::{ @@ -21,7 +25,10 @@ use language::{ use project::DisableAiSettings; use regex::Regex; use settings::{ - EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, update_settings_file, + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, + update_settings_file, }; use std::{ sync::{Arc, LazyLock}, @@ -32,13 +39,16 @@ use ui::{ Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton, IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*, }; +use util::ResultExt as _; use workspace::{ StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, notifications::NotificationId, }; -use zed_actions::OpenBrowser; -use zeta::RateCompletions; -use zeta2::SweepFeatureFlag; +use zed_actions::{OpenBrowser, OpenSettingsAt}; + +use crate::{ + CaptureExample, RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag, +}; actions!( edit_prediction, @@ -61,7 +71,7 @@ pub struct EditPredictionButton { editor_focus_handle: Option, language: Option>, file: Option>, - edit_prediction_provider: Option>, + edit_prediction_provider: Option>, fs: Arc, user_store: Entity, popover_menu_handle: PopoverMenuHandle, @@ -238,46 +248,22 @@ impl Render for EditPredictionButton { EditPredictionProvider::Codestral => { let enabled = self.editor_enabled.unwrap_or(true); - let has_api_key = CodestralCompletionProvider::has_api_key(cx); - let fs = self.fs.clone(); + let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx); let this = cx.weak_entity(); + let tooltip_meta = if has_api_key { + "Powered by Codestral" + } else { + "Missing API key for Codestral" + }; + div().child( PopoverMenu::new("codestral") .menu(move |window, cx| { - if has_api_key { - this.update(cx, |this, cx| { - this.build_codestral_context_menu(window, cx) - }) - .ok() - } else { - Some(ContextMenu::build(window, cx, |menu, _, _| { - let fs = fs.clone(); - - menu.entry( - "Configure Codestral API Key", - None, - move |window, cx| { - window.dispatch_action( - zed_actions::agent::OpenSettings.boxed_clone(), - cx, - ); - }, - ) - .separator() - .entry( - "Use Zed AI instead", - None, - move |_, cx| { - set_completion_provider( - fs.clone(), - cx, - EditPredictionProvider::Zed, - ) - }, - ) - })) - } + this.update(cx, |this, cx| { + this.build_codestral_context_menu(window, cx) + }) + .ok() }) .anchor(Corner::BottomRight) .trigger_with_tooltip( @@ -295,39 +281,69 @@ impl Render for EditPredictionButton { cx.theme().colors().status_bar_background, )) }), - move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx), + move |_window, cx| { + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + tooltip_meta, + cx, + ) + }, ) .with_handle(self.popover_menu_handle.clone()), ) } - provider @ (EditPredictionProvider::Experimental( - EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, - ) - | EditPredictionProvider::Zed) => { + provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => { let enabled = self.editor_enabled.unwrap_or(true); - let is_sweep = matches!( - provider, - EditPredictionProvider::Experimental( - EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME - ) - ); + let ep_icon; + let tooltip_meta; + let mut missing_token = false; - let zeta_icon = match (is_sweep, enabled) { - (true, _) => IconName::SweepAi, - (false, true) => IconName::ZedPredict, - (false, false) => IconName::ZedPredictDisabled, + match provider { + EditPredictionProvider::Experimental( + EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, + ) => { + ep_icon = IconName::SweepAi; + tooltip_meta = if missing_token { + "Missing API key for Sweep" + } else { + "Powered by Sweep" + }; + missing_token = edit_prediction::EditPredictionStore::try_global(cx) + .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx)); + } + EditPredictionProvider::Experimental( + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + ) => { + ep_icon = IconName::Inception; + missing_token = edit_prediction::EditPredictionStore::try_global(cx) + .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx)); + tooltip_meta = if missing_token { + "Missing API key for Mercury" + } else { + "Powered by Mercury" + }; + } + _ => { + ep_icon = if enabled { + IconName::ZedPredict + } else { + IconName::ZedPredictDisabled + }; + tooltip_meta = "Powered by Zeta" + } }; - if zeta::should_show_upsell_modal() { + if edit_prediction::should_show_upsell_modal() { let tooltip_meta = if self.user_store.read(cx).current_user().is_some() { "Choose a Plan" } else { - "Sign In" + "Sign In To Use" }; return div().child( - IconButton::new("zed-predict-pending-button", zeta_icon) + IconButton::new("zed-predict-pending-button", ep_icon) .shape(IconButtonShape::Square) .indicator(Indicator::dot().color(Color::Muted)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) @@ -358,52 +374,73 @@ impl Render for EditPredictionButton { } let show_editor_predictions = self.editor_show_predictions; + let user = self.user_store.read(cx).current_user(); - let icon_button = IconButton::new("zed-predict-pending-button", zeta_icon) + let indicator_color = if missing_token { + Some(Color::Error) + } else if enabled && (!show_editor_predictions || over_limit) { + Some(if over_limit { + Color::Error + } else { + Color::Muted + }) + } else { + None + }; + + let icon_button = IconButton::new("zed-predict-pending-button", ep_icon) .shape(IconButtonShape::Square) - .when( - enabled && (!show_editor_predictions || over_limit), - |this| { - this.indicator(Indicator::dot().when_else( - over_limit, - |dot| dot.color(Color::Error), - |dot| dot.color(Color::Muted), - )) + .when_some(indicator_color, |this, color| { + this.indicator(Indicator::dot().color(color)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) - }, - ) + }) .when(!self.popover_menu_handle.is_deployed(), |element| { + let user = user.clone(); + element.tooltip(move |_window, cx| { - if enabled { + let description = if enabled { if show_editor_predictions { - Tooltip::for_action("Edit Prediction", &ToggleMenu, cx) + tooltip_meta + } else if user.is_none() { + "Sign In To Use" } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Hidden For This File", - cx, - ) + "Hidden For This File" } } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Disabled For This File", - cx, - ) - } + "Disabled For This File" + }; + + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + description, + cx, + ) }) }); let this = cx.weak_entity(); - let mut popover_menu = PopoverMenu::new("zeta") - .menu(move |window, cx| { - this.update(cx, |this, cx| { - this.build_zeta_context_menu(provider, window, cx) + let mut popover_menu = PopoverMenu::new("edit-prediction") + .when(user.is_some(), |popover_menu| { + let this = this.clone(); + + popover_menu.menu(move |window, cx| { + this.update(cx, |this, cx| { + this.build_edit_prediction_context_menu(provider, window, cx) + }) + .ok() + }) + }) + .when(user.is_none(), |popover_menu| { + let this = this.clone(); + + popover_menu.menu(move |window, cx| { + this.update(cx, |this, cx| { + this.build_zeta_upsell_context_menu(window, cx) + }) + .ok() }) - .ok() }) .anchor(Corner::BottomRight) .with_handle(self.popover_menu_handle.clone()); @@ -430,9 +467,7 @@ impl Render for EditPredictionButton { div().child(popover_menu.into_any_element()) } - EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => { - div().hidden() - } + EditPredictionProvider::None => div().hidden(), } } } @@ -452,7 +487,22 @@ impl EditPredictionButton { cx.observe_global::(move |_, cx| cx.notify()) .detach(); - CodestralCompletionProvider::ensure_api_key_loaded(client.http_client(), cx); + cx.observe_global::(move |_, cx| cx.notify()) + .detach(); + + let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx); + let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx); + + cx.spawn(async move |this, cx| { + _ = futures::join!(sweep_api_token_task, mercury_api_token_task); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }) + .detach(); + + CodestralEditPredictionDelegate::ensure_api_key_loaded(client.http_client(), cx); Self { editor_subscription: None, @@ -468,11 +518,17 @@ impl EditPredictionButton { } } - fn get_available_providers(&self, cx: &App) -> Vec { + fn get_available_providers(&self, cx: &mut App) -> Vec { let mut providers = Vec::new(); providers.push(EditPredictionProvider::Zed); + if cx.has_flag::() { + providers.push(EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + )); + } + if let Some(copilot) = Copilot::global(cx) { if matches!(copilot.read(cx).status(), Status::Authorized) { providers.push(EditPredictionProvider::Copilot); @@ -487,16 +543,30 @@ impl EditPredictionButton { } } - if CodestralCompletionProvider::has_api_key(cx) { + if CodestralEditPredictionDelegate::has_api_key(cx) { providers.push(EditPredictionProvider::Codestral); } - if cx.has_flag::() { + if cx.has_flag::() + && edit_prediction::sweep_ai::sweep_api_token(cx) + .read(cx) + .has_key() + { providers.push(EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, )); } + if cx.has_flag::() + && edit_prediction::mercury::mercury_api_token(cx) + .read(cx) + .has_key() + { + providers.push(EditPredictionProvider::Experimental( + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + )); + } + providers } @@ -504,77 +574,48 @@ impl EditPredictionButton { &self, mut menu: ContextMenu, current_provider: EditPredictionProvider, - cx: &App, + cx: &mut App, ) -> ContextMenu { let available_providers = self.get_available_providers(cx); - const ZED_AI_CALLOUT: &str = - "Zed's edit prediction is powered by Zeta, an open-source, dataset mode."; - const USE_SWEEP_API_TOKEN_CALLOUT: &str = - "Set the SWEEP_API_TOKEN environment variable to use Sweep"; - - let other_providers: Vec<_> = available_providers + let providers: Vec<_> = available_providers .into_iter() - .filter(|p| *p != current_provider && *p != EditPredictionProvider::None) + .filter(|p| *p != EditPredictionProvider::None) .collect(); - if !other_providers.is_empty() { - menu = menu.separator().header("Switch Providers"); + if !providers.is_empty() { + menu = menu.separator().header("Providers"); - for provider in other_providers { + for provider in providers { + let is_current = provider == current_provider; let fs = self.fs.clone(); - menu = match provider { - EditPredictionProvider::Zed => menu.item( - ContextMenuEntry::new("Zed AI") - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| Label::new(ZED_AI_CALLOUT).into_any_element(), - ) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Copilot => { - menu.entry("GitHub Copilot", None, move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }) - } - EditPredictionProvider::Supermaven => { - menu.entry("Supermaven", None, move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }) - } - EditPredictionProvider::Codestral => { - menu.entry("Codestral", None, move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }) - } + let name = match provider { + EditPredictionProvider::Zed => "Zed AI", + EditPredictionProvider::Copilot => "GitHub Copilot", + EditPredictionProvider::Supermaven => "Supermaven", + EditPredictionProvider::Codestral => "Codestral", EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, - ) => { - let has_api_token = zeta2::Zeta::try_global(cx) - .map_or(false, |zeta| zeta.read(cx).has_sweep_api_token()); - - let entry = ContextMenuEntry::new("Sweep") - .when(!has_api_token, |this| { - this.disabled(true).documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| Label::new(USE_SWEEP_API_TOKEN_CALLOUT).into_any_element(), - ) - }) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }); - - menu.item(entry) - } + ) => "Sweep", + EditPredictionProvider::Experimental( + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + ) => "Mercury", + EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + ) => "Zeta2", EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => { continue; } }; + + menu = menu.item( + ContextMenuEntry::new(name) + .toggleable(IconPosition::Start, is_current) + .handler(move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }), + ) } } @@ -679,14 +720,7 @@ impl EditPredictionButton { let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle); let eager_mode = matches!(current_mode, EditPredictionsMode::Eager); - if matches!( - provider, - EditPredictionProvider::Zed - | EditPredictionProvider::Copilot - | EditPredictionProvider::Supermaven - | EditPredictionProvider::Codestral - ) { - menu = menu + menu = menu .separator() .header("Display Modes") .item( @@ -715,104 +749,111 @@ impl EditPredictionButton { } }), ); - } menu = menu.separator().header("Privacy"); - if let Some(provider) = &self.edit_prediction_provider { - let data_collection = provider.data_collection_state(cx); - - if data_collection.is_supported() { - let provider = provider.clone(); - let enabled = data_collection.is_enabled(); - let is_open_source = data_collection.is_project_open_source(); - let is_collecting = data_collection.is_enabled(); - let (icon_name, icon_color) = if is_open_source && is_collecting { - (IconName::Check, Color::Success) - } else { - (IconName::Check, Color::Accent) - }; - - menu = menu.item( - ContextMenuEntry::new("Training Data Collection") - .toggleable(IconPosition::Start, data_collection.is_enabled()) - .icon(icon_name) - .icon_color(icon_color) - .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { - let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { - (true, true) => ( - "Project identified as open source, and you're sharing data.", - Color::Default, - IconName::Check, - Color::Success, - ), - (true, false) => ( - "Project identified as open source, but you're not sharing data.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, true) => ( - "Project not identified as open source. No data captured.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, false) => ( - "Project not identified as open source, and setting turned off.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - }; - v_flex() - .gap_2() - .child( - Label::new(indoc!{ - "Help us improve our open dataset model by sharing data from open source repositories. \ - Zed must detect a license file in your repo for this setting to take effect. \ - Files with sensitive data and secrets are excluded by default." - }) - ) - .child( - h_flex() - .items_start() - .pt_2() - .pr_1() - .flex_1() - .gap_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) - .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) - ) - .into_any_element() - }) - .handler(move |_, cx| { - provider.toggle_data_collection(cx); - - if !enabled { - telemetry::event!( - "Data Collection Enabled", - source = "Edit Prediction Status Menu" - ); - } else { - telemetry::event!( - "Data Collection Disabled", - source = "Edit Prediction Status Menu" - ); - } - }) - ); + if matches!( + provider, + EditPredictionProvider::Zed + | EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + ) + ) { + if let Some(provider) = &self.edit_prediction_provider { + let data_collection = provider.data_collection_state(cx); + + if data_collection.is_supported() { + let provider = provider.clone(); + let enabled = data_collection.is_enabled(); + let is_open_source = data_collection.is_project_open_source(); + let is_collecting = data_collection.is_enabled(); + let (icon_name, icon_color) = if is_open_source && is_collecting { + (IconName::Check, Color::Success) + } else { + (IconName::Check, Color::Accent) + }; - if is_collecting && !is_open_source { menu = menu.item( - ContextMenuEntry::new("No data captured.") - .disabled(true) - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::Small), + ContextMenuEntry::new("Training Data Collection") + .toggleable(IconPosition::Start, data_collection.is_enabled()) + .icon(icon_name) + .icon_color(icon_color) + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { + let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { + (true, true) => ( + "Project identified as open source, and you're sharing data.", + Color::Default, + IconName::Check, + Color::Success, + ), + (true, false) => ( + "Project identified as open source, but you're not sharing data.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, true) => ( + "Project not identified as open source. No data captured.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, false) => ( + "Project not identified as open source, and setting turned off.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + }; + v_flex() + .gap_2() + .child( + Label::new(indoc!{ + "Help us improve our open dataset model by sharing data from open source repositories. \ + Zed must detect a license file in your repo for this setting to take effect. \ + Files with sensitive data and secrets are excluded by default." + }) + ) + .child( + h_flex() + .items_start() + .pt_2() + .pr_1() + .flex_1() + .gap_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) + .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) + ) + .into_any_element() + }) + .handler(move |_, cx| { + provider.toggle_data_collection(cx); + + if !enabled { + telemetry::event!( + "Data Collection Enabled", + source = "Edit Prediction Status Menu" + ); + } else { + telemetry::event!( + "Data Collection Disabled", + source = "Edit Prediction Status Menu" + ); + } + }) ); + + if is_collecting && !is_open_source { + menu = menu.item( + ContextMenuEntry::new("No data captured.") + .disabled(true) + .icon(IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::Small), + ); + } } } } @@ -872,8 +913,14 @@ impl EditPredictionButton { ) .context(editor_focus_handle) .when( - cx.has_flag::(), - |this| this.action("Rate Completions", RateCompletions.boxed_clone()), + cx.has_flag::(), + |this| { + this.action( + "Capture Edit Prediction Example", + CaptureExample.boxed_clone(), + ) + .action("Rate Predictions", RatePredictions.boxed_clone()) + }, ); } @@ -934,14 +981,11 @@ impl EditPredictionButton { let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx); - menu.separator() - .entry("Configure Codestral API Key", None, move |window, cx| { - window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx); - }) + menu }) } - fn build_zeta_context_menu( + fn build_edit_prediction_context_menu( &self, provider: EditPredictionProvider, window: &mut Window, @@ -1030,8 +1074,97 @@ impl EditPredictionButton { .separator(); } - let menu = self.build_language_settings_menu(menu, window, cx); - let menu = self.add_provider_switching_section(menu, provider, cx); + menu = self.build_language_settings_menu(menu, window, cx); + + if cx.has_flag::() { + let settings = all_language_settings(None, cx); + let context_retrieval = settings.edit_predictions.use_context; + menu = menu.separator().header("Context Retrieval").item( + ContextMenuEntry::new("Enable Context Retrieval") + .toggleable(IconPosition::Start, context_retrieval) + .action(workspace::ToggleEditPrediction.boxed_clone()) + .handler({ + let fs = self.fs.clone(); + move |_, cx| { + update_settings_file(fs.clone(), cx, move |settings, _| { + settings + .project + .all_languages + .features + .get_or_insert_default() + .experimental_edit_prediction_context_retrieval = + Some(!context_retrieval) + }); + } + }), + ); + } + + menu = self.add_provider_switching_section(menu, provider, cx); + menu = menu.separator().item( + ContextMenuEntry::new("Configure Providers") + .icon(IconName::Settings) + .icon_position(IconPosition::Start) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + OpenSettingsAt { + path: "edit_predictions.providers".to_string(), + } + .boxed_clone(), + cx, + ); + }), + ); + + menu + }) + } + + fn build_zeta_upsell_context_menu( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + ContextMenu::build(window, cx, |mut menu, _window, cx| { + menu = menu + .custom_row(move |_window, cx| { + let description = indoc! { + "You get 2,000 accepted suggestions at every keystroke for free, \ + powered by Zeta, our open-source, open-data model" + }; + + v_flex() + .max_w_64() + .h(rems_from_px(148.)) + .child(render_zeta_tab_animation(cx)) + .child(Label::new("Edit Prediction")) + .child( + Label::new(description) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + }) + .separator() + .entry("Sign In & Start Using", None, |window, cx| { + let client = Client::global(cx); + window + .spawn(cx, async move |cx| { + client + .sign_in_with_optional_connect(true, &cx) + .await + .log_err(); + }) + .detach(); + }) + .link( + "Learn More", + OpenBrowser { + url: zed_urls::edit_prediction_docs(cx), + } + .boxed_clone(), + ); menu }) @@ -1240,6 +1373,73 @@ fn toggle_edit_prediction_mode(fs: Arc, mode: EditPredictionsMode, cx: & } } +fn render_zeta_tab_animation(cx: &App) -> impl IntoElement { + let tab = |n: u64, inverted: bool| { + let text_color = cx.theme().colors().text; + + h_flex().child( + h_flex() + .text_size(TextSize::XSmall.rems(cx)) + .text_color(text_color) + .child("tab") + .with_animation( + ElementId::Integer(n), + Animation::new(Duration::from_secs(3)).repeat(), + move |tab, delta| { + let n_f32 = n as f32; + + let offset = if inverted { + 0.2 * (4.0 - n_f32) + } else { + 0.2 * n_f32 + }; + + let phase = (delta - offset + 1.0) % 1.0; + let pulse = if phase < 0.6 { + let t = phase / 0.6; + 1.0 - (0.5 - t).abs() * 2.0 + } else { + 0.0 + }; + + let eased = ease_in_out(pulse); + let opacity = 0.1 + 0.5 * eased; + + tab.text_color(text_color.opacity(opacity)) + }, + ), + ) + }; + + let tab_sequence = |inverted: bool| { + h_flex() + .gap_1() + .child(tab(0, inverted)) + .child(tab(1, inverted)) + .child(tab(2, inverted)) + .child(tab(3, inverted)) + .child(tab(4, inverted)) + }; + + h_flex() + .my_1p5() + .p_4() + .justify_center() + .gap_2() + .rounded_xs() + .border_1() + .border_dashed() + .border_color(cx.theme().colors().border) + .bg(gpui::pattern_slash( + cx.theme().colors().border.opacity(0.5), + 1., + 8., + )) + .child(tab_sequence(true)) + .child(Icon::new(IconName::ZedPredict)) + .child(tab_sequence(false)) +} + fn copilot_settings_url(enterprise_uri: Option<&str>) -> String { match enterprise_uri { Some(uri) => { diff --git a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..92d66d2bec3a7a3b35678f1d4da92fae6b071633 --- /dev/null +++ b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs @@ -0,0 +1,370 @@ +use std::{ + any::TypeId, + collections::VecDeque, + ops::Add, + sync::Arc, + time::{Duration, Instant}, +}; + +use anyhow::Result; +use client::{Client, UserStore}; +use editor::{Editor, PathKey}; +use futures::StreamExt as _; +use gpui::{ + Animation, AnimationExt, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement as _, IntoElement as _, ParentElement as _, SharedString, + Styled as _, Task, TextAlign, Window, actions, div, pulsating_between, +}; +use multi_buffer::MultiBuffer; +use project::Project; +use text::Point; +use ui::{ + ButtonCommon, Clickable, Disableable, FluentBuilder as _, IconButton, IconName, + StyledTypography as _, h_flex, v_flex, +}; + +use edit_prediction::{ + ContextRetrievalFinishedDebugEvent, ContextRetrievalStartedDebugEvent, DebugEvent, + EditPredictionStore, +}; +use workspace::Item; + +pub struct EditPredictionContextView { + empty_focus_handle: FocusHandle, + project: Entity, + store: Entity, + runs: VecDeque, + current_ix: usize, + _update_task: Task>, +} + +#[derive(Debug)] +struct RetrievalRun { + editor: Entity, + started_at: Instant, + metadata: Vec<(&'static str, SharedString)>, + finished_at: Option, +} + +actions!( + dev, + [ + /// Go to the previous context retrieval run + EditPredictionContextGoBack, + /// Go to the next context retrieval run + EditPredictionContextGoForward + ] +); + +impl EditPredictionContextView { + pub fn new( + project: Entity, + client: &Arc, + user_store: &Entity, + window: &mut gpui::Window, + cx: &mut Context, + ) -> Self { + let store = EditPredictionStore::global(client, user_store, cx); + + let mut debug_rx = store.update(cx, |store, cx| store.debug_info(&project, cx)); + let _update_task = cx.spawn_in(window, async move |this, cx| { + while let Some(event) = debug_rx.next().await { + this.update_in(cx, |this, window, cx| { + this.handle_store_event(event, window, cx) + })?; + } + Ok(()) + }); + + Self { + empty_focus_handle: cx.focus_handle(), + project, + runs: VecDeque::new(), + current_ix: 0, + store, + _update_task, + } + } + + fn handle_store_event( + &mut self, + event: DebugEvent, + window: &mut gpui::Window, + cx: &mut Context, + ) { + match event { + DebugEvent::ContextRetrievalStarted(info) => { + if info.project_entity_id == self.project.entity_id() { + self.handle_context_retrieval_started(info, window, cx); + } + } + DebugEvent::ContextRetrievalFinished(info) => { + if info.project_entity_id == self.project.entity_id() { + self.handle_context_retrieval_finished(info, window, cx); + } + } + DebugEvent::EditPredictionStarted(_) => {} + DebugEvent::EditPredictionFinished(_) => {} + } + } + + fn handle_context_retrieval_started( + &mut self, + info: ContextRetrievalStartedDebugEvent, + window: &mut Window, + cx: &mut Context, + ) { + if self + .runs + .back() + .is_some_and(|run| run.finished_at.is_none()) + { + self.runs.pop_back(); + } + + let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly)); + let editor = cx + .new(|cx| Editor::for_multibuffer(multibuffer, Some(self.project.clone()), window, cx)); + + if self.runs.len() == 32 { + self.runs.pop_front(); + } + + self.runs.push_back(RetrievalRun { + editor, + started_at: info.timestamp, + finished_at: None, + metadata: Vec::new(), + }); + + cx.notify(); + } + + fn handle_context_retrieval_finished( + &mut self, + info: ContextRetrievalFinishedDebugEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(run) = self.runs.back_mut() else { + return; + }; + + run.finished_at = Some(info.timestamp); + run.metadata = info.metadata; + + let related_files = self + .store + .read(cx) + .context_for_project_with_buffers(&self.project, cx) + .map_or(Vec::new(), |files| files.collect()); + + let editor = run.editor.clone(); + let multibuffer = run.editor.read(cx).buffer().clone(); + + if self.current_ix + 2 == self.runs.len() { + self.current_ix += 1; + } + + cx.spawn_in(window, async move |this, cx| { + let mut paths = Vec::new(); + for (related_file, buffer) in related_files { + let point_ranges = related_file + .excerpts + .iter() + .map(|excerpt| { + Point::new(excerpt.row_range.start, 0)..Point::new(excerpt.row_range.end, 0) + }) + .collect::>(); + cx.update(|_, cx| { + let path = PathKey::for_buffer(&buffer, cx); + paths.push((path, buffer, point_ranges)); + })?; + } + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.clear(cx); + + for (path, buffer, ranges) in paths { + multibuffer.set_excerpts_for_path(path, buffer, ranges, 0, cx); + } + })?; + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + })?; + + this.update(cx, |_, cx| cx.notify()) + }) + .detach(); + } + + fn handle_go_back( + &mut self, + _: &EditPredictionContextGoBack, + window: &mut Window, + cx: &mut Context, + ) { + self.current_ix = self.current_ix.saturating_sub(1); + cx.focus_self(window); + cx.notify(); + } + + fn handle_go_forward( + &mut self, + _: &EditPredictionContextGoForward, + window: &mut Window, + cx: &mut Context, + ) { + self.current_ix = self + .current_ix + .add(1) + .min(self.runs.len().saturating_sub(1)); + cx.focus_self(window); + cx.notify(); + } + + fn render_informational_footer( + &self, + cx: &mut Context<'_, EditPredictionContextView>, + ) -> ui::Div { + let run = &self.runs[self.current_ix]; + let new_run_started = self + .runs + .back() + .map_or(false, |latest_run| latest_run.finished_at.is_none()); + + h_flex() + .p_2() + .w_full() + .font_buffer(cx) + .text_xs() + .border_t_1() + .gap_2() + .child(v_flex().h_full().flex_1().child({ + let t0 = run.started_at; + let mut table = ui::Table::<2>::new().width(ui::px(300.)).no_ui_font(); + for (key, value) in &run.metadata { + table = table.row([key.into_any_element(), value.clone().into_any_element()]) + } + table = table.row([ + "Total Time".into_any_element(), + format!("{} ms", (run.finished_at.unwrap_or(t0) - t0).as_millis()) + .into_any_element(), + ]); + table + })) + .child( + v_flex().h_full().text_align(TextAlign::Right).child( + h_flex() + .justify_end() + .child( + IconButton::new("go-back", IconName::ChevronLeft) + .disabled(self.current_ix == 0 || self.runs.len() < 2) + .tooltip(ui::Tooltip::for_action_title( + "Go to previous run", + &EditPredictionContextGoBack, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_go_back(&EditPredictionContextGoBack, window, cx); + })), + ) + .child( + div() + .child(format!("{}/{}", self.current_ix + 1, self.runs.len())) + .map(|this| { + if new_run_started { + this.with_animation( + "pulsating-count", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any_element() + } else { + this.into_any_element() + } + }), + ) + .child( + IconButton::new("go-forward", IconName::ChevronRight) + .disabled(self.current_ix + 1 == self.runs.len()) + .tooltip(ui::Tooltip::for_action_title( + "Go to next run", + &EditPredictionContextGoBack, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_go_forward( + &EditPredictionContextGoForward, + window, + cx, + ); + })), + ), + ), + ) + } +} + +impl Focusable for EditPredictionContextView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.runs + .get(self.current_ix) + .map(|run| run.editor.read(cx).focus_handle(cx)) + .unwrap_or_else(|| self.empty_focus_handle.clone()) + } +} + +impl EventEmitter<()> for EditPredictionContextView {} + +impl Item for EditPredictionContextView { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Edit Prediction Context".into() + } + + fn buffer_kind(&self, _cx: &App) -> workspace::item::ItemBufferKind { + workspace::item::ItemBufferKind::Multibuffer + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.runs.get(self.current_ix)?.editor.clone().into()) + } else { + None + } + } +} + +impl gpui::Render for EditPredictionContextView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { + v_flex() + .key_context("EditPredictionContext") + .on_action(cx.listener(Self::handle_go_back)) + .on_action(cx.listener(Self::handle_go_forward)) + .size_full() + .map(|this| { + if self.runs.is_empty() { + this.child( + v_flex() + .size_full() + .justify_center() + .items_center() + .child("No retrieval runs yet"), + ) + } else { + this.child(self.runs[self.current_ix].editor.clone()) + .child(self.render_informational_footer(cx)) + } + }) + } +} diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..a762fd22aa7c32779a096fa97b2ea20ef3c9b744 --- /dev/null +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -0,0 +1,330 @@ +mod edit_prediction_button; +mod edit_prediction_context_view; +mod rate_prediction_modal; + +use std::any::{Any as _, TypeId}; +use std::path::Path; +use std::sync::Arc; + +use command_palette_hooks::CommandPaletteFilter; +use edit_prediction::{ + EditPredictionStore, ResetOnboarding, Zeta2FeatureFlag, example_spec::ExampleSpec, +}; +use edit_prediction_context_view::EditPredictionContextView; +use editor::Editor; +use feature_flags::FeatureFlagAppExt as _; +use git::repository::DiffType; +use gpui::{Window, actions}; +use language::ToPoint as _; +use log; +use project::DisableAiSettings; +use rate_prediction_modal::RatePredictionsModal; +use settings::{Settings as _, SettingsStore}; +use text::ToOffset as _; +use ui::{App, prelude::*}; +use workspace::{SplitDirection, Workspace}; + +pub use edit_prediction_button::{EditPredictionButton, ToggleMenu}; + +use crate::rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag; + +actions!( + dev, + [ + /// Opens the edit prediction context view. + OpenEditPredictionContextView, + ] +); + +actions!( + edit_prediction, + [ + /// Opens the rate completions modal. + RatePredictions, + /// Captures an ExampleSpec from the current editing session and opens it as Markdown. + CaptureExample, + ] +); + +pub fn init(cx: &mut App) { + feature_gate_predict_edits_actions(cx); + + cx.observe_new(move |workspace: &mut Workspace, _, _cx| { + workspace.register_action(|workspace, _: &RatePredictions, window, cx| { + if cx.has_flag::() { + RatePredictionsModal::toggle(workspace, window, cx); + } + }); + + workspace.register_action(capture_edit_prediction_example); + workspace.register_action_renderer(|div, _, _, cx| { + let has_flag = cx.has_flag::(); + div.when(has_flag, |div| { + div.on_action(cx.listener( + move |workspace, _: &OpenEditPredictionContextView, window, cx| { + let project = workspace.project(); + workspace.split_item( + SplitDirection::Right, + Box::new(cx.new(|cx| { + EditPredictionContextView::new( + project.clone(), + workspace.client(), + workspace.user_store(), + window, + cx, + ) + })), + window, + cx, + ); + }, + )) + }) + }); + }) + .detach(); +} + +fn feature_gate_predict_edits_actions(cx: &mut App) { + let rate_completion_action_types = [TypeId::of::()]; + let reset_onboarding_action_types = [TypeId::of::()]; + let all_action_types = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + zed_actions::OpenZedPredictOnboarding.type_id(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&rate_completion_action_types); + filter.hide_action_types(&reset_onboarding_action_types); + filter.hide_action_types(&[zed_actions::OpenZedPredictOnboarding.type_id()]); + }); + + cx.observe_global::(move |cx| { + let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; + let has_feature_flag = cx.has_flag::(); + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + if is_ai_disabled { + filter.hide_action_types(&all_action_types); + } else if has_feature_flag { + filter.show_action_types(&rate_completion_action_types); + } else { + filter.hide_action_types(&rate_completion_action_types); + } + }); + }) + .detach(); + + cx.observe_flag::(move |is_enabled, cx| { + if !DisableAiSettings::get_global(cx).disable_ai { + if is_enabled { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.show_action_types(&rate_completion_action_types); + }); + } else { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&rate_completion_action_types); + }); + } + } + }) + .detach(); +} + +fn capture_edit_prediction_example( + workspace: &mut Workspace, + _: &CaptureExample, + window: &mut Window, + cx: &mut Context, +) { + let Some(ep_store) = EditPredictionStore::try_global(cx) else { + return; + }; + + let project = workspace.project().clone(); + + let (worktree_root, repository) = { + let project_ref = project.read(cx); + let worktree_root = project_ref + .visible_worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path()); + let repository = project_ref.active_repository(cx); + (worktree_root, repository) + }; + + let (Some(worktree_root), Some(repository)) = (worktree_root, repository) else { + log::error!("CaptureExampleSpec: missing worktree or active repository"); + return; + }; + + let repository_snapshot = repository.read(cx).snapshot(); + if worktree_root.as_ref() != repository_snapshot.work_directory_abs_path.as_ref() { + log::error!( + "repository is not at worktree root (repo={:?}, worktree={:?})", + repository_snapshot.work_directory_abs_path, + worktree_root + ); + return; + } + + let Some(repository_url) = repository_snapshot + .remote_origin_url + .clone() + .or_else(|| repository_snapshot.remote_upstream_url.clone()) + else { + log::error!("active repository has no origin/upstream remote url"); + return; + }; + + let Some(revision) = repository_snapshot + .head_commit + .as_ref() + .map(|commit| commit.sha.to_string()) + else { + log::error!("active repository has no head commit"); + return; + }; + + let mut events = ep_store.update(cx, |store, cx| { + store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + + let Some(editor) = workspace.active_item_as::(cx) else { + log::error!("no active editor"); + return; + }; + + let Some(project_path) = editor.read(cx).project_path(cx) else { + log::error!("active editor has no project path"); + return; + }; + + let Some((buffer, cursor_anchor)) = editor + .read(cx) + .buffer() + .read(cx) + .text_anchor_for_position(editor.read(cx).selections.newest_anchor().head(), cx) + else { + log::error!("failed to resolve cursor buffer/anchor"); + return; + }; + + let snapshot = buffer.read(cx).snapshot(); + let cursor_point = cursor_anchor.to_point(&snapshot); + let (_editable_range, context_range) = + edit_prediction::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + &snapshot, + 100, + 50, + ); + + let cursor_path: Arc = repository + .read(cx) + .project_path_to_repo_path(&project_path, cx) + .map(|repo_path| Path::new(repo_path.as_unix_str()).into()) + .unwrap_or_else(|| Path::new(project_path.path.as_unix_str()).into()); + + let cursor_position = { + let context_start_offset = context_range.start.to_offset(&snapshot); + let cursor_offset = cursor_anchor.to_offset(&snapshot); + let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset); + let mut excerpt = snapshot.text_for_range(context_range).collect::(); + if cursor_offset_in_excerpt <= excerpt.len() { + excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER); + } + excerpt + }; + + let markdown_language = workspace + .app_state() + .languages + .language_for_name("Markdown"); + + cx.spawn_in(window, async move |workspace_entity, cx| { + let markdown_language = markdown_language.await?; + + let uncommitted_diff_rx = repository.update(cx, |repository, cx| { + repository.diff(DiffType::HeadToWorktree, cx) + })?; + + let uncommitted_diff = match uncommitted_diff_rx.await { + Ok(Ok(diff)) => diff, + Ok(Err(error)) => { + log::error!("failed to compute uncommitted diff: {error:#}"); + return Ok(()); + } + Err(error) => { + log::error!("uncommitted diff channel dropped: {error:#}"); + return Ok(()); + } + }; + + let mut edit_history = String::new(); + let mut expected_patch = String::new(); + if let Some(last_event) = events.pop() { + for event in &events { + zeta_prompt::write_event(&mut edit_history, event); + if !edit_history.ends_with('\n') { + edit_history.push('\n'); + } + edit_history.push('\n'); + } + + zeta_prompt::write_event(&mut expected_patch, &last_event); + } + + let format = + time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]"); + let name = match format { + Ok(format) => { + let now = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + now.format(&format) + .unwrap_or_else(|_| "unknown-time".to_string()) + } + Err(_) => "unknown-time".to_string(), + }; + + let markdown = ExampleSpec { + name, + repository_url, + revision, + uncommitted_diff, + cursor_path, + cursor_position, + edit_history, + expected_patch, + } + .to_markdown(); + + let buffer = project + .update(cx, |project, cx| project.create_buffer(false, cx))? + .await?; + buffer.update(cx, |buffer, cx| { + buffer.set_text(markdown, cx); + buffer.set_language(Some(markdown_language), cx); + })?; + + workspace_entity.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new( + cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)), + ), + None, + true, + window, + cx, + ); + }) + }) + .detach_and_log_err(cx); +} diff --git a/crates/zeta/src/rate_completion_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs similarity index 57% rename from crates/zeta/src/rate_completion_modal.rs rename to crates/edit_prediction_ui/src/rate_prediction_modal.rs index a081538f5528946ea5b959981b7bd70d44b8b11b..1af65ad58083e3cccfa51ea7b674da01cad810a0 100644 --- a/crates/zeta/src/rate_completion_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -1,8 +1,16 @@ -use crate::{CompletionDiffElement, EditPrediction, EditPredictionRating, Zeta}; -use editor::Editor; -use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, actions, prelude::*}; -use language::language_settings; -use std::time::Duration; +use buffer_diff::{BufferDiff, BufferDiffSnapshot}; +use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore}; +use editor::{Editor, ExcerptRange, MultiBuffer}; +use feature_flags::FeatureFlag; +use gpui::{ + App, BorderStyle, DismissEvent, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, + Length, StyleRefinement, TextStyleRefinement, Window, actions, prelude::*, +}; +use language::{LanguageRegistry, Point, language_settings}; +use markdown::{Markdown, MarkdownStyle}; +use settings::Settings as _; +use std::{fmt::Write, sync::Arc, time::Duration}; +use theme::ThemeSettings; use ui::{KeyBinding, List, ListItem, ListItemSpacing, Tooltip, prelude::*}; use workspace::{ModalView, Workspace}; @@ -10,41 +18,50 @@ actions!( zeta, [ /// Rates the active completion with a thumbs up. - ThumbsUpActiveCompletion, + ThumbsUpActivePrediction, /// Rates the active completion with a thumbs down. - ThumbsDownActiveCompletion, + ThumbsDownActivePrediction, /// Navigates to the next edit in the completion history. NextEdit, /// Navigates to the previous edit in the completion history. PreviousEdit, /// Focuses on the completions list. - FocusCompletions, + FocusPredictions, /// Previews the selected completion. - PreviewCompletion, + PreviewPrediction, ] ); -pub struct RateCompletionModal { - zeta: Entity, - active_completion: Option, +pub struct PredictEditsRatePredictionsFeatureFlag; + +impl FeatureFlag for PredictEditsRatePredictionsFeatureFlag { + const NAME: &'static str = "predict-edits-rate-completions"; +} + +pub struct RatePredictionsModal { + ep_store: Entity, + language_registry: Arc, + active_prediction: Option, selected_index: usize, + diff_editor: Entity, focus_handle: FocusHandle, _subscription: gpui::Subscription, - current_view: RateCompletionView, + current_view: RatePredictionView, } -struct ActiveCompletion { - completion: EditPrediction, +struct ActivePrediction { + prediction: EditPrediction, feedback_editor: Entity, + formatted_inputs: Entity, } #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -enum RateCompletionView { +enum RatePredictionView { SuggestedEdits, RawInput, } -impl RateCompletionView { +impl RatePredictionView { pub fn name(&self) -> &'static str { match self { Self::SuggestedEdits => "Suggested Edits", @@ -53,25 +70,42 @@ impl RateCompletionView { } } -impl RateCompletionModal { +impl RatePredictionsModal { pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { - if let Some(zeta) = Zeta::global(cx) { - workspace.toggle_modal(window, cx, |_window, cx| RateCompletionModal::new(zeta, cx)); + if let Some(ep_store) = EditPredictionStore::try_global(cx) { + let language_registry = workspace.app_state().languages.clone(); + workspace.toggle_modal(window, cx, |window, cx| { + RatePredictionsModal::new(ep_store, language_registry, window, cx) + }); - telemetry::event!("Rate Completion Modal Open", source = "Edit Prediction"); + telemetry::event!("Rate Prediction Modal Open", source = "Edit Prediction"); } } - pub fn new(zeta: Entity, cx: &mut Context) -> Self { - let subscription = cx.observe(&zeta, |_, _, cx| cx.notify()); + pub fn new( + ep_store: Entity, + language_registry: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let subscription = cx.observe(&ep_store, |_, _, cx| cx.notify()); Self { - zeta, + ep_store, + language_registry, selected_index: 0, focus_handle: cx.focus_handle(), - active_completion: None, + active_prediction: None, _subscription: subscription, - current_view: RateCompletionView::SuggestedEdits, + diff_editor: cx.new(|cx| { + let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly)); + let mut editor = Editor::for_multibuffer(multibuffer, None, window, cx); + editor.disable_inline_diagnostics(); + editor.set_expand_all_diff_hunks(cx); + editor.set_show_git_diff_gutter(false, cx); + editor + }), + current_view: RatePredictionView::SuggestedEdits, } } @@ -83,7 +117,7 @@ impl RateCompletionModal { self.selected_index += 1; self.selected_index = usize::min( self.selected_index, - self.zeta.read(cx).shown_completions().count(), + self.ep_store.read(cx).shown_predictions().count(), ); cx.notify(); } @@ -100,9 +134,9 @@ impl RateCompletionModal { fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) { let next_index = self - .zeta + .ep_store .read(cx) - .shown_completions() + .shown_predictions() .skip(self.selected_index) .enumerate() .skip(1) // Skip straight to the next item @@ -116,13 +150,13 @@ impl RateCompletionModal { } fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context) { - let zeta = self.zeta.read(cx); - let completions_len = zeta.shown_completions_len(); + let ep_store = self.ep_store.read(cx); + let completions_len = ep_store.shown_completions_len(); let prev_index = self - .zeta + .ep_store .read(cx) - .shown_completions() + .shown_predictions() .rev() .skip((completions_len - 1) - self.selected_index) .enumerate() @@ -143,20 +177,20 @@ impl RateCompletionModal { } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.selected_index = self.zeta.read(cx).shown_completions_len() - 1; + self.selected_index = self.ep_store.read(cx).shown_completions_len() - 1; cx.notify(); } pub fn thumbs_up_active( &mut self, - _: &ThumbsUpActiveCompletion, + _: &ThumbsUpActivePrediction, window: &mut Window, cx: &mut Context, ) { - self.zeta.update(cx, |zeta, cx| { - if let Some(active) = &self.active_completion { - zeta.rate_completion( - &active.completion, + self.ep_store.update(cx, |ep_store, cx| { + if let Some(active) = &self.active_prediction { + ep_store.rate_prediction( + &active.prediction, EditPredictionRating::Positive, active.feedback_editor.read(cx).text(cx), cx, @@ -165,9 +199,9 @@ impl RateCompletionModal { }); let current_completion = self - .active_completion + .active_prediction .as_ref() - .map(|completion| completion.completion.clone()); + .map(|completion| completion.prediction.clone()); self.select_completion(current_completion, false, window, cx); self.select_next_edit(&Default::default(), window, cx); self.confirm(&Default::default(), window, cx); @@ -177,18 +211,18 @@ impl RateCompletionModal { pub fn thumbs_down_active( &mut self, - _: &ThumbsDownActiveCompletion, + _: &ThumbsDownActivePrediction, window: &mut Window, cx: &mut Context, ) { - if let Some(active) = &self.active_completion { + if let Some(active) = &self.active_prediction { if active.feedback_editor.read(cx).text(cx).is_empty() { return; } - self.zeta.update(cx, |zeta, cx| { - zeta.rate_completion( - &active.completion, + self.ep_store.update(cx, |ep_store, cx| { + ep_store.rate_prediction( + &active.prediction, EditPredictionRating::Negative, active.feedback_editor.read(cx).text(cx), cx, @@ -197,9 +231,9 @@ impl RateCompletionModal { } let current_completion = self - .active_completion + .active_prediction .as_ref() - .map(|completion| completion.completion.clone()); + .map(|completion| completion.prediction.clone()); self.select_completion(current_completion, false, window, cx); self.select_next_edit(&Default::default(), window, cx); self.confirm(&Default::default(), window, cx); @@ -209,7 +243,7 @@ impl RateCompletionModal { fn focus_completions( &mut self, - _: &FocusCompletions, + _: &FocusPredictions, window: &mut Window, cx: &mut Context, ) { @@ -219,14 +253,14 @@ impl RateCompletionModal { fn preview_completion( &mut self, - _: &PreviewCompletion, + _: &PreviewPrediction, window: &mut Window, cx: &mut Context, ) { let completion = self - .zeta + .ep_store .read(cx) - .shown_completions() + .shown_predictions() .skip(self.selected_index) .take(1) .next() @@ -237,9 +271,9 @@ impl RateCompletionModal { fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { let completion = self - .zeta + .ep_store .read(cx) - .shown_completions() + .shown_predictions() .skip(self.selected_index) .take(1) .next() @@ -250,54 +284,153 @@ impl RateCompletionModal { pub fn select_completion( &mut self, - completion: Option, + prediction: Option, focus: bool, window: &mut Window, cx: &mut Context, ) { // Avoid resetting completion rating if it's already selected. - if let Some(completion) = completion.as_ref() { + if let Some(prediction) = prediction { self.selected_index = self - .zeta + .ep_store .read(cx) - .shown_completions() + .shown_predictions() .enumerate() - .find(|(_, completion_b)| completion.id == completion_b.id) + .find(|(_, completion_b)| prediction.id == completion_b.id) .map(|(ix, _)| ix) .unwrap_or(self.selected_index); cx.notify(); - if let Some(prev_completion) = self.active_completion.as_ref() - && completion.id == prev_completion.completion.id + if let Some(prev_prediction) = self.active_prediction.as_ref() + && prediction.id == prev_prediction.prediction.id { if focus { - window.focus(&prev_completion.feedback_editor.focus_handle(cx)); + window.focus(&prev_prediction.feedback_editor.focus_handle(cx), cx); } return; } - } - self.active_completion = completion.map(|completion| ActiveCompletion { - completion, - feedback_editor: cx.new(|cx| { - let mut editor = Editor::multi_line(window, cx); - editor.disable_scrollbars_and_minimap(window, cx); - editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); - editor.set_show_line_numbers(false, cx); - editor.set_show_git_diff_gutter(false, cx); - editor.set_show_code_actions(false, cx); - editor.set_show_runnables(false, cx); - editor.set_show_breakpoints(false, cx); - editor.set_show_wrap_guides(false, cx); - editor.set_show_indent_guides(false, cx); - editor.set_show_edit_predictions(Some(false), window, cx); - editor.set_placeholder_text("Add your feedback…", window, cx); - if focus { - cx.focus_self(window); + self.diff_editor.update(cx, |editor, cx| { + let new_buffer = prediction.edit_preview.build_result_buffer(cx); + let new_buffer_snapshot = new_buffer.read(cx).snapshot(); + let old_buffer_snapshot = prediction.snapshot.clone(); + let new_buffer_id = new_buffer_snapshot.remote_id(); + + let range = prediction + .edit_preview + .compute_visible_range(&prediction.edits) + .unwrap_or(Point::zero()..Point::zero()); + let start = Point::new(range.start.row.saturating_sub(5), 0); + let end = Point::new(range.end.row + 5, 0).min(new_buffer_snapshot.max_point()); + + let diff = cx.new::(|cx| { + let diff_snapshot = BufferDiffSnapshot::new_with_base_buffer( + new_buffer_snapshot.text.clone(), + Some(old_buffer_snapshot.text().into()), + old_buffer_snapshot.clone(), + cx, + ); + let diff = BufferDiff::new(&new_buffer_snapshot, cx); + cx.spawn(async move |diff, cx| { + let diff_snapshot = diff_snapshot.await; + diff.update(cx, |diff, cx| { + diff.set_snapshot(diff_snapshot, &new_buffer_snapshot.text, cx); + }) + }) + .detach(); + diff + }); + + editor.disable_header_for_buffer(new_buffer_id, cx); + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.clear(cx); + multibuffer.push_excerpts( + new_buffer, + vec![ExcerptRange { + context: start..end, + primary: start..end, + }], + cx, + ); + multibuffer.add_diff(diff, cx); + }); + }); + + let mut formatted_inputs = String::new(); + + write!(&mut formatted_inputs, "## Events\n\n").unwrap(); + + for event in &prediction.inputs.events { + formatted_inputs.push_str("```diff\n"); + zeta_prompt::write_event(&mut formatted_inputs, event.as_ref()); + formatted_inputs.push_str("```\n\n"); + } + + write!(&mut formatted_inputs, "## Related files\n\n").unwrap(); + + for included_file in prediction.inputs.related_files.as_ref() { + write!( + &mut formatted_inputs, + "### {}\n\n", + included_file.path.display() + ) + .unwrap(); + + for excerpt in included_file.excerpts.iter() { + write!( + &mut formatted_inputs, + "```{}\n{}\n```\n", + included_file.path.display(), + excerpt.text + ) + .unwrap(); } - editor - }), - }); + } + + write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap(); + + writeln!( + &mut formatted_inputs, + "```{}\n{}{}\n```\n", + prediction.inputs.cursor_path.display(), + &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt], + &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..], + ) + .unwrap(); + + self.active_prediction = Some(ActivePrediction { + prediction, + feedback_editor: cx.new(|cx| { + let mut editor = Editor::multi_line(window, cx); + editor.disable_scrollbars_and_minimap(window, cx); + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor.set_show_line_numbers(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_runnables(false, cx); + editor.set_show_breakpoints(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_show_edit_predictions(Some(false), window, cx); + editor.set_placeholder_text("Add your feedback…", window, cx); + if focus { + cx.focus_self(window); + } + editor + }), + formatted_inputs: cx.new(|cx| { + Markdown::new( + formatted_inputs.into(), + Some(self.language_registry.clone()), + None, + cx, + ) + }), + }); + } else { + self.active_prediction = None; + } + cx.notify(); } @@ -312,33 +445,31 @@ impl RateCompletionModal { .child( Button::new( ElementId::Name("suggested-edits".into()), - RateCompletionView::SuggestedEdits.name(), + RatePredictionView::SuggestedEdits.name(), ) .label_size(LabelSize::Small) .on_click(cx.listener(move |this, _, _window, cx| { - this.current_view = RateCompletionView::SuggestedEdits; + this.current_view = RatePredictionView::SuggestedEdits; cx.notify(); })) - .toggle_state(self.current_view == RateCompletionView::SuggestedEdits), + .toggle_state(self.current_view == RatePredictionView::SuggestedEdits), ) .child( Button::new( ElementId::Name("raw-input".into()), - RateCompletionView::RawInput.name(), + RatePredictionView::RawInput.name(), ) .label_size(LabelSize::Small) .on_click(cx.listener(move |this, _, _window, cx| { - this.current_view = RateCompletionView::RawInput; + this.current_view = RatePredictionView::RawInput; cx.notify(); })) - .toggle_state(self.current_view == RateCompletionView::RawInput), + .toggle_state(self.current_view == RatePredictionView::RawInput), ) } fn render_suggested_edits(&self, cx: &mut Context) -> Option> { - let active_completion = self.active_completion.as_ref()?; let bg_color = cx.theme().colors().editor_background; - Some( div() .id("diff") @@ -347,14 +478,18 @@ impl RateCompletionModal { .bg(bg_color) .overflow_scroll() .whitespace_nowrap() - .child(CompletionDiffElement::new( - &active_completion.completion, - cx, - )), + .child(self.diff_editor.clone()), ) } - fn render_raw_input(&self, cx: &mut Context) -> Option> { + fn render_raw_input( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let theme_settings = ThemeSettings::get_global(cx); + let buffer_font_size = theme_settings.buffer_font_size(cx); + Some( v_flex() .size_full() @@ -368,30 +503,81 @@ impl RateCompletionModal { .size_full() .bg(cx.theme().colors().editor_background) .overflow_scroll() - .child(if let Some(active_completion) = &self.active_completion { - format!( - "{}\n{}", - active_completion.completion.input_events, - active_completion.completion.input_excerpt + .child(if let Some(active_prediction) = &self.active_prediction { + markdown::MarkdownElement::new( + active_prediction.formatted_inputs.clone(), + MarkdownStyle { + base_text_style: window.text_style(), + syntax: cx.theme().syntax().clone(), + code_block: StyleRefinement { + text: TextStyleRefinement { + font_family: Some( + theme_settings.buffer_font.family.clone(), + ), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }, + padding: EdgesRefinement { + top: Some(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(8.)), + )), + left: Some(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(8.)), + )), + right: Some(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(8.)), + )), + bottom: Some(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(8.)), + )), + }, + margin: EdgesRefinement { + top: Some(Length::Definite(px(8.).into())), + left: Some(Length::Definite(px(0.).into())), + right: Some(Length::Definite(px(0.).into())), + bottom: Some(Length::Definite(px(12.).into())), + }, + border_style: Some(BorderStyle::Solid), + border_widths: EdgesRefinement { + top: Some(AbsoluteLength::Pixels(px(1.))), + left: Some(AbsoluteLength::Pixels(px(1.))), + right: Some(AbsoluteLength::Pixels(px(1.))), + bottom: Some(AbsoluteLength::Pixels(px(1.))), + }, + border_color: Some(cx.theme().colors().border_variant), + background: Some( + cx.theme().colors().editor_background.into(), + ), + ..Default::default() + }, + ..Default::default() + }, ) + .into_any_element() } else { - "No active completion".to_string() + div() + .child("No active completion".to_string()) + .into_any_element() }), ) .id("raw-input-view"), ) } - fn render_active_completion(&mut self, cx: &mut Context) -> Option { - let active_completion = self.active_completion.as_ref()?; - let completion_id = active_completion.completion.id; + fn render_active_completion( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let active_prediction = self.active_prediction.as_ref()?; + let completion_id = active_prediction.prediction.id.clone(); let focus_handle = &self.focus_handle(cx); let border_color = cx.theme().colors().border; let bg_color = cx.theme().colors().editor_background; - let rated = self.zeta.read(cx).is_completion_rated(completion_id); - let feedback_empty = active_completion + let rated = self.ep_store.read(cx).is_prediction_rated(&completion_id); + let feedback_empty = active_prediction .feedback_editor .read(cx) .text(cx) @@ -412,10 +598,10 @@ impl RateCompletionModal { .child(self.render_view_nav(cx)) .when_some( match self.current_view { - RateCompletionView::SuggestedEdits => { + RatePredictionView::SuggestedEdits => { self.render_suggested_edits(cx) } - RateCompletionView::RawInput => self.render_raw_input(cx), + RatePredictionView::RawInput => self.render_raw_input(window, cx), }, |this, element| this.child(element), ), @@ -450,7 +636,7 @@ impl RateCompletionModal { .h_40() .pt_1() .bg(bg_color) - .child(active_completion.feedback_editor.clone()), + .child(active_prediction.feedback_editor.clone()), ) }) .child( @@ -472,7 +658,7 @@ impl RateCompletionModal { ) .child(Label::new("Rated completion.").color(Color::Muted)), ) - } else if active_completion.completion.edits.is_empty() { + } else if active_prediction.prediction.edits.is_empty() { Some( label_container .child( @@ -489,7 +675,7 @@ impl RateCompletionModal { h_flex() .gap_1() .child( - Button::new("bad", "Bad Completion") + Button::new("bad", "Bad Prediction") .icon(IconName::ThumbsDown) .icon_size(IconSize::Small) .icon_position(IconPosition::Start) @@ -500,14 +686,14 @@ impl RateCompletionModal { )) }) .key_binding(KeyBinding::for_action_in( - &ThumbsDownActiveCompletion, + &ThumbsDownActivePrediction, focus_handle, cx, )) .on_click(cx.listener(move |this, _, window, cx| { - if this.active_completion.is_some() { + if this.active_prediction.is_some() { this.thumbs_down_active( - &ThumbsDownActiveCompletion, + &ThumbsDownActivePrediction, window, cx, ); @@ -515,20 +701,20 @@ impl RateCompletionModal { })), ) .child( - Button::new("good", "Good Completion") + Button::new("good", "Good Prediction") .icon(IconName::ThumbsUp) .icon_size(IconSize::Small) .icon_position(IconPosition::Start) .disabled(rated) .key_binding(KeyBinding::for_action_in( - &ThumbsUpActiveCompletion, + &ThumbsUpActivePrediction, focus_handle, cx, )) .on_click(cx.listener(move |this, _, window, cx| { - if this.active_completion.is_some() { + if this.active_prediction.is_some() { this.thumbs_up_active( - &ThumbsUpActiveCompletion, + &ThumbsUpActivePrediction, window, cx, ); @@ -541,36 +727,34 @@ impl RateCompletionModal { } fn render_shown_completions(&self, cx: &Context) -> impl Iterator { - self.zeta + self.ep_store .read(cx) - .shown_completions() + .shown_predictions() .cloned() .enumerate() .map(|(index, completion)| { let selected = self - .active_completion + .active_prediction .as_ref() - .is_some_and(|selected| selected.completion.id == completion.id); - let rated = self.zeta.read(cx).is_completion_rated(completion.id); + .is_some_and(|selected| selected.prediction.id == completion.id); + let rated = self.ep_store.read(cx).is_prediction_rated(&completion.id); let (icon_name, icon_color, tooltip_text) = match (rated, completion.edits.is_empty()) { - (true, _) => (IconName::Check, Color::Success, "Rated Completion"), + (true, _) => (IconName::Check, Color::Success, "Rated Prediction"), (false, true) => (IconName::File, Color::Muted, "No Edits Produced"), (false, false) => (IconName::FileDiff, Color::Accent, "Edits Available"), }; - let file_name = completion - .path - .file_name() - .map(|f| f.to_string_lossy().into_owned()) - .unwrap_or("untitled".to_string()); - let file_path = completion - .path - .parent() - .map(|p| p.to_string_lossy().into_owned()); - - ListItem::new(completion.id) + let file = completion.buffer.read(cx).file(); + let file_name = file + .as_ref() + .map_or(SharedString::new_static("untitled"), |file| { + file.file_name(cx).to_string().into() + }); + let file_path = file.map(|file| file.path().as_unix_str().to_string()); + + ListItem::new(completion.id.clone()) .inset(true) .spacing(ListItemSpacing::Sparse) .focused(index == self.selected_index) @@ -615,12 +799,12 @@ impl RateCompletionModal { } } -impl Render for RateCompletionModal { +impl Render for RatePredictionsModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let border_color = cx.theme().colors().border; h_flex() - .key_context("RateCompletionModal") + .key_context("RatePredictionModal") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::dismiss)) .on_action(cx.listener(Self::confirm)) @@ -688,20 +872,20 @@ impl Render for RateCompletionModal { ), ), ) - .children(self.render_active_completion(cx)) + .children(self.render_active_completion(window, cx)) .on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))) } } -impl EventEmitter for RateCompletionModal {} +impl EventEmitter for RatePredictionsModal {} -impl Focusable for RateCompletionModal { +impl Focusable for RatePredictionsModal { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } -impl ModalView for RateCompletionModal {} +impl ModalView for RatePredictionsModal {} fn format_time_ago(elapsed: Duration) -> String { let seconds = elapsed.as_secs(); diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index bd3ac75649ce705f98f7fa1c2616dc2bcf152642..f3ed28ab05c6839a478ebbf6c81ca5e66fc372e3 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -41,6 +41,7 @@ dap.workspace = true db.workspace = true buffer_diff.workspace = true emojis.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true @@ -48,7 +49,7 @@ fs.workspace = true git.workspace = true gpui.workspace = true indoc.workspace = true -edit_prediction.workspace = true +edit_prediction_types.workspace = true itertools.workspace = true language.workspace = true linkify.workspace = true @@ -83,6 +84,8 @@ tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } tree-sitter-python = { workspace = true, optional = true } +ztracing.workspace = true +tracing.workspace = true unicode-segmentation.workspace = true unicode-script.workspace = true unindent = { workspace = true, optional = true } @@ -93,6 +96,7 @@ uuid.workspace = true vim_mode_setting.workspace = true workspace.workspace = true zed_actions.workspace = true +zlog.workspace = true [dev-dependencies] criterion.workspace = true @@ -117,6 +121,7 @@ tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true tree-sitter-yaml.workspace = true tree-sitter-bash.workspace = true +tree-sitter-md.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/editor/benches/display_map.rs b/crates/editor/benches/display_map.rs index 2459e7466f8054189c9f644e404158b9612e2b9e..c443bdba1c87bf9f8eac7588e2189c4fc98fb1f3 100644 --- a/crates/editor/benches/display_map.rs +++ b/crates/editor/benches/display_map.rs @@ -45,7 +45,7 @@ fn to_tab_point_benchmark(c: &mut Criterion) { &snapshot, |bench, snapshot| { bench.iter(|| { - snapshot.to_tab_point(fold_point); + snapshot.fold_point_to_tab_point(fold_point); }); }, ); @@ -79,7 +79,7 @@ fn to_fold_point_benchmark(c: &mut Criterion) { ); let (_, snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(4).unwrap()); - let tab_point = snapshot.to_tab_point(fold_point); + let tab_point = snapshot.fold_point_to_tab_point(fold_point); (length, snapshot, tab_point) }; @@ -94,7 +94,7 @@ fn to_fold_point_benchmark(c: &mut Criterion) { &snapshot, |bench, snapshot| { bench.iter(|| { - snapshot.to_fold_point(tab_point, Bias::Left); + snapshot.tab_point_to_fold_point(tab_point, Bias::Left); }); }, ); diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs index 4323c6c973f3729623d8939ca89ecf3ac403bcbf..daaeede790cbd75a7238a81559513c5d3165a054 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/editor/benches/editor_render.rs @@ -29,7 +29,7 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &TestAppContext ); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); @@ -72,7 +72,7 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes editor.set_style(editor::EditorStyle::default(), window, cx); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); }); @@ -100,7 +100,7 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { editor.set_style(editor::EditorStyle::default(), window, cx); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index e823b06910fba67a38754ece6ad746f5f632e613..ba36f88f6380ade2a0d70f0f7ac3eb221446b781 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -327,6 +327,23 @@ pub struct AddSelectionBelow { pub skip_soft_wrap: bool, } +/// Inserts a snippet at the cursor. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct InsertSnippet { + /// Language name if using a named snippet, or `None` for a global snippet + /// + /// This is typically lowercase and matches the filename containing the snippet, without the `.json` extension. + pub language: Option, + /// Name if using a named snippet + pub name: Option, + + /// Snippet body, if not using a named snippet + // todo(andrew): use `ListOrDirect` or similar for multiline snippet body + pub snippet: Option, +} + actions!( debugger, [ @@ -353,7 +370,8 @@ actions!( AcceptEditPrediction, /// Accepts a partial edit prediction. #[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])] - AcceptPartialEditPrediction, + AcceptNextWordEditPrediction, + AcceptNextLineEditPrediction, /// Applies all diff hunks in the editor. ApplyAllDiffHunks, /// Applies the diff hunk at the current position. @@ -453,8 +471,6 @@ actions!( CollapseAllDiffHunks, /// Expands macros recursively at cursor position. ExpandMacroRecursively, - /// Finds all references to the symbol at cursor. - FindAllReferences, /// Finds the next match in the search. FindNextMatch, /// Finds the previous match in the search. @@ -665,6 +681,10 @@ actions!( ReloadFile, /// Rewraps text to fit within the preferred line length. Rewrap, + /// Rotates selections or lines backward. + RotateSelectionsBackward, + /// Rotates selections or lines forward. + RotateSelectionsForward, /// Runs flycheck diagnostics. RunFlycheck, /// Scrolls the cursor to the bottom of the viewport. @@ -827,3 +847,20 @@ actions!( WrapSelectionsInTag ] ); + +/// Finds all references to the symbol at cursor. +#[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct FindAllReferences { + #[serde(default = "default_true")] + pub always_open_multibuffer: bool, +} + +impl Default for FindAllReferences { + fn default() -> Self { + Self { + always_open_multibuffer: true, + } + } +} diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 902ec2b7702b945bb482e4e4700cf37b36ae907b..ee7e785ed30a14bce53bb777b67bdf69a9cecd07 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -7,6 +7,7 @@ use std::ops::Range; use crate::Editor; use collections::HashMap; use gpui::{Context, HighlightStyle}; +use itertools::Itertools; use language::language_settings; use multi_buffer::{Anchor, ExcerptId}; use ui::{ActiveTheme, utils::ensure_minimum_contrast}; @@ -26,22 +27,25 @@ impl Editor { let accents_count = cx.theme().accents().0.len(); let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); let all_excerpts = self.buffer().read(cx).excerpt_ids(); - let anchor_in_multi_buffer = |current_excerpt: ExcerptId, text_anchor: text::Anchor| { + let anchors_in_multi_buffer = |current_excerpt: ExcerptId, + text_anchors: [text::Anchor; 4]| + -> Option<[Option<_>; 4]> { multi_buffer_snapshot - .anchor_in_excerpt(current_excerpt, text_anchor) + .anchors_in_excerpt(current_excerpt, text_anchors) .or_else(|| { all_excerpts .iter() .filter(|&&excerpt_id| excerpt_id != current_excerpt) .find_map(|&excerpt_id| { - multi_buffer_snapshot.anchor_in_excerpt(excerpt_id, text_anchor) + multi_buffer_snapshot.anchors_in_excerpt(excerpt_id, text_anchors) }) - }) + })? + .collect_array() }; - let bracket_matches_by_accent = self.visible_excerpts(cx).into_iter().fold( + let bracket_matches_by_accent = self.visible_excerpts(false, cx).into_iter().fold( HashMap::default(), - |mut acc, (excerpt_id, (buffer, buffer_version, buffer_range))| { + |mut acc, (excerpt_id, (buffer, _, buffer_range))| { let buffer_snapshot = buffer.read(cx).snapshot(); if language_settings::language_settings( buffer_snapshot.language().map(|language| language.name()), @@ -58,7 +62,7 @@ impl Editor { let brackets_by_accent = buffer_snapshot .fetch_bracket_ranges( buffer_range.start..buffer_range.end, - Some((&buffer_version, fetched_chunks)), + Some(fetched_chunks), ) .into_iter() .flat_map(|(chunk_range, pairs)| { @@ -77,13 +81,24 @@ impl Editor { let buffer_close_range = buffer_snapshot .anchor_before(pair.close_range.start) ..buffer_snapshot.anchor_after(pair.close_range.end); + let [ + buffer_open_range_start, + buffer_open_range_end, + buffer_close_range_start, + buffer_close_range_end, + ] = anchors_in_multi_buffer( + excerpt_id, + [ + buffer_open_range.start, + buffer_open_range.end, + buffer_close_range.start, + buffer_close_range.end, + ], + )?; let multi_buffer_open_range = - anchor_in_multi_buffer(excerpt_id, buffer_open_range.start) - .zip(anchor_in_multi_buffer(excerpt_id, buffer_open_range.end)); + buffer_open_range_start.zip(buffer_open_range_end); let multi_buffer_close_range = - anchor_in_multi_buffer(excerpt_id, buffer_close_range.start).zip( - anchor_in_multi_buffer(excerpt_id, buffer_close_range.end), - ); + buffer_close_range_start.zip(buffer_close_range_end); let mut ranges = Vec::with_capacity(2); if let Some((open_start, open_end)) = multi_buffer_open_range { @@ -149,7 +164,7 @@ mod tests { use super::*; use crate::{ - DisplayPoint, EditorSnapshot, MoveToBeginning, MoveToEnd, MoveUp, + DisplayPoint, EditorMode, EditorSnapshot, MoveToBeginning, MoveToEnd, MoveUp, display_map::{DisplayRow, ToDisplayPoint}, editor_tests::init_test, test::{ @@ -161,7 +176,7 @@ mod tests { use gpui::{AppContext as _, UpdateGlobal as _}; use indoc::indoc; use itertools::Itertools; - use language::Capability; + use language::{Capability, markdown_lang}; use languages::rust_lang; use multi_buffer::{ExcerptRange, MultiBuffer}; use pretty_assertions::assert_eq; @@ -261,6 +276,133 @@ where ); } + #[gpui::test] + async fn test_file_less_file_colorization(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let editor = cx.add_window(|window, cx| { + let multi_buffer = MultiBuffer::build_simple("fn main() {}", cx); + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }); + }); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + "fn main«1()1» «1{}1» +1 hsla(207.80, 16.20%, 69.19%, 1.00) +", + editor + .update(cx, |editor, window, cx| { + editor_bracket_colors_markup(&editor.snapshot(window, cx)) + }) + .unwrap(), + "File-less buffer should still have its brackets colorized" + ); + } + + #[gpui::test] + async fn test_markdown_bracket_colorization(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(markdown_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + cx.set_state(indoc! {r#"ˇ[LLM-powered features](./ai/overview.md), [bring and configure your own API keys](./ai/llm-providers.md#use-your-own-keys)"#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"«1[LLM-powered features]1»«1(./ai/overview.md)1», «1[bring and configure your own API keys]1»«1(./ai/llm-providers.md#use-your-own-keys)1» +1 hsla(207.80, 16.20%, 69.19%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "All markdown brackets should be colored based on their depth" + ); + + cx.set_state(indoc! {r#"ˇ{{}}"#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"«1{«2{}2»}1» +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "All markdown brackets should be colored based on their depth, again" + ); + } + + #[gpui::test] + async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + + let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); + language_registry.add(markdown_lang()); + language_registry.add(rust_lang()); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(language_registry.clone()); + buffer.set_language(Some(markdown_lang()), cx); + }); + + cx.set_state(indoc! {r#" + fn main() { + let v: Vec = vec![]; + } + "#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"fn main«1()1» «1{ + let v: Vec = vec!«2[]2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "Markdown does not colorize <> brackets" + ); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"fn main«1()1» «1{ + let v: Vec«22» = vec!«2[]2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "After switching to Rust, <> brackets are now colorized" + ); + } + #[gpui::test] async fn test_bracket_colorization_when_editing(cx: &mut gpui::TestAppContext) { init_test(cx, |language_settings| { diff --git a/crates/editor/src/code_completion_tests.rs b/crates/editor/src/code_completion_tests.rs index 364b310f367ff195f9aee8693a815be94db0b44d..4602824486ebb88f78ed529abb91ddcc1c34646f 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/crates/editor/src/code_completion_tests.rs @@ -239,6 +239,89 @@ async fn test_fuzzy_over_sort_positions(cx: &mut TestAppContext) { assert_eq!(matches[2].string, "fetch_code_lens"); } +#[gpui::test] +async fn test_semver_label_sort_by_latest_version(cx: &mut TestAppContext) { + let mut versions = [ + "10.4.112", + "10.4.22", + "10.4.2", + "10.4.20", + "10.4.21", + "10.4.12", + // Pre-release versions + "10.4.22-alpha", + "10.4.22-beta.1", + "10.4.22-rc.1", + // Build metadata versions + "10.4.21+build.123", + "10.4.20+20210327", + ]; + versions.sort_by(|a, b| { + match ( + semver::Version::parse(a).ok(), + semver::Version::parse(b).ok(), + ) { + (Some(a_ver), Some(b_ver)) => b_ver.cmp(&a_ver), + _ => std::cmp::Ordering::Equal, + } + }); + let completions: Vec<_> = versions + .iter() + .enumerate() + .map(|(i, version)| { + // This sort text would come from the LSP + let sort_text = format!("{:08}", i); + CompletionBuilder::new(version, None, &sort_text, None) + }) + .collect(); + + // Case 1: User types just the major and minor version + let matches = + filter_and_sort_matches("10.4.", &completions, SnippetSortOrder::default(), cx).await; + // Versions are ordered by recency (latest first) + let expected_versions = [ + "10.4.112", + "10.4.22", + "10.4.22-rc.1", + "10.4.22-beta.1", + "10.4.22-alpha", + "10.4.21+build.123", + "10.4.21", + "10.4.20+20210327", + "10.4.20", + "10.4.12", + "10.4.2", + ]; + for (match_item, expected) in matches.iter().zip(expected_versions.iter()) { + assert_eq!(match_item.string.as_ref() as &str, *expected); + } + + // Case 2: User types the major, minor, and patch version + let matches = + filter_and_sort_matches("10.4.2", &completions, SnippetSortOrder::default(), cx).await; + let expected_versions = [ + // Exact match comes first + "10.4.2", + // Ordered by recency with exact major, minor, and patch versions + "10.4.22", + "10.4.22-rc.1", + "10.4.22-beta.1", + "10.4.22-alpha", + "10.4.21+build.123", + "10.4.21", + "10.4.20+20210327", + "10.4.20", + // Versions with non-exact patch versions are ordered by fuzzy score + // Higher fuzzy score than 112 patch version since "2" appears before "1" + // in "12", making it rank higher than "112" + "10.4.12", + "10.4.112", + ]; + for (match_item, expected) in matches.iter().zip(expected_versions.iter()) { + assert_eq!(match_item.string.as_ref() as &str, *expected); + } +} + async fn test_for_each_prefix( target: &str, completions: &Vec, @@ -259,30 +342,55 @@ struct CompletionBuilder; impl CompletionBuilder { fn constant(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::CONSTANT) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::CONSTANT), + ) } fn function(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::FUNCTION) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::FUNCTION), + ) } fn method(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::METHOD) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::METHOD), + ) } fn variable(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::VARIABLE) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::VARIABLE), + ) } fn snippet(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion { - Self::new(label, filter_text, sort_text, CompletionItemKind::SNIPPET) + Self::new( + label, + filter_text, + sort_text, + Some(CompletionItemKind::SNIPPET), + ) } fn new( label: &str, filter_text: Option<&str>, sort_text: &str, - kind: CompletionItemKind, + kind: Option, ) -> Completion { Completion { replace_range: Anchor::MIN..Anchor::MAX, @@ -294,7 +402,7 @@ impl CompletionBuilder { server_id: LanguageServerId(0), lsp_completion: Box::new(CompletionItem { label: label.to_string(), - kind: Some(kind), + kind: kind, sort_text: Some(sort_text.to_string()), filter_text: filter_text.map(|text| text.to_string()), ..Default::default() diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 9a2b8c385689f284fc42e49a5c7451b3774fe018..96739defc506414f573e2454dc31f9c32d8e4adf 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -49,6 +49,10 @@ pub const MENU_GAP: Pixels = px(4.); pub const MENU_ASIDE_X_PADDING: Pixels = px(16.); pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.); pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.); +pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.); +pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.); +pub const CODE_ACTION_MENU_MIN_WIDTH: Pixels = px(220.); +pub const CODE_ACTION_MENU_MAX_WIDTH: Pixels = px(540.); // Constants for the markdown cache. The purpose of this cache is to reduce flickering due to // documentation not yet being parsed. @@ -177,7 +181,7 @@ impl CodeContextMenu { ) -> Option { match self { CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx), - CodeContextMenu::CodeActions(_) => None, + CodeContextMenu::CodeActions(menu) => menu.render_aside(max_size, window, cx), } } @@ -204,6 +208,13 @@ impl CodeContextMenu { CodeContextMenu::CodeActions(_) => (), } } + + pub fn primary_scroll_handle(&self) -> UniformListScrollHandle { + match self { + CodeContextMenu::Completions(menu) => menu.scroll_handle.clone(), + CodeContextMenu::CodeActions(menu) => menu.scroll_handle.clone(), + } + } } pub enum ContextMenuOrigin { @@ -301,6 +312,7 @@ impl CompletionsMenu { is_incomplete: bool, buffer: Entity, completions: Box<[Completion]>, + scroll_handle: Option, display_options: CompletionDisplayOptions, snippet_sort_order: SnippetSortOrder, language_registry: Option>, @@ -330,7 +342,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: true, last_rendered_range: RefCell::new(None).into(), @@ -352,6 +364,7 @@ impl CompletionsMenu { choices: &Vec, selection: Range, buffer: Entity, + scroll_handle: Option, snippet_sort_order: SnippetSortOrder, ) -> Self { let completions = choices @@ -402,7 +415,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: false, show_completion_documentation: false, @@ -833,36 +846,38 @@ impl CompletionsMenu { FontWeight::BOLD.into(), ) }), - styled_runs_for_code_label(&completion.label, &style.syntax).map( - |(range, mut highlight)| { - // Ignore font weight for syntax highlighting, as we'll use it - // for fuzzy matches. - highlight.font_weight = None; - if completion - .source - .lsp_completion(false) - .and_then(|lsp_completion| { - match (lsp_completion.deprecated, &lsp_completion.tags) - { - (Some(true), _) => Some(true), - (_, Some(tags)) => Some( - tags.contains(&CompletionItemTag::DEPRECATED), - ), - _ => None, + styled_runs_for_code_label( + &completion.label, + &style.syntax, + &style.local_player, + ) + .map(|(range, mut highlight)| { + // Ignore font weight for syntax highlighting, as we'll use it + // for fuzzy matches. + highlight.font_weight = None; + if completion + .source + .lsp_completion(false) + .and_then(|lsp_completion| { + match (lsp_completion.deprecated, &lsp_completion.tags) { + (Some(true), _) => Some(true), + (_, Some(tags)) => { + Some(tags.contains(&CompletionItemTag::DEPRECATED)) } - }) - .unwrap_or(false) - { - highlight.strikethrough = Some(StrikethroughStyle { - thickness: 1.0.into(), - ..Default::default() - }); - highlight.color = Some(cx.theme().colors().text_muted); - } - - (range, highlight) - }, - ), + _ => None, + } + }) + .unwrap_or(false) + { + highlight.strikethrough = Some(StrikethroughStyle { + thickness: 1.0.into(), + ..Default::default() + }); + highlight.color = Some(cx.theme().colors().text_muted); + } + + (range, highlight) + }), ); let completion_label = StyledText::new(completion.label.text.clone()) @@ -878,7 +893,7 @@ impl CompletionsMenu { None } else { Some( - Label::new(text.clone()) + Label::new(text.trim().to_string()) .ml_4() .size(LabelSize::Small) .color(Color::Muted), @@ -907,33 +922,36 @@ impl CompletionsMenu { }) }); - div().min_w(px(280.)).max_w(px(540.)).child( - ListItem::new(mat.candidate_id) - .inset(true) - .toggle_state(item_ix == selected_item) - .on_click(cx.listener(move |editor, _event, window, cx| { - cx.stop_propagation(); - if let Some(task) = editor.confirm_completion( - &ConfirmCompletion { - item_ix: Some(item_ix), - }, - window, - cx, - ) { - task.detach_and_log_err(cx) - } - })) - .start_slot::(start_slot) - .child(h_flex().overflow_hidden().child(completion_label)) - .end_slot::