diff --git a/.agents/skills/lint-creator/SKILL.md b/.agents/skills/lint-creator/SKILL.md new file mode 100644 index 00000000000000..e72e83a9abaf1e --- /dev/null +++ b/.agents/skills/lint-creator/SKILL.md @@ -0,0 +1,17 @@ +--- +name: lint-creator +description: An auxiliary skill to add more dylints to `tooling/lints` +disable-model-invocation: false +--- + +# Lint RULES + +1. Every lint MUST have accompanying `ui` tests +2. `ui` tests MUST be in the `ui` folder +3. Every lint MUST be in a separate module +4. Every lint MUST have negative `ui` tests +5. Lints should be as simple as possible. +6. Reporting is fine if it's simple, it does not need to be elaborate or lengthy code. +7. Do NOT suggest how to fix the lint, only flag it. +8. Do NOT make lints machine applicable. +9. Detect if lints are redundant vs clippy's capabilities. diff --git a/.cloudflare/docs-proxy/src/worker.js b/.cloudflare/docs-proxy/src/worker.js index 08b0265fafbbb0..99fca0c7116614 100644 --- a/.cloudflare/docs-proxy/src/worker.js +++ b/.cloudflare/docs-proxy/src/worker.js @@ -1,6 +1,11 @@ export default { async fetch(request, _env, _ctx) { const url = new URL(request.url); + const acceptHeader = request.headers.get("Accept") || ""; + const wantsMarkdown = acceptHeader + .split(",") + .map((mediaType) => mediaType.split(";")[0].trim().toLowerCase()) + .includes("text/markdown"); if (url.pathname === "/docs/nightly") { url.hostname = "docs-nightly.pages.dev"; @@ -18,6 +23,14 @@ export default { url.hostname = "docs-anw.pages.dev"; } + if (url.pathname === "/docs.md") { + url.pathname = "/docs/getting-started.md"; + } + + if (wantsMarkdown) { + url.pathname = markdownPathFor(url.pathname); + } + let res = await fetch(url, request); if (res.status === 404) { @@ -27,3 +40,31 @@ export default { return res; }, }; + +function markdownPathFor(pathname) { + if (pathname === "/docs" || pathname === "/docs/") { + return "/docs/getting-started.md"; + } + + if (pathname.endsWith("/index.md")) { + return pathname.replace(/\/index\.md$/, "/getting-started.md"); + } + + if (pathname.endsWith(".md")) { + return pathname; + } + + if (pathname.endsWith(".html")) { + return pathname.replace(/\.html$/, ".md"); + } + + if (pathname.split("/").pop().includes(".")) { + return pathname; + } + + if (pathname.endsWith("/")) { + return `${pathname}getting-started.md`; + } + + return `${pathname}.md`; +} diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold index 0e6ab04228d43c..fea437d4ff9206 100644 --- a/.github/CODEOWNERS.hold +++ b/.github/CODEOWNERS.hold @@ -304,7 +304,6 @@ /crates/picker/ @zed-industries/ui-team /crates/refineable/ @zed-industries/ui-team /crates/story/ @zed-industries/ui-team -/crates/storybook/ @zed-industries/ui-team /crates/svg_preview/ @zed-industries/ui-team /crates/tab_switcher/ @zed-industries/ui-team /crates/theme/ @zed-industries/ui-team diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml index 9448dfebb0e1a2..49022915ee4207 100644 --- a/.github/actions/run_tests/action.yml +++ b/.github/actions/run_tests/action.yml @@ -5,7 +5,9 @@ runs: using: "composite" steps: - name: Install nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # 2.84.0 + with: + tool: nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index 3752cbb50d5384..d1a5219144e89f 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -12,7 +12,9 @@ runs: steps: - name: Install test runner working-directory: ${{ inputs.working-directory }} - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # 2.84.0 + with: + tool: nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/cherry-pick-bot.yml b/.github/cherry-pick-bot.yml deleted file mode 100644 index 1f62315d79dcac..00000000000000 --- a/.github/cherry-pick-bot.yml +++ /dev/null @@ -1,2 +0,0 @@ -enabled: true -preservePullRequestTitle: true diff --git a/.github/workflows/add_commented_closed_issue_to_project.yml b/.github/workflows/add_commented_closed_issue_to_project.yml index 46b9f5079756e5..5924e04ff78350 100644 --- a/.github/workflows/add_commented_closed_issue_to_project.yml +++ b/.github/workflows/add_commented_closed_issue_to_project.yml @@ -40,6 +40,9 @@ jobs: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries + repositories: zed + permission-members: read + permission-organization-projects: write - if: steps.is-post-close-comment.outputs.result == 'true' id: check-staff diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index 7d78af925f4124..77c3bd8507c058 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -22,6 +22,8 @@ on: description: body type: string default: '' +permissions: + contents: read jobs: rebuild_releases_page: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 9918f6be0fc933..1b05da608571a6 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -13,9 +13,14 @@ on: description: run_clippy type: boolean default: 'true' +permissions: + contents: read jobs: run_autofix: runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: read + pull-requests: read env: CC: clang CXX: clang++ @@ -43,12 +48,12 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_pnpm - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 with: version: '9' - name: autofix_pr::run_autofix::install_cargo_machete if: ${{ inputs.run_clippy }} - uses: taiki-e/install-action@02cc5f8ca9f2301050c0c099055816a41ee05507 + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 with: tool: cargo-machete@0.7.0 - name: autofix_pr::run_autofix::run_cargo_fix @@ -99,6 +104,8 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} permission-contents: write permission-workflows: write - name: steps::checkout_repo diff --git a/.github/workflows/bump_collab_staging.yml b/.github/workflows/bump_collab_staging.yml index 4f9724439f37b2..be39f41b6e54e5 100644 --- a/.github/workflows/bump_collab_staging.yml +++ b/.github/workflows/bump_collab_staging.yml @@ -5,10 +5,15 @@ on: # Fire every day at 16:00 UTC (At the start of the US workday) - cron: "0 16 * * *" +permissions: + contents: read + jobs: update-collab-staging-tag: if: github.repository_owner == 'zed-industries' runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 3618d7230f79b4..b2ef7349f4b221 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -8,10 +8,14 @@ on: description: Branch name to run on required: true type: string +permissions: + contents: read jobs: run_bump_patch_version: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy @@ -19,6 +23,10 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-workflows: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: @@ -51,7 +59,7 @@ jobs: echo "tag_suffix=$tag_suffix" } >> "$GITHUB_OUTPUT" - name: steps::install_cargo_edit - uses: taiki-e/install-action@02cc5f8ca9f2301050c0c099055816a41ee05507 + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 with: tool: cargo-edit - id: bump-version diff --git a/.github/workflows/bump_zed_version.yml b/.github/workflows/bump_zed_version.yml index f8fae3de7afc0a..f5b72cccf190a8 100644 --- a/.github/workflows/bump_zed_version.yml +++ b/.github/workflows/bump_zed_version.yml @@ -8,6 +8,8 @@ on: description: 'Which channels to bump: all, main, preview, or stable' type: string default: all +permissions: + contents: read jobs: resolve_versions: if: github.repository_owner == 'zed-industries' @@ -19,6 +21,9 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: read - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: @@ -86,6 +91,9 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'main' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write + pull-requests: write steps: - id: generate-token name: steps::authenticate_as_zippy @@ -93,6 +101,10 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: @@ -100,7 +112,7 @@ jobs: ref: main token: ${{ steps.generate-token.outputs.token }} - name: steps::install_cargo_edit - uses: taiki-e/install-action@02cc5f8ca9f2301050c0c099055816a41ee05507 + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 with: tool: cargo-edit - name: bump_zed_version::bump_main::bump_version @@ -127,6 +139,8 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'preview' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy @@ -134,6 +148,10 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-workflows: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: @@ -180,6 +198,8 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'stable' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy @@ -187,6 +207,10 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-workflows: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: diff --git a/.github/workflows/catch_blank_issues.yml b/.github/workflows/catch_blank_issues.yml index 6d6f414d8350fa..7b993d23a7aa2e 100644 --- a/.github/workflows/catch_blank_issues.yml +++ b/.github/workflows/catch_blank_issues.yml @@ -21,6 +21,9 @@ jobs: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries + repositories: zed + permission-members: read + permission-issues: write - id: check-staff uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index 82dc9fb545d027..4ff5b9bcf80b7d 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -21,23 +21,28 @@ on: description: pr_number required: true type: string +permissions: + contents: read jobs: run_cherry_pick: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - id: generate-token name: steps::authenticate_as_zippy uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} permission-contents: write permission-workflows: write permission-pull-requests: write + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + token: ${{ steps.generate-token.outputs.token }} - name: cherry_pick::run_cherry_pick::cherry_pick run: ./script/cherry-pick "$BRANCH" "$COMMIT" "$CHANNEL" env: diff --git a/.github/workflows/comment_on_potential_duplicate_issues.yml b/.github/workflows/comment_on_potential_duplicate_issues.yml index 0d7ce3aad3ce9d..498aafb434a8ed 100644 --- a/.github/workflows/comment_on_potential_duplicate_issues.yml +++ b/.github/workflows/comment_on_potential_duplicate_issues.yml @@ -14,6 +14,8 @@ concurrency: group: potential-duplicate-check-${{ github.event.issue.number || inputs.issue_number }} cancel-in-progress: true +permissions: {} + jobs: identify-duplicates: # For manual testing, allow running on any branch; for automatic runs, only on main repo @@ -39,6 +41,8 @@ jobs: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries + repositories: zed + permission-issues: write - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index be1d8e66d046ae..f309dd589b7d20 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -13,10 +13,15 @@ on: type: number default: 1000 +permissions: + contents: read + jobs: stale: if: github.repository_owner == 'zed-industries' runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + issues: write steps: - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10 with: diff --git a/.github/workflows/community_pr_board.yml b/.github/workflows/community_pr_board.yml index d155cf8275dbb5..16d4a4f8f733b6 100644 --- a/.github/workflows/community_pr_board.yml +++ b/.github/workflows/community_pr_board.yml @@ -12,6 +12,9 @@ name: Community PR Board on: + # zizmor: ignore[dangerous-triggers] + # Fork PRs must be supported, but this workflow only reads event metadata and + # checks out scripts from the trusted default branch; it never executes PR code. pull_request_target: types: [labeled, unlabeled, assigned, review_requested, edited] issue_comment: @@ -50,6 +53,10 @@ jobs: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries + repositories: zed + permission-issues: read + permission-organization-projects: write + permission-pull-requests: read - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/.github/workflows/community_pr_board_refresh.yml b/.github/workflows/community_pr_board_refresh.yml index 1d77638c82d381..f4b0c293cf657a 100644 --- a/.github/workflows/community_pr_board_refresh.yml +++ b/.github/workflows/community_pr_board_refresh.yml @@ -32,6 +32,10 @@ jobs: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries + repositories: zed + permission-issues: read + permission-organization-projects: write + permission-pull-requests: read - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/.github/workflows/community_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index f088b8147b64fd..b381370e3e9c60 100644 --- a/.github/workflows/community_update_all_top_ranking_issues.yml +++ b/.github/workflows/community_update_all_top_ranking_issues.yml @@ -5,10 +5,16 @@ on: - cron: "0 */12 * * *" workflow_dispatch: +permissions: + contents: read + jobs: update_top_ranking_issues: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv diff --git a/.github/workflows/community_update_weekly_top_ranking_issues.yml b/.github/workflows/community_update_weekly_top_ranking_issues.yml index 8b3585e3c1c50b..491b1656460225 100644 --- a/.github/workflows/community_update_weekly_top_ranking_issues.yml +++ b/.github/workflows/community_update_weekly_top_ranking_issues.yml @@ -5,10 +5,16 @@ on: - cron: "0 15 * * *" workflow_dispatch: +permissions: + contents: read + jobs: update_top_ranking_issues: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv diff --git a/.github/workflows/compliance_check.yml b/.github/workflows/compliance_check.yml index 2cf27fea8b0652..a701371d76ea40 100644 --- a/.github/workflows/compliance_check.yml +++ b/.github/workflows/compliance_check.yml @@ -7,6 +7,8 @@ on: schedule: - cron: 30 17 * * 2 workflow_dispatch: {} +permissions: + contents: read jobs: scheduled_compliance_check: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index 4866b3c33bc6ba..f6d04265975451 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -4,6 +4,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: check-author: if: ${{ github.repository_owner == 'zed-industries' }} diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 4cb0df5735a5c8..22ffdc6f34552a 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -11,6 +11,8 @@ on: - edited branches: - main +permissions: + contents: read jobs: danger: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -21,13 +23,15 @@ jobs: with: clean: false - name: steps::setup_pnpm - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 with: version: '9' - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false cache: pnpm cache-dependency-path: script/danger/pnpm-lock.yaml - name: danger::danger_job::install_deps diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index ef6cb399a761ce..d14394954ba211 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -7,6 +7,8 @@ on: push: tags: - collab-production +permissions: + contents: read jobs: style: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -64,7 +66,9 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 + with: + tool: nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 - name: deploy_collab::tests::run_collab_tests diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 6c492135ea6c3d..ace3c0729e7f2a 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -35,6 +35,8 @@ on: description: Git ref to checkout and deploy. Defaults to event SHA when omitted. type: string default: '' +permissions: + contents: read jobs: deploy_docs: if: github.repository_owner == 'zed-industries' diff --git a/.github/workflows/deploy_nightly_docs.yml b/.github/workflows/deploy_nightly_docs.yml index acd904841bb33c..12d10f10b26161 100644 --- a/.github/workflows/deploy_nightly_docs.yml +++ b/.github/workflows/deploy_nightly_docs.yml @@ -5,6 +5,7 @@ on: push: branches: - main +permissions: {} jobs: deploy_docs: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/docs_suggestions.yml b/.github/workflows/docs_suggestions.yml index c3d04d5780b290..1509e79ad92ac7 100644 --- a/.github/workflows/docs_suggestions.yml +++ b/.github/workflows/docs_suggestions.yml @@ -9,32 +9,25 @@ name: Documentation Suggestions # 5. Keep this workflow focused on suggestions only until that stable workflow is added. on: - # Run when PRs are merged to main + # Analyze merged PRs on main and cherry-picks to release branches. Each job + # further restricts the event actions and target branch that it handles. pull_request: - types: [closed] - branches: [main] - paths: - - 'crates/**/*.rs' - - '!crates/**/*_test.rs' - - '!crates/**/tests/**' - - # Run on cherry-picks to release branches - pull_request_target: - types: [opened, synchronize] + types: [closed, opened, synchronize] branches: - - 'v0.*' + - main + - "v0.*" paths: - - 'crates/**/*.rs' + - "crates/**/*.rs" # Manual trigger for testing workflow_dispatch: inputs: pr_number: - description: 'PR number to analyze' + description: "PR number to analyze" required: true type: string mode: - description: 'Output mode' + description: "Output mode" required: true type: choice options: @@ -42,6 +35,10 @@ on: - immediate default: batch +# Both jobs below declare their own `permissions:` blocks, so no job uses this +# top-level default. Least privilege therefore grants nothing here. +permissions: {} + env: DROID_MODEL: claude-sonnet-4-5-20250929 SUGGESTIONS_BRANCH: docs/suggestions-pending @@ -85,7 +82,6 @@ jobs: fi sleep $((i * 5)) done - echo "${HOME}/.local/bin" >> "$GITHUB_PATH" env: FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} @@ -121,6 +117,8 @@ jobs: FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} PR_NUMBER: ${{ steps.pr.outputs.number }} run: | + export PATH="${HOME}/.local/bin:${PATH}" + # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected) # Unset GH_TOKEN first to allow gh auth login to store credentials echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token) @@ -288,8 +286,10 @@ jobs: group: docs-suggestions-${{ github.event.pull_request.number || inputs.pr_number || 'manual' }} cancel-in-progress: true if: | - (github.event_name == 'pull_request_target' && + (github.event_name == 'pull_request' && + contains(fromJSON('["opened","synchronize"]'), github.event.action) && startsWith(github.event.pull_request.base.ref, 'v0.') && + github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["MEMBER","OWNER"]'), github.event.pull_request.author_association)) || (github.event_name == 'workflow_dispatch' && inputs.mode == 'immediate') @@ -299,7 +299,7 @@ jobs: uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 - ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || '' }} persist-credentials: false - name: Install Droid CLI @@ -318,7 +318,6 @@ jobs: fi sleep $((i * 5)) done - echo "${HOME}/.local/bin" >> "$GITHUB_PATH" env: FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} @@ -346,6 +345,8 @@ jobs: FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} PR_NUMBER: ${{ steps.pr.outputs.number }} run: | + export PATH="${HOME}/.local/bin:${PATH}" + # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected) # Unset GH_TOKEN first to allow gh auth login to store credentials echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token) diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml index e48ccdb082a362..df9fcc986937a0 100644 --- a/.github/workflows/extension_auto_bump.yml +++ b/.github/workflows/extension_auto_bump.yml @@ -10,6 +10,8 @@ on: - '!extensions/test-extension/**' - '!extensions/workflows/**' - '!extensions/*.md' +permissions: + contents: read jobs: detect_changed_extensions: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 6e68db7af0f274..0b611bfdafb5a1 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -28,6 +28,8 @@ on: app-secret: description: The app secret for the corresponding app ID required: true +permissions: + contents: read jobs: check_version_changed: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -79,6 +81,10 @@ jobs: with: app-id: ${{ secrets.app-id }} private-key: ${{ secrets.app-secret }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: @@ -168,6 +174,9 @@ jobs: with: app-id: ${{ secrets.app-id }} private-key: ${{ secrets.app-secret }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: @@ -220,6 +229,10 @@ jobs: private-key: ${{ secrets.app-secret }} owner: zed-industries repositories: extensions + permission-contents: write + permission-issues: write + permission-members: read + permission-pull-requests: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 23efa368d17653..885ef12b3d2015 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -15,6 +15,8 @@ on: description: working-directory type: string default: . +permissions: + contents: read jobs: orchestrate: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -97,7 +99,9 @@ jobs: env: PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }} - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 + with: + tool: nextest - name: extension_tests::run_nextest run: 'cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"' env: diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index c1e61822df6b23..a295c20ddbcb8e 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -14,9 +14,11 @@ on: description: Description for the changes to be expected with this rollout type: string default: '' +permissions: + contents: read jobs: fetch_extension_repos: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && (github.ref == 'refs/tags/extension-workflows' || github.ref == 'refs/heads/main') runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: checkout_zed_repo @@ -70,7 +72,7 @@ jobs: .filter(repo => !repo.archived) .map(repo => repo.name); - const filterInput = `${{ inputs.filter-repos }}`.trim(); + const filterInput = process.env.FILTER_REPOS.trim(); if (filterInput.length > 0) { const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0); filteredRepos = filteredRepos.filter(name => allowedNames.includes(name)); @@ -80,6 +82,8 @@ jobs: console.log(`Found ${filteredRepos.length} extension repos`); return filteredRepos; result-encoding: json + env: + FILTER_REPOS: ${{ inputs.filter-repos }} - name: steps::cache_rust_dependencies_namespace uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: @@ -213,6 +217,8 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} permission-contents: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd @@ -220,21 +226,18 @@ jobs: clean: false fetch-depth: 0 token: ${{ steps.generate-token.outputs.token }} - - name: extension_workflow_rollout::create_rollout_tag::update_rollout_tag - run: | - if git rev-parse "extension-workflows" >/dev/null 2>&1; then - git tag -d "extension-workflows" - git push origin ":refs/tags/extension-workflows" || true - fi - - echo "Creating new tag 'extension-workflows' at $(git rev-parse --short HEAD)" - git tag "extension-workflows" - git push origin "extension-workflows" - env: - GIT_AUTHOR_NAME: zed-zippy[bot] - GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: zed-zippy[bot] - GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + - name: steps::update_tag + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + with: + script: | + github.rest.git.updateRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'tags/extension-workflows', + sha: context.sha, + force: true + }) + github-token: ${{ steps.generate-token.outputs.token }} timeout-minutes: 1 defaults: run: diff --git a/.github/workflows/good_first_issue_notifier.yml b/.github/workflows/good_first_issue_notifier.yml index fc1b49424dce24..9e86118dd0ab24 100644 --- a/.github/workflows/good_first_issue_notifier.yml +++ b/.github/workflows/good_first_issue_notifier.yml @@ -4,6 +4,9 @@ on: issues: types: [labeled] +permissions: + contents: read + jobs: handle-good-first-issue: if: github.event.label.name == '.contrib/good first issue' && github.repository_owner == 'zed-industries' diff --git a/.github/workflows/guild_assignment_status.yml b/.github/workflows/guild_assignment_status.yml new file mode 100644 index 00000000000000..c91c05bb544291 --- /dev/null +++ b/.github/workflows/guild_assignment_status.yml @@ -0,0 +1,64 @@ +# Guild board (https://github.com/orgs/zed-industries/projects/74) reactions to issue events: +# assigned guild member -> Status "In Progress" (or Slack if off-board) +# unassigned guild member -> move back to a To-Do column by Type + Slack +# commented guild assignee comments after a check-in -> Slack (each comment) + +name: Guild Assignment Status + +on: + issues: + types: [assigned, unassigned] + issue_comment: + types: [created] + +permissions: + contents: read + +concurrency: + group: guild-assignment-status-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: false + +jobs: + handle-event: + if: >- + github.repository == 'zed-industries/zed' && + (github.event_name != 'issue_comment' || github.event.issue.pull_request == null) + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + repositories: zed + permission-issues: write + permission-members: read + permission-organization-projects: write + permission-pull-requests: read + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Handle issue event + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: event + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml new file mode 100644 index 00000000000000..52b4107bb255d4 --- /dev/null +++ b/.github/workflows/guild_new_pr_notify.yml @@ -0,0 +1,64 @@ +# When a guild member opens a PR, react on the Guild board (#74): +# - if the PR will close an open board issue, set that issue to In Progress +# (covers starting work without self-assigning), and +# - if they already have another open PR opened since the cohort started, post +# a gentle heads-up to #zed-guild-internal. + +name: Guild New PR Notification + +on: + # zizmor: ignore[dangerous-triggers] + # Fork PRs must be supported, but this workflow only reads event metadata and + # checks out scripts from the trusted default branch; it never executes PR code. + pull_request_target: + types: [opened] + +permissions: + contents: read + +concurrency: + group: guild-new-pr-notify-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + react: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + repositories: zed + permission-issues: write + permission-members: read + permission-organization-projects: write + permission-pull-requests: read + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: React to the new PR + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: event + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_stale_assignments.yml b/.github/workflows/guild_stale_assignments.yml new file mode 100644 index 00000000000000..81a7f0c0ff3931 --- /dev/null +++ b/.github/workflows/guild_stale_assignments.yml @@ -0,0 +1,62 @@ +# Scheduled sweep of the Guild board (https://github.com/orgs/zed-industries/projects/74). +# For an "In Progress" issue assigned to a guild member with no linked PR: post a +# check-in comment once the assignee goes quiet, re-nudging after any renewed +# silence, and clear the assignment (moving the issue back to a To-Do column by +# Type) if a check-in goes unanswered. The "guild hold" label pauses the sweep. + +name: Guild Stale Assignments + +on: + schedule: + - cron: "0 8 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guild-stale-assignments + cancel-in-progress: true + +jobs: + sweep: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + repositories: zed + permission-issues: write + permission-members: read + permission-organization-projects: write + permission-pull-requests: read + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Sweep stale assignments + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: stale + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_weekly_shipped.yml b/.github/workflows/guild_weekly_shipped.yml new file mode 100644 index 00000000000000..1f3bf71feaf67c --- /dev/null +++ b/.github/workflows/guild_weekly_shipped.yml @@ -0,0 +1,60 @@ +# Scheduled Slack digest of Guild board +# (https://github.com/orgs/zed-industries/projects/74) issues recently closed by +# a merged PR authored by a guild member. + +name: Guild Weekly Shipped + +on: + schedule: + - cron: "0 7 * * 3" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guild-weekly-shipped + cancel-in-progress: true + +jobs: + digest: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + repositories: zed + permission-issues: read + permission-members: read + permission-organization-projects: read + permission-pull-requests: read + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Build and send digest + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: weekly + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/nix_build.yml b/.github/workflows/nix_build.yml index f658634c06c166..3ddd6bdd5c6e16 100644 --- a/.github/workflows/nix_build.yml +++ b/.github/workflows/nix_build.yml @@ -9,6 +9,8 @@ on: types: - labeled - synchronize +permissions: + contents: read jobs: build_nix_linux_x86_64: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling')))) diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml index fbba166a2446d9..27e4dc86a98bd8 100644 --- a/.github/workflows/pr_issue_labeler.yml +++ b/.github/workflows/pr_issue_labeler.yml @@ -12,6 +12,9 @@ name: PR Issue Labeler on: issues: types: [opened] + # zizmor: ignore[dangerous-triggers] + # Fork PRs must be labeled, but this workflow only passes GitHub-provided event + # metadata to a pinned action; it never checks out or executes PR code. pull_request_target: types: [opened] @@ -30,6 +33,10 @@ jobs: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries + repositories: zed + permission-issues: write + permission-members: read + permission-pull-requests: write - id: apply-authorship-label uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 @@ -41,47 +48,9 @@ jobs: const STAFF_TEAM_SLUG = 'staff'; const FIRST_CONTRIBUTION_LABEL = 'first contribution'; const GUILD_LABEL = 'guild'; - const GUILD_MEMBERS = [ - '11happy', - 'AidanV', - 'alanpjohn', - 'AmaanBilwar', - 'arjunkomath', - 'austincummings', - 'ayushk-1801', - 'criticic', - 'dongdong867', - 'emamulandalib', - 'eureka928', - 'feitreim', - 'iam-liam', - 'iksuddle', - 'ishaksebsib', - 'lingyaochu', - 'loadingalias', - 'marcocondrache', - 'mchisolm0', - 'MostlyKIGuess', - 'nairadithya', - 'nihalxkumar', - 'notJoon', - 'OmChillure', - 'Palanikannan1437', - 'polyesterswing', - 'prayanshchh', - 'razeghi71', - 'sarmadgulzar', - 'seanstrom', - 'Shivansh-25', - 'SkandaBhat', - 'th0jensen', - 'tommyming', - 'transitoryangel', - 'TwistingTwists', - 'virajbhartiya', - 'YEDASAVG', - 'Ziqi-Yang', - ]; + // Guild cohort members are outside collaborators holding this custom + // repository role, not members of an org team. + const GUILD_ROLE_NAME = 'Guild Assign issues/PRs'; const COMMUNITY_CHAMPION_LABEL = 'community champion'; const COMMUNITY_CHAMPIONS = [ '0x2CA', @@ -161,11 +130,11 @@ jobs: return members.some((member) => member.toLowerCase() === authorLower); }; - const isStaffMember = async (author) => { + const isTeamMember = async (teamSlug, author) => { try { const response = await github.rest.teams.getMembershipForUserInOrg({ org: 'zed-industries', - team_slug: STAFF_TEAM_SLUG, + team_slug: teamSlug, username: author }); return response.data.state === 'active'; @@ -177,6 +146,27 @@ jobs: } }; + const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author); + + const isGuildMember = async (author) => { + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: 'zed-industries', + repo: 'zed', + username: author + }); + // role_name is the effective (highest) role; for cohort outside + // collaborators that is the custom role. Built-in roles come back + // lowercased and won't match. + return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase(); + } catch (error) { + if (error.status !== 404) { + throw error; + } + return false; + } + }; + const getIssueLabels = () => { if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { return [COMMUNITY_CHAMPION_LABEL]; @@ -202,7 +192,7 @@ jobs: labelsToAdd.push(COMMUNITY_CHAMPION_LABEL); } - if (listIncludesAuthor(GUILD_MEMBERS, author)) { + if (await isGuildMember(author)) { labelsToAdd.push(GUILD_LABEL); } diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index 5bef991887cdba..40dfbac974ffcd 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -11,6 +11,8 @@ on: description: Describe why the extension CLI is being bumped and/or what changes are included. required: true type: string +permissions: + contents: read jobs: publish_job: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' @@ -40,6 +42,8 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} permission-contents: write - name: steps::update_tag uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b @@ -65,6 +69,11 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: @@ -119,6 +128,9 @@ jobs: private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} owner: zed-industries repositories: extensions + permission-contents: write + permission-pull-requests: write + permission-workflows: write - id: short-sha name: publish_extension_cli::get_short_sha run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea968386a117b8..0e59362fddc7ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: push: tags: - v* +permissions: + contents: read jobs: run_tests_mac: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -27,11 +29,15 @@ jobs: cache: rust path: ~/.rustup - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 + with: + tool: nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 - name: steps::setup_sccache @@ -75,11 +81,15 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 + with: + tool: nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 - name: steps::setup_sccache @@ -120,9 +130,11 @@ jobs: Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" shell: pwsh - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than.ps1 350 200 shell: pwsh @@ -249,13 +261,6 @@ jobs: clean: false - name: run_tests::check_scripts::run_shellcheck run: ./script/shellcheck-scripts error - - id: get_actionlint - name: run_tests::check_scripts::download_actionlint - run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) - - name: run_tests::check_scripts::run_actionlint - run: '"$ACTIONLINT_BIN" -color' - env: - ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: @@ -269,6 +274,19 @@ jobs: echo "Please run 'cargo xtask workflows' locally and commit the changes" exit 1 fi + - id: get_actionlint + name: run_tests::check_scripts::download_actionlint + run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + - name: run_tests::check_scripts::run_actionlint + run: '"$ACTIONLINT_BIN" -color' + env: + ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} + - name: run_tests::check_scripts::run_zizmor + uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 + with: + advanced-security: false + min-severity: high + version: latest timeout-minutes: 60 create_draft_release: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -280,6 +298,8 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} permission-contents: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd @@ -536,9 +556,11 @@ jobs: cache: rust path: ~/.rustup - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::setup_sentry uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b with: @@ -586,9 +608,11 @@ jobs: cache: rust path: ~/.rustup - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::setup_sentry uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b with: @@ -718,6 +742,8 @@ jobs: - bundle_windows_aarch64 - bundle_windows_x86_64 runs-on: namespace-profile-4x8-ubuntu-2204 + permissions: + contents: write steps: - name: release::download_workflow_artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -751,6 +777,8 @@ jobs: needs: - upload_release_assets runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + contents: write steps: - name: release::validate_release_assets run: | @@ -834,6 +862,9 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 17760bae3c69bf..75f5708fe1f0b5 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -8,6 +8,8 @@ on: schedule: - cron: 0 */4 * * * workflow_dispatch: {} +permissions: + contents: read jobs: check_nightly_tag: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -58,11 +60,15 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 + with: + tool: nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 - name: steps::setup_sccache @@ -283,9 +289,11 @@ jobs: echo "Publishing version: ${version} on release channel nightly" echo "nightly" > crates/zed/RELEASE_CHANNEL - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::setup_sentry uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b with: @@ -337,9 +345,11 @@ jobs: echo "Publishing version: ${version} on release channel nightly" echo "nightly" > crates/zed/RELEASE_CHANNEL - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::setup_sentry uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b with: @@ -569,6 +579,8 @@ jobs: with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} permission-contents: write - name: steps::checkout_repo uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index 37d1936232f31b..1912927baf6ab3 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -9,6 +9,8 @@ on: types: - labeled - synchronize +permissions: + contents: read jobs: bundle_linux_aarch64: if: |- @@ -99,6 +101,9 @@ jobs: if-no-files-found: error timeout-minutes: 60 build_static_bwrap_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')) runs-on: namespace-profile-8x32-ubuntu-2004-arm-m4 steps: - name: steps::cache_nix_dependencies_namespace @@ -130,6 +135,9 @@ jobs: if-no-files-found: error timeout-minutes: 60 build_static_bwrap_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')) runs-on: namespace-profile-32x64-ubuntu-2004 steps: - name: steps::cache_nix_dependencies_namespace @@ -185,9 +193,11 @@ jobs: cache: rust path: ~/.rustup - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::setup_sentry uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b with: @@ -234,9 +244,11 @@ jobs: cache: rust path: ~/.rustup - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::setup_sentry uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b with: diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 9c0812f4a51d24..ebe8b577146613 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -14,6 +14,8 @@ on: branches: - main - v[0-9]+.[0-9]+.x +permissions: + contents: read jobs: orchestrate: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -73,12 +75,17 @@ jobs: # Map directory names to package names FILE_CHANGED_PKGS="" for dir in $CHANGED_DIRS; do - pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1) + pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1 || true) + # Only add directories that map to a real root-workspace package. + # Some directories (e.g. tooling/lints) belong to a separate workspace + # and are not root members, so they have no mapping here. Previously we + # fell back to the raw directory name, which fabricated a bogus package + # (e.g. "lints") and produced a nextest filter like rdeps(lints) that + # hard-errors ("operator didn't match any packages"). Skipping such + # directories leaves the package set empty, which falls through to the + # "run all tests" path below. if [ -n "$pkg" ]; then FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$pkg") - else - # Fall back to directory name if no mapping found - FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$dir") fi done FILE_CHANGED_PKGS=$(echo "$FILE_CHANGED_PKGS" | grep -v '^$' | sort -u || true) @@ -150,7 +157,7 @@ jobs: cache: rust path: ~/.rustup - name: steps::setup_pnpm - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 with: version: '9' - name: steps::prettier @@ -333,9 +340,11 @@ jobs: Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" shell: pwsh - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than.ps1 350 200 shell: pwsh @@ -390,11 +399,15 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 + with: + tool: nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 - name: steps::setup_sccache @@ -441,11 +454,15 @@ jobs: cache: rust path: ~/.rustup - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: '20' + node-version: '24' + check-latest: true + package-manager-cache: false - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 + with: + tool: nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 - name: steps::setup_sccache @@ -650,7 +667,7 @@ jobs: R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} SCCACHE_BUCKET: sccache-zed - name: run_tests::check_wasm::cargo_check_wasm - run: cargo -Zbuild-std=std,panic_abort check --target wasm32-unknown-unknown -p gpui_platform + run: cargo -Zbuild-std=std,panic_abort check --target wasm32-unknown-unknown -p gpui_platform -p cloud_api_client env: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: -C target-feature=+atomics,+bulk-memory,+mutable-globals RUSTC_BOOTSTRAP: '1' @@ -684,7 +701,7 @@ jobs: cache: rust path: ~/.rustup - name: run_tests::check_dependencies::install_cargo_machete - uses: taiki-e/install-action@02cc5f8ca9f2301050c0c099055816a41ee05507 + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 with: tool: cargo-machete@0.7.0 - name: run_tests::check_dependencies::run_cargo_machete @@ -794,13 +811,6 @@ jobs: clean: false - name: run_tests::check_scripts::run_shellcheck run: ./script/shellcheck-scripts error - - id: get_actionlint - name: run_tests::check_scripts::download_actionlint - run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) - - name: run_tests::check_scripts::run_actionlint - run: '"$ACTIONLINT_BIN" -color' - env: - ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: @@ -814,6 +824,19 @@ jobs: echo "Please run 'cargo xtask workflows' locally and commit the changes" exit 1 fi + - id: get_actionlint + name: run_tests::check_scripts::download_actionlint + run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + - name: run_tests::check_scripts::run_actionlint + run: '"$ACTIONLINT_BIN" -color' + env: + ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} + - name: run_tests::check_scripts::run_zizmor + uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 + with: + advanced-security: false + min-severity: high + version: latest timeout-minutes: 60 check_postgres_and_protobuf_migrations: needs: diff --git a/.github/workflows/slack_notify_community_automation_failure.yml b/.github/workflows/slack_notify_community_automation_failure.yml index 688cfdded83a76..71e490cd02ae18 100644 --- a/.github/workflows/slack_notify_community_automation_failure.yml +++ b/.github/workflows/slack_notify_community_automation_failure.yml @@ -5,6 +5,9 @@ name: Notify Slack on community automation failure on: + # zizmor: ignore[dangerous-triggers] + # The job validates that the completed run came from this repository, was + # started by an expected event, and failed before exposing the Slack secret. workflow_run: workflows: - "Comment on potential duplicate bug/crash reports" @@ -12,12 +15,21 @@ on: - "Community PR Board" - "PR Board Meta Fields Refresh" - "PR Issue Labeler" + - "Guild Assignment Status" + - "Guild Stale Assignments" + - "Guild Weekly Shipped" + - "Guild New PR Notification" types: [completed] +permissions: + contents: read + jobs: notify-slack: if: >- - github.repository_owner == 'zed-industries' + github.repository == 'zed-industries/zed' + && github.event.workflow_run.head_repository.full_name == github.repository + && contains(fromJSON('["issues","issue_comment","pull_request","pull_request_target","schedule","workflow_dispatch"]'), github.event.workflow_run.event) && github.event.workflow_run.conclusion == 'failure' runs-on: namespace-profile-2x4-ubuntu-2404 diff --git a/.github/workflows/slack_notify_first_responders.yml b/.github/workflows/slack_notify_first_responders.yml index 3dd9ffeabae1b7..a15c7b9c0f04ee 100644 --- a/.github/workflows/slack_notify_first_responders.yml +++ b/.github/workflows/slack_notify_first_responders.yml @@ -4,37 +4,53 @@ on: issues: types: [labeled] +permissions: + contents: read + env: - PRIORITY_LABELS: '["priority:P0", "priority:P1"]' + SEVERITY_LABELS: '["severity:S0", "severity:S1"]' REPRODUCIBLE_LABEL: 'state:reproducible' - FREQUENCY_LABELS: '["frequency:always", "frequency:common"]' + REACH_LABELS: '["reach:all users", "reach:many users"]' + MARKER_REACTION: 'eyes' jobs: notify-slack: if: github.repository_owner == 'zed-industries' && github.event.issue.state == 'open' runs-on: namespace-profile-2x4-ubuntu-2404 - # Serialize per-issue so concurrent `labeled` events can't both observe - # the trifecta and double-notify. + permissions: + contents: read + # For the issue reaction used to dedupe notifications. + issues: write + # Serialize per issue so the reaction claim below can't race. concurrency: group: slack-notify-first-responders-${{ github.event.issue.number }} cancel-in-progress: false steps: - - name: Check if label combination requires first responder notification + - name: Check label combination and claim the notification id: check-label env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} LABEL_NAME: ${{ github.event.label.name }} - ISSUE_LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }} + ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -euo pipefail - # Gate on the just-added label so unrelated labeling on an - # already-qualifying issue doesn't re-fire the notification. + api() { + curl -sS \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + # Ignore labels outside the trifecta so unrelated later edits don't re-fire. TRIGGER_LABELS=$(jq -cn \ - --argjson priority "$PRIORITY_LABELS" \ + --argjson severity "$SEVERITY_LABELS" \ --arg repro "$REPRODUCIBLE_LABEL" \ - --argjson freq "$FREQUENCY_LABELS" \ - '$priority + [$repro] + $freq') + --argjson reach "$REACH_LABELS" \ + '$severity + [$repro] + $reach') if ! echo "$TRIGGER_LABELS" | jq -e --arg l "$LABEL_NAME" 'index($l) != null' > /dev/null; then echo "Added label '$LABEL_NAME' is not in the trigger set, skipping" @@ -42,19 +58,52 @@ jobs: exit 0 fi - MATCHED_PRIORITY=$(echo "$ISSUE_LABELS_JSON" | jq -r --argjson priority "$PRIORITY_LABELS" 'map(select(. as $x | $priority | index($x) != null)) | first // ""') + # The webhook's issue.labels snapshot is racy under bulk apply; read live. + ISSUE_LABELS_JSON=$(api -f "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/labels?per_page=100" | jq '[.[].name]') + + MATCHED_SEVERITY=$(echo "$ISSUE_LABELS_JSON" | jq -r --argjson severity "$SEVERITY_LABELS" 'map(select(. as $x | $severity | index($x) != null)) | first // ""') HAS_REPRO=$(echo "$ISSUE_LABELS_JSON" | jq --arg l "$REPRODUCIBLE_LABEL" 'index($l) != null') - HAS_FREQ=$(echo "$ISSUE_LABELS_JSON" | jq --argjson freq "$FREQUENCY_LABELS" 'any(.[]; . as $x | $freq | index($x) != null)') - - if [ -n "$MATCHED_PRIORITY" ] && [ "$HAS_REPRO" = "true" ] && [ "$HAS_FREQ" = "true" ]; then - echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying" - echo "notify_reason=confirmed $MATCHED_PRIORITY" >> "$GITHUB_OUTPUT" - echo "should_notify=true" >> "$GITHUB_OUTPUT" - else - echo "Combination not yet satisfied (priority=$MATCHED_PRIORITY, reproducible=$HAS_REPRO, frequency=$HAS_FREQ), skipping" + HAS_REACH=$(echo "$ISSUE_LABELS_JSON" | jq --argjson reach "$REACH_LABELS" 'any(.[]; . as $x | $reach | index($x) != null)') + + if [ -z "$MATCHED_SEVERITY" ] || [ "$HAS_REPRO" != "true" ] || [ "$HAS_REACH" != "true" ]; then + echo "Combination not yet satisfied (severity=$MATCHED_SEVERITY, reproducible=$HAS_REPRO, reach=$HAS_REACH), skipping" + echo "should_notify=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # A bulk label apply emits one `labeled` event per label, so several + # runs reach this point. Creating the reaction is our atomic claim: + # one run gets 201 and notifies, the rest get 200. It's scoped to our + # own reaction, so a human's reaction can't suppress it. + REACTION_PAYLOAD=$(jq -cn --arg content "$MARKER_REACTION" '{content: $content}') + REACTION_RESPONSE=$(api -w "\n%{http_code}" -X POST \ + "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions" \ + -d "$REACTION_PAYLOAD") + REACTION_BODY=$(echo "$REACTION_RESPONSE" | sed '$d') + REACTION_STATUS=$(echo "$REACTION_RESPONSE" | tail -n1) + + if [ "$REACTION_STATUS" = "200" ]; then + echo "First responders already notified for this issue (reaction present), skipping" echo "should_notify=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$REACTION_STATUS" != "201" ]; then + echo "::error::Unexpected status $REACTION_STATUS creating reaction: $REACTION_BODY" + exit 1 fi + REACTION_ID=$(echo "$REACTION_BODY" | jq -r '.id') + LABELS=$(echo "$ISSUE_LABELS_JSON" | jq -r 'join(", ")') + + echo "Confirmed high-reach $MATCHED_SEVERITY, notifying" + { + echo "notify_reason=confirmed $MATCHED_SEVERITY" + echo "issue_labels=$LABELS" + echo "reaction_id=$REACTION_ID" + echo "should_notify=true" + } >> "$GITHUB_OUTPUT" + - name: Build Slack message payload if: steps.check-label.outputs.should_notify == 'true' env: @@ -62,10 +111,8 @@ jobs: ISSUE_URL: ${{ github.event.issue.html_url }} LABELED_BY: ${{ github.event.sender.login }} NOTIFY_REASON: ${{ steps.check-label.outputs.notify_reason }} - LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }} + LABELS: ${{ steps.check-label.outputs.issue_labels }} run: | - LABELS=$(echo "$LABELS_JSON" | jq -r 'join(", ")') - jq -n \ --arg notify_reason "$NOTIFY_REASON" \ --arg issue_title "$ISSUE_TITLE" \ @@ -108,9 +155,27 @@ jobs: if: steps.check-label.outputs.should_notify == 'true' env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_FIRST_RESPONDERS }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REACTION_ID: ${{ steps.check-label.outputs.reaction_id }} run: | + set -uo pipefail + + release_claim() { + if [ -n "${REACTION_ID:-}" ]; then + echo "Releasing reaction claim so the notification can be retried" + curl -sS -X DELETE \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions/$REACTION_ID" || true + fi + } + if [ -z "$SLACK_WEBHOOK_URL" ]; then echo "::error::SLACK_WEBHOOK_FIRST_RESPONDERS secret is not set" + release_claim exit 1 fi @@ -126,6 +191,7 @@ jobs: if [ "$HTTP_STATUS" -ne 200 ]; then echo "::error::Slack notification failed with status $HTTP_STATUS: $HTTP_BODY" + release_claim exit 1 fi diff --git a/.github/workflows/slack_notify_label_created.yml b/.github/workflows/slack_notify_label_created.yml index e791cbc7ea4c37..f30650dc2204a8 100644 --- a/.github/workflows/slack_notify_label_created.yml +++ b/.github/workflows/slack_notify_label_created.yml @@ -4,6 +4,9 @@ on: label: types: [created] +permissions: + contents: read + jobs: notify-slack: if: >- diff --git a/.github/workflows/track_duplicate_bot_effectiveness.yml b/.github/workflows/track_duplicate_bot_effectiveness.yml index bdcf8a5f4077a2..b72f08d47e4269 100644 --- a/.github/workflows/track_duplicate_bot_effectiveness.yml +++ b/.github/workflows/track_duplicate_bot_effectiveness.yml @@ -35,6 +35,9 @@ jobs: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries + repositories: zed + permission-issues: read + permission-organization-projects: write - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -74,6 +77,9 @@ jobs: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} owner: zed-industries + repositories: zed + permission-issues: read + permission-organization-projects: write - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 diff --git a/.github/workflows/triage_project_sync.yml b/.github/workflows/triage_project_sync.yml index b652e0fb0a79ac..9b8500db982c1c 100644 --- a/.github/workflows/triage_project_sync.yml +++ b/.github/workflows/triage_project_sync.yml @@ -91,6 +91,7 @@ jobs: # mutations. Without `owner:`, the default token is repo-scoped and # cannot write to org projects. owner: zed-industries + repositories: zed # Scope the token down to the minimum needed for this workflow. # Even though the App may have broader permissions for other # automations (e.g., Issues:Write for the dupe-bot), this token diff --git a/.github/workflows/update_duplicate_magnets.yml b/.github/workflows/update_duplicate_magnets.yml index 4c9bcd99fdc6b9..73dc159a3f1d74 100644 --- a/.github/workflows/update_duplicate_magnets.yml +++ b/.github/workflows/update_duplicate_magnets.yml @@ -5,10 +5,16 @@ on: - cron: "0 6 * * 1,4" # Mondays and Thursdays at 6 AM UTC workflow_dispatch: +permissions: + contents: read + jobs: update-duplicate-magnets: runs-on: ubuntu-latest if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000000000..550e33a7cc5d3f --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1 @@ +rules: {} diff --git a/.gitignore b/.gitignore index 67cdd58042b8ff..37b1d11e068258 100644 --- a/.gitignore +++ b/.gitignore @@ -55,12 +55,11 @@ crates/docs_preprocessor/actions.json # Local documentation audit files /december-2025-releases.md /docs/december-2025-documentation-gaps.md -<<<<<<< HEAD /target-ubuntu22 /target-ubuntu25 crates/external_websocket_sync/e2e-test/helix-ws-test-server/zed-ws-test-server -======= # NixOS integration test state .nixos-test-history ->>>>>>> upstream/main + +.local* diff --git a/.rules b/.rules index 3f8f8e1c440a35..4258973d4d1ae4 100644 --- a/.rules +++ b/.rules @@ -117,7 +117,7 @@ Often event handlers will want to update the entity that's in the current `Conte Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`. -Actions with no data defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user. +Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user. Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0a3287387b47d..62d0d1337f5d8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,46 +13,76 @@ Zed is a large project with a number of priorities. We spend most of our time working on what we believe the product needs, but we also love working with the community to improve the product in ways we haven't thought of (or had time to get to yet!) -In particular we love PRs that are: +In particular **we love PRs that are**: -- 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. -- Part of a Community Program like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541). +- 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. +- Part of a **Community Program** like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541) or [The Guild](https://zed.dev/community/guild). +- Features we **explicitly called out as open to community contributions** If you're looking for concrete ideas: -- [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). +- [Docs issues](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ADocs) +- Issues suitable for [first-time contributors](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22.contrib%2Fgood%20first%20issue%22), [returning contributors](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22.contrib%2Fgood%20second%20issue%22), and [expert contributors](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22.contrib%2Fgood%20expert%20issue%22) +- [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) +- [The board with the features](https://github.com/orgs/zed-industries/projects/78/views/4) we explicitly invited the community's contributions for. -If you're thinking about proposing or building a larger feature, read the [Zed Feature Process](./docs/src/development/feature-process.md) for how we think about feature design — what context to provide, what integration points to consider, and how to put together a strong proposal. +**Thinking about proposing or building a larger feature? Don't start with a PR**, start with reading the [Zed Feature Process](./docs/src/development/feature-process.md) for how we think about feature design — what context to provide, what integration points to consider, and how to put together a strong proposal. The right place for the proposals is [GitHub discussions](https://github.com/zed-industries/zed/discussions) (not GitHub issues). ## Sending changes The Zed culture values working code and synchronous conversations over long discussion threads. -The best way to get us to take a look at a proposed change is to send a pull -request. We will get back to you (though this sometimes takes longer than we'd -like, sorry). +The best way to get us to take a look at a proposed change (excluding new features) is to send a pull request. We will get back to you (though this sometimes takes longer than we'd like, sorry). **Pinging the maintainers by their username or writing them emails spends their time but does not bump the priority of a particular PR.** + +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 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. 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: +submitted. **If you'd like your PR to have the best chance of being merged**: - 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 + 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. + confirmation that we want it, start with a [GitHub discussion](https://github.com/zed-industries/zed/discussions) rather than a PR. - This especially applies to any changes proposed to the Zed Extension API. - Include a clear description of **what you're solving**, and why it's important. - Include **tests**. For UI changes, consider updating visual regression tests (see [Building Zed for macOS](./docs/src/development/macos.md#visual-regression-tests)). -- If it changes the UI, attach **screenshots** or screen recordings. +- If the change is visible in 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. +**A note on open pull requests:** currently we cap them at **three per author**. +We're lucky to get a lot of contributions, and the pattern we've seen is that landing +your first PR dramatically improves the odds for every PR after it. A stack of +simultaneous PRs, by contrast, tends to just sit and rot against a moving `main` +branch. It's better to start with one, see it through, then bring on the next. +It also keeps things sane when the contributions are coming in faster than we can +review them, including the occasional automated burst. + +## Things we will (probably) not merge + +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). +- Changes to the Zed Extension API submitted without prior discussion involving Zed staff. +- 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. +- Anything that seems AI-generated without understanding the output. + ### AI Policy We welcome the use of LLMs for coding, but we hold a high bar for all contributions, and **we expect a human in the loop who genuinely understands the work an LLM produces** on their behalf. For that reason, we **don't accept contributions from autonomous agents**. Pull requests that appear to violate this may be closed, sometimes without notice. @@ -69,13 +99,6 @@ This policy was adapted from [ripgrep's AI policy](https://github.com/BurntSushi - If the fix/feature is obviously great, and the code is nearly great. Send PR comments, or offer to pair to get things perfect. - If the fix/feature is not obviously great, or the code needs rewriting from scratch. Close the PR with a thank you and some explanation. -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 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. - ### UI/UX checklist When your changes affect UI, consult this checklist: @@ -138,19 +161,6 @@ When your changes affect UI, consult this checklist: - Are power features discoverable but not intrusive? - Is there a path from beginner → expert usage (progressive disclosure)? -## Things we will (probably) not merge - -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). -- Changes to the Zed Extension API submitted without prior discussion involving Zed staff. -- 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. -- Anything that seems AI-generated without understanding the output. - ## Bird's-eye view of Zed We suggest you keep the [Zed glossary](docs/src/development/glossary.md) at your side when starting out. It lists and explains some of the structures and terms you will see throughout the codebase. diff --git a/Cargo.lock b/Cargo.lock index b5977ad3f9c7b9..0ff5621bc8509d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,7 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ + "enumn", "uuid", ] @@ -116,6 +117,7 @@ dependencies = [ "language_model", "log", "markdown", + "mime", "multi_buffer", "parking_lot", "portable-pty", @@ -127,6 +129,7 @@ dependencies = [ "settings", "task", "telemetry", + "tempfile", "terminal", "text", "ui", @@ -286,6 +289,7 @@ dependencies = [ "quick-xml 0.38.3", "rand 0.9.4", "regex", + "release_channel", "reqwest_client", "rust-embed", "sandbox", @@ -319,41 +323,43 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.14.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efba6592048ef8a9ac97de8d79b2d9933d8ac4d94f7a2de102348fed0c61103" +checksum = "6d87bc7769eba641753ba5dc52f73ec3765d51022c6753bf040967125ddc86a8" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", + "async-io", "async-process", "blocking", "futures 0.3.32", "futures-concurrency", - "jsonrpcmsg", "rustc-hash 2.1.1", + "rustix 1.1.2", "schemars 1.0.4", "serde", "serde_json", "shell-words", "tracing", "uuid", + "windows-sys 0.61.2", ] [[package]] name = "agent-client-protocol-derive" -version = "0.14.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d176a10d4cb06e0262a738c3c5bf21ff0968db13a666e31cbca94a3d3d72e7c" +checksum = "3abd4080f51e4f24f5042beb7fb7a66ede29a2dc1c2582c329532e1c27264ddc" dependencies = [ "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "agent-client-protocol-schema" -version = "0.13.6" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c290bfa00c6b52339db66f8e9cf711d5f08530800529f7d619ff24d6cba253d0" +checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" dependencies = [ "anyhow", "derive_more", @@ -492,6 +498,7 @@ dependencies = [ "heapless", "html_to_markdown", "http_client", + "idna", "image", "indoc", "itertools 0.14.0", @@ -525,6 +532,7 @@ dependencies = [ "remote_server", "reqwest_client", "rope", + "sandbox", "schemars 1.0.4", "search", "semver", @@ -532,6 +540,7 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", + "shlex", "streaming_diff", "task", "telemetry", @@ -546,7 +555,8 @@ dependencies = [ "tokio", "tree-sitter-md", "ui", - "ui_input", + "unicode-script", + "unicode-segmentation", "unindent", "url", "util", @@ -614,7 +624,7 @@ version = "0.26.1-dev" source = "git+https://github.com/zed-industries/alacritty?rev=4c129667ce56611becdc82de6e28218c80e2e88f#4c129667ce56611becdc82de6e28218c80e2e88f" dependencies = [ "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "home", "libc", "log", @@ -669,7 +679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" dependencies = [ "alsa-sys", - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -949,6 +959,7 @@ dependencies = [ "smol", "tempfile", "util", + "which 6.0.3", "windows 0.61.3", "zeroize", ] @@ -1230,15 +1241,15 @@ dependencies = [ [[package]] name = "async-tar" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1937db2d56578aa3919b9bdb0e5100693fd7d1c0f145c53eb81fbb03e217550" +checksum = "f6affe71e5b6180fb5eaf9e8127a243694baf6ae1120c199227167302f56c14b" dependencies = [ "async-std", "filetime", + "futures-core", "libc", - "pin-project", - "redox_syscall 0.2.16", + "redox_syscall 0.7.5", "xattr", ] @@ -1553,9 +1564,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -1564,14 +1575,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -2256,10 +2268,10 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -2276,10 +2288,10 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", "regex", @@ -2347,9 +2359,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -2749,6 +2761,7 @@ dependencies = [ "postage", "project", "serde", + "serde_json", "settings", "telemetry", "util", @@ -2760,7 +2773,7 @@ name = "calloop" version = "0.14.3" source = "git+https://github.com/zed-industries/calloop#eb6b4fd17b9af5ecc226546bdd04185391b3e265" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "polling", "rustix 1.1.2", "slab", @@ -2945,7 +2958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" dependencies = [ "heck 0.4.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "proc-macro2", "quote", @@ -3171,6 +3184,16 @@ dependencies = [ "clap", ] +[[package]] +name = "clap_complete_nushell" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb9e9715d29a754b468591be588f6b926f5b0a1eb6a8b62acabeb66ff84d897" +dependencies = [ + "clap", + "clap_complete", +] + [[package]] name = "clap_derive" version = "4.5.49" @@ -3196,6 +3219,8 @@ dependencies = [ "anyhow", "askpass", "clap", + "clap_complete", + "clap_complete_nushell", "collections", "console", "core-foundation 0.10.0", @@ -3225,7 +3250,6 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "async-tungstenite", - "base64 0.22.1", "chrono", "clock", "cloud_api_client", @@ -3241,12 +3265,12 @@ dependencies = [ "gpui_tokio", "http_client", "http_client_tls", - "httparse", "log", "objc2-foundation 0.3.2", "parking_lot", "paths", "postage", + "proxy_handshake", "rand 0.9.4", "regex", "release_channel", @@ -3268,7 +3292,6 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls 0.26.4", - "tokio-socks", "url", "util", "windows 0.61.3", @@ -3315,7 +3338,6 @@ dependencies = [ "serde", "serde_json", "strum 0.27.2", - "uuid", "zeta_prompt", ] @@ -3371,7 +3393,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block", "cocoa-foundation 0.2.0", "core-foundation 0.10.0", @@ -3401,7 +3423,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block", "core-foundation 0.10.0", "core-graphics-types 0.2.0", @@ -3525,6 +3547,7 @@ dependencies = [ "theme", "theme_settings", "time", + "title_bar", "tokio", "toml 0.8.23", "tower 0.4.13", @@ -3551,7 +3574,6 @@ dependencies = [ "collections", "db", "editor", - "feature_flags", "futures 0.3.32", "fuzzy", "gpui", @@ -3566,6 +3588,7 @@ dependencies = [ "serde_json", "settings", "smallvec", + "smol", "telemetry", "theme", "theme_settings", @@ -3582,7 +3605,7 @@ name = "collections" version = "0.1.0" dependencies = [ "gpui_util", - "indexmap 2.11.4", + "indexmap 2.14.0", "rustc-hash 2.1.1", ] @@ -3952,6 +3975,7 @@ dependencies = [ "lsp", "menu", "project", + "release_channel", "serde_json", "settings", "ui", @@ -4004,7 +4028,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.0", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -4017,7 +4041,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -4041,7 +4065,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.0", "libc", ] @@ -4052,7 +4076,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block", "cfg-if", "core-foundation 0.10.0", @@ -4152,7 +4176,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "fontdb", "harfrust", "linebender_resource_handle", @@ -4220,36 +4244,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f81cede359311706057b689b91b59f464926de0316f389898a2b028cb494fa" +checksum = "3cd990d8a6304475bbad64534a0d418f5572f44d5f011437e6b9f1ee7d5c2570" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa6ca11305de425ea08884097b913ebe1a83875253b3c0063ce28411e226bfdc" +checksum = "ccabe4636007296721080e02d7dab46d4319638ec4e3f6f7402fcb46dc5122c6" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7537341a9a4ba9812141927be733e7254bf2318aab6597d567af9cad90609f27" +checksum = "da7ed173c870c0aea202a9830880156905a028a88df076e35ce383a8acbf90a7" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d28a4ca5faf25ff821fcc768f26e68ffef505e9f71bb06e608862d941fa65086" +checksum = "800cc586df98b12c502e76707c96565e40629a5322eaa15aaa34ba05f5721e31" dependencies = [ "serde", "serde_derive", @@ -4257,9 +4281,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d891057fe1b73910c41e73b32a70fa8454092fce65942b5fa6f72aa6d5487f8a" +checksum = "ae93f863f9094ae34d2567f9edb0ae2c41d35228b286598354dd78b198868ebd" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -4287,9 +4311,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c29a66028a78eedc534b3a94e5ebfbaeb4e1f6b09038afe41bb24afd614faa4b" +checksum = "38c505162bcf77dcb859905b3eac56a1917fc3cf326424fb06e7732031e3a8ae" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -4300,24 +4324,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95809ad251fe9422087b4a72d61e584d6ab6eff44dee1335f93cfaea0bedc9ac" +checksum = "e3b786958bcb79bdb5fbae095af58f0c2da7d7895c475c991f6a6bb5a9c7e6d9" [[package]] name = "cranelift-control" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79d0cacf063c297e5e8d5b73cb355b41b87f6d248e252d1b284e7a7b73673c2" +checksum = "2faf9a5009bce7f725ce2af7a08c4883ebac6af933e7e0aa7d84f976f4e6deb5" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d73297a195ce3be55997c6307142c4b1e58dd0c2f18ceaa0179444024e312a" +checksum = "017271194ba5e101d626560d0d6767efd341468d1ba0f4d015f19fe64020b65b" dependencies = [ "cranelift-bitset", "serde", @@ -4326,9 +4350,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be38d1ae29ef7c5d611fc6cb694f698dc4ca44152dcaa112ec0fef8d4d34858" +checksum = "f80847f0929967f0cec82f9e0543b3901e0f0063690405891f22107b5a130fd8" dependencies = [ "cranelift-codegen", "log", @@ -4338,15 +4362,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6761926f6636209de7ac568be28b206890f2181761375b9722e0a1e7a7e1637a" +checksum = "75904abbc0e7b46d20f7a49c8042c8a4481c0db4253b99889c723c566295d506" [[package]] name = "cranelift-native" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0893472f73f0d530a28e9a573ada6d1f93b9659bb6734dfe17061ac967bd1830" +checksum = "6e0135923540574362e16f01bf40000664263840991039ff3041ba717de6cf3a" dependencies = [ "cranelift-codegen", "libc", @@ -4355,9 +4379,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1daccebabb1ccd034dbab0eacc0722af27d3cccc7929dea27a3546cb3562e40" +checksum = "93fb12f76c482e034f6ebefa843c914e74112f088215d8d36d33a649f9fab99b" [[package]] name = "crash-context" @@ -4389,6 +4413,7 @@ version = "0.1.0" dependencies = [ "async-process", "crash-handler", + "libc", "log", "mach2 0.5.0", "minidumper", @@ -4618,8 +4643,10 @@ dependencies = [ "anyhow", "editor", "feature_flags", + "fuzzy", "gpui", "log", + "picker", "text", "ui", "workspace", @@ -4675,7 +4702,7 @@ checksum = "d74b6bcf49ebbd91f1b1875b706ea46545032a14003b5557b7dfa4bbeba6766e" dependencies = [ "cc", "codespan-reporting", - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "scratch", @@ -4690,7 +4717,7 @@ checksum = "94ca2ad69673c4b35585edfa379617ac364bccd0ba0adf319811ba3a74ffa48a" dependencies = [ "clap", "codespan-reporting", - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4708,7 +4735,7 @@ version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8ebf0b6138325af3ec73324cb3a48b64d57721f17291b151206782e61f66cd" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5023,7 +5050,7 @@ name = "debugger_ui" version = "0.1.0" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.13.1", "client", "collections", "command_palette_hooks", @@ -5064,9 +5091,7 @@ dependencies = [ "text", "theme", "theme_settings", - "tree-sitter", "tree-sitter-go", - "tree-sitter-json", "ui", "ui_input", "unindent", @@ -5343,7 +5368,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5369,7 +5394,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.3", @@ -5388,9 +5413,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -5854,6 +5879,7 @@ dependencies = [ "unicode-width", "unindent", "url", + "urlencoding", "util", "uuid", "vim_mode_setting", @@ -6033,6 +6059,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -6140,7 +6177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6348,6 +6385,7 @@ dependencies = [ "log", "lsp", "parking_lot", + "path", "pretty_assertions", "proto", "semver", @@ -6359,8 +6397,9 @@ dependencies = [ "tracing", "url", "util", - "wasm-encoder 0.221.3", - "wasmparser 0.221.3", + "wasm-encoder 0.252.0", + "wasmparser 0.252.0", + "which 6.0.3", "ztracing", ] @@ -6440,7 +6479,7 @@ dependencies = [ "tracing", "url", "util", - "wasmparser 0.221.3", + "wasmparser 0.252.0", "wasmtime", "wasmtime-wasi", "zlog", @@ -6560,9 +6599,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ "bit-set 0.8.0", "regex-automata", @@ -6589,6 +6628,9 @@ name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "fax" @@ -6639,6 +6681,7 @@ dependencies = [ "fs", "gpui", "inventory", + "release_channel", "schemars 1.0.4", "serde_json", "settings", @@ -6696,6 +6739,7 @@ dependencies = [ "menu", "open_path_prompt", "picker", + "picker_preview", "pretty_assertions", "project", "project_panel", @@ -6706,7 +6750,6 @@ dependencies = [ "theme", "theme_settings", "ui", - "ui_input", "util", "workspace", "zed_actions", @@ -6736,14 +6779,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", - "windows-sys 0.60.2", ] [[package]] @@ -6811,7 +6852,18 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "nanorand", + "spin 0.9.8", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand 2.3.0", + "futures-core", + "futures-sink", "spin 0.9.8", ] @@ -6857,7 +6909,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -6962,6 +7014,7 @@ dependencies = [ "anyhow", "ashpd", "async-channel 2.5.0", + "async-std", "async-tar", "async-trait", "collections", @@ -6976,11 +7029,14 @@ dependencies = [ "log", "notify 9.0.0-rc.4", "parking_lot", + "path", "paths", "proto", "rope", + "rustix 1.1.2", "serde", "serde_json", + "slotmap", "smol", "telemetry", "tempfile", @@ -6988,6 +7044,7 @@ dependencies = [ "thiserror 2.0.17", "time", "trash", + "unicode-normalization", "util", "windows 0.61.3", ] @@ -7343,7 +7400,7 @@ dependencies = [ "derive_more", "derive_setters", "gh-workflow-macros", - "indexmap 2.11.4", + "indexmap 2.14.0", "merge", "serde", "serde_json", @@ -7361,16 +7418,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gif" version = "0.14.2" @@ -7388,7 +7435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" dependencies = [ "fallible-iterator", - "indexmap 2.11.4", + "indexmap 2.14.0", "stable_deref_trait", ] @@ -7402,7 +7449,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7474,6 +7521,7 @@ dependencies = [ "async-channel 2.5.0", "buffer_diff", "call", + "client", "collections", "component", "ctor", @@ -7660,7 +7708,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -7829,7 +7877,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -7840,7 +7888,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -7853,7 +7901,7 @@ dependencies = [ "async-task", "backtrace", "bindgen 0.71.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "block", "cbindgen", "chrono", @@ -7945,7 +7993,7 @@ dependencies = [ "anyhow", "as-raw-xcb-connection", "ashpd", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "calloop", "calloop-wayland-source", @@ -7960,6 +8008,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", + "notify-rust", "oo7", "open", "parking_lot", @@ -7995,6 +8044,7 @@ dependencies = [ "anyhow", "async-task", "block", + "block2 0.6.2", "cbindgen", "cocoa 0.26.0", "collections", @@ -8022,6 +8072,7 @@ dependencies = [ "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "objc2-user-notifications", "parking_lot", "pathfinder_geometry", "raw-window-handle", @@ -8097,7 +8148,6 @@ dependencies = [ "log", "parking_lot", "raw-window-handle", - "smallvec", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -8127,6 +8177,7 @@ dependencies = [ "raw-window-handle", "smallvec", "swash", + "unicode-bidi", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", @@ -8189,7 +8240,6 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-typescript", "tree-sitter-yaml", - "util", ] [[package]] @@ -8221,7 +8271,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -8240,7 +8290,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -8294,7 +8344,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f9f40651a03bc0f7316bd75267ff5767e93017ef3cfffe76c6aa7252cc5a31c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "core_maths", "read-fonts 0.37.0", @@ -8352,6 +8402,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + [[package]] name = "hashlink" version = "0.8.4" @@ -8448,7 +8509,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd54745cfacb7b97dee45e8fdb91814b62bccddb481debb7de0f9ee6b7bf5b43" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "heed-traits", "heed-types", @@ -8863,7 +8924,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.62.2", ] [[package]] @@ -9028,7 +9089,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif 0.14.2", + "gif", "image-webp", "moxcms", "num-traits", @@ -9036,10 +9097,9 @@ dependencies = [ "qoi", "ravif", "rayon", - "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.15", + "zune-core", + "zune-jpeg", ] [[package]] @@ -9063,6 +9123,7 @@ dependencies = [ "gpui", "language", "log", + "menu", "project", "serde", "settings", @@ -9074,17 +9135,18 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imara-diff" -version = "0.1.8" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" dependencies = [ "hashbrown 0.15.5", + "memchr", ] [[package]] @@ -9112,12 +9174,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -9156,7 +9218,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "inotify-sys", "libc", ] @@ -9219,6 +9281,7 @@ dependencies = [ "anyhow", "client", "gpui", + "log", "release_channel", "smol", "util", @@ -9534,16 +9597,6 @@ dependencies = [ "util", ] -[[package]] -name = "jsonrpcmsg" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "jsonschema" version = "0.37.4" @@ -9681,9 +9734,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -9691,11 +9744,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", ] @@ -9710,12 +9763,13 @@ dependencies = [ [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] @@ -9778,6 +9832,7 @@ dependencies = [ "fuzzy_nucleo", "globset", "gpui", + "grammars", "http_client", "imara-diff", "indoc", @@ -9833,16 +9888,16 @@ dependencies = [ "anyhow", "collections", "gpui_shared_string", + "gpui_util", "log", - "lsp", "parking_lot", + "path", "regex", "schemars 1.0.4", "serde", "serde_json", "toml 0.8.23", "tree-sitter", - "util", ] [[package]] @@ -9878,6 +9933,7 @@ dependencies = [ "env_var", "futures 0.3.32", "gpui", + "gpui_util", "http_client", "icons", "image", @@ -9887,7 +9943,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.17", - "util", ] [[package]] @@ -9902,6 +9957,7 @@ dependencies = [ "http_client", "log", "partial-json-fixer", + "pretty_assertions", "schemars 1.0.4", "serde", "serde_json", @@ -9919,6 +9975,7 @@ dependencies = [ "async-lock", "aws-config", "aws-credential-types", + "aws-sigv4", "aws_http_client", "base64 0.22.1", "bedrock", @@ -9948,14 +10005,15 @@ dependencies = [ "language", "language_model", "language_models_cloud", + "llama_cpp", "lmstudio", "log", "menu", "mistral", - "oauth_callback_server", "ollama", "open_ai", "open_router", + "openai_subscribed", "opencode", "parking_lot", "pretty_assertions", @@ -9965,13 +10023,10 @@ dependencies = [ "serde", "serde_json", "settings", - "sha2", - "smol", "strum 0.27.2", "tokio", "ui", "ui_input", - "url", "util", "x_ai", ] @@ -10231,7 +10286,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", "redox_syscall 0.5.18", ] @@ -10250,7 +10305,7 @@ dependencies = [ [[package]] name = "libwebrtc" version = "0.3.26" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "cxx", "glib", @@ -10348,7 +10403,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "livekit" version = "0.7.32" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "base64 0.22.1", "bmrng", @@ -10374,7 +10429,7 @@ dependencies = [ [[package]] name = "livekit-api" version = "0.4.14" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "base64 0.21.7", "futures-util", @@ -10401,7 +10456,7 @@ dependencies = [ [[package]] name = "livekit-protocol" version = "0.7.1" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "futures-util", "livekit-runtime", @@ -10417,7 +10472,7 @@ dependencies = [ [[package]] name = "livekit-runtime" version = "0.4.0" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "tokio", "tokio-stream", @@ -10479,6 +10534,19 @@ dependencies = [ "zed-scap", ] +[[package]] +name = "llama_cpp" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures 0.3.32", + "http_client", + "schemars 1.0.4", + "serde", + "serde_json", + "url", +] + [[package]] name = "lmdb-master-sys" version = "0.2.5" @@ -10560,7 +10628,7 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6888e8653f6e49cb2924c660fc367a8beeb6239b71e117fa082153c6ea44d427" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cssparser 0.36.0", "encoding_rs", @@ -10615,10 +10683,12 @@ name = "lsp" version = "0.1.0" dependencies = [ "anyhow", + "async-channel 2.5.0", "async-pipe", "collections", "ctor", "futures 0.3.32", + "futures-lite 1.13.0", "gpui", "gpui_util", "log", @@ -10630,7 +10700,6 @@ dependencies = [ "semver", "serde", "serde_json", - "smol", "util", "zlog", ] @@ -10646,6 +10715,32 @@ dependencies = [ "url", ] +[[package]] +name = "lsp_locations" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "editor", + "file_icons", + "fuzzy", + "gpui", + "indoc", + "language", + "log", + "lsp", + "picker", + "picker_preview", + "project", + "settings", + "text", + "theme", + "theme_settings", + "ui", + "util", + "workspace", +] + [[package]] name = "lyon" version = "1.0.16" @@ -10715,6 +10810,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + [[package]] name = "mach2" version = "0.4.3" @@ -10747,7 +10856,7 @@ name = "manatee" version = "0.6.2" source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#9acc3960f04a7deeb08079d60fa8183f15e8bde1" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "nalgebra", "rustc-hash 2.1.1", "thiserror 2.0.17", @@ -10773,6 +10882,7 @@ dependencies = [ "gpui", "gpui_platform", "html5ever 0.27.0", + "image", "language", "languages", "linkify", @@ -11038,6 +11148,7 @@ dependencies = [ "merman", "quick-xml 0.38.3", "serde_json", + "usvg", ] [[package]] @@ -11058,7 +11169,7 @@ dependencies = [ "chrono", "euclid", "htmlize", - "indexmap 2.11.4", + "indexmap 2.14.0", "json5", "lalrpop", "lalrpop-util", @@ -11084,7 +11195,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dugong", - "indexmap 2.11.4", + "indexmap 2.14.0", "manatee", "merman-core", "pulldown-cmark 0.12.2", @@ -11106,7 +11217,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -11165,7 +11276,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e16d10087ae9e375bad7a40e8ef5504bc08e808ccc6019067ff9de42a84570f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "debugid", "num-derive", "num-traits", @@ -11180,7 +11291,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e1fc14d6ded915b8e850801465e7096f77ed60bf87e4e85878d463720d9dc4d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "cfg-if", "crash-context", @@ -11282,7 +11393,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11376,19 +11487,20 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" [[package]] name = "naga" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", "bit-set 0.9.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.1", "codespan-reporting", "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap 2.11.4", + "indexmap 2.14.0", "libm", "log", "num-traits", @@ -11441,15 +11553,6 @@ dependencies = [ "rand 0.8.6", ] -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom 0.2.16", -] - [[package]] name = "native-tls" version = "0.2.18" @@ -11488,7 +11591,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "jni-sys", "log", "ndk-sys", @@ -11534,7 +11637,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -11546,10 +11649,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset", ] [[package]] @@ -11558,7 +11662,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -11646,7 +11750,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crossbeam-channel", "filetime", "fsevent-sys", @@ -11662,9 +11766,9 @@ dependencies = [ [[package]] name = "notify" version = "9.0.0-rc.4" -source = "git+https://github.com/zed-industries/notify?rev=faecbc33db4f59313e5225ef766bfd9e54a54cfd#faecbc33db4f59313e5225ef766bfd9e54a54cfd" +source = "git+https://github.com/zed-industries/notify?rev=0890bbb8ca40a4b5d1f67031698dd7918b37d991#0890bbb8ca40a4b5d1f67031698dd7918b37d991" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "inotify 0.11.0", "kqueue", "libc", @@ -11689,12 +11793,26 @@ dependencies = [ "notify 6.1.1", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite 2.6.1", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "notify-types" version = "2.1.0" -source = "git+https://github.com/zed-industries/notify?rev=faecbc33db4f59313e5225ef766bfd9e54a54cfd#faecbc33db4f59313e5225ef766bfd9e54a54cfd" +source = "git+https://github.com/zed-industries/notify?rev=0890bbb8ca40a4b5d1f67031698dd7918b37d991#0890bbb8ca40a4b5d1f67031698dd7918b37d991" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -11712,7 +11830,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11994,7 +12112,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -12010,7 +12128,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2 0.6.3", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -12022,7 +12140,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", "objc2 0.6.3", "objc2-core-audio", @@ -12060,7 +12178,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2 0.6.3", ] @@ -12070,7 +12188,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -12082,7 +12200,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "dispatch2", "libc", @@ -12101,6 +12219,16 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-services" version = "0.3.2" @@ -12123,7 +12251,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -12135,7 +12263,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.3", @@ -12158,7 +12286,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -12170,7 +12298,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "dispatch2", "objc2 0.6.3", @@ -12184,7 +12312,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -12197,13 +12325,26 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2 0.6.3", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-core-location", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -12230,7 +12371,7 @@ checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "crc32fast", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "memchr", ] @@ -12444,16 +12585,40 @@ dependencies = [ ] [[package]] -name = "opencode" +name = "openai_subscribed" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", + "credentials_provider", "futures 0.3.32", - "google_ai", + "gpui", "http_client", - "language_model_core", - "schemars 1.0.4", - "serde", + "language_model", + "log", + "oauth_callback_server", + "open_ai", + "parking_lot", + "rand 0.9.4", + "serde", + "serde_json", + "sha2", + "smol", + "url", + "util", +] + +[[package]] +name = "opencode" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures 0.3.32", + "google_ai", + "http_client", + "language_model_core", + "schemars 1.0.4", + "serde", "serde_json", "strum 0.27.2", ] @@ -12472,11 +12637,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -12509,9 +12674,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -12601,6 +12766,7 @@ dependencies = [ "lsp", "menu", "picker", + "picker_preview", "project", "rope", "serde_json", @@ -12775,6 +12941,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "path" +version = "0.1.0" +dependencies = [ + "anyhow", + "dunce", + "serde", + "tempfile", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -13396,7 +13572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.11.4", + "indexmap 2.14.0", ] [[package]] @@ -13560,20 +13736,41 @@ name = "picker" version = "0.1.0" dependencies = [ "anyhow", + "db", "editor", "gpui", + "language", "menu", + "project", "schemars 1.0.4", "serde", + "serde_json", "settings", "theme", "theme_settings", "ui", "ui_input", + "util", "workspace", "zed_actions", ] +[[package]] +name = "picker_preview" +version = "0.1.0" +dependencies = [ + "editor", + "gpui", + "language", + "multi_buffer", + "picker", + "project", + "rope", + "settings", + "ui", + "util", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -13689,7 +13886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "quick-xml 0.38.3", "serde", "time", @@ -13742,7 +13939,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -13785,6 +13982,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "pori" version = "0.0.0" @@ -14017,7 +14223,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "hex", "serde", ] @@ -14061,7 +14267,7 @@ dependencies = [ "dap", "encoding_rs", "extension", - "fancy-regex 0.17.0", + "fancy-regex 0.18.0", "fs", "futures 0.3.32", "fuzzy", @@ -14072,7 +14278,7 @@ dependencies = [ "gpui", "http_client", "image", - "indexmap 2.11.4", + "indexmap 2.14.0", "itertools 0.14.0", "language", "log", @@ -14080,6 +14286,7 @@ dependencies = [ "markdown", "node_runtime", "parking_lot", + "path", "paths", "percent-encoding", "postage", @@ -14152,7 +14359,6 @@ dependencies = [ "anyhow", "client", "collections", - "command_palette_hooks", "criterion", "editor", "feature_flags", @@ -14164,6 +14370,9 @@ dependencies = [ "gpui", "itertools 0.14.0", "language", + "log", + "markdown", + "markdown_preview", "menu", "notifications", "pretty_assertions", @@ -14200,6 +14409,7 @@ dependencies = [ "lsp", "ordered-float 2.10.1", "picker", + "picker_preview", "project", "release_channel", "semver", @@ -14261,7 +14471,7 @@ source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.10.0", + "bitflags 2.13.1", "num-traits", "proptest-macro", "rand 0.9.4", @@ -14343,7 +14553,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes 1.11.1", "heck 0.5.0", - "itertools 0.10.5", + "itertools 0.11.0", "log", "multimap", "once_cell", @@ -14376,7 +14586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", "syn 2.0.117", @@ -14431,6 +14641,21 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "proxy_handshake" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "futures 0.3.32", + "httparse", + "percent-encoding", + "piper", + "proptest", + "thiserror 2.0.17", + "tokio", + "url", +] + [[package]] name = "proxyvars" version = "0.2.0" @@ -14477,7 +14702,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "memchr", "pulldown-cmark-escape 0.10.1", "unicase", @@ -14489,7 +14714,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape 0.11.0", @@ -14502,7 +14727,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "memchr", "unicase", ] @@ -14521,9 +14746,9 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "pulley-interpreter" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b78fdec962b639b921badfcfe77db7d18aa3c0c1e292ac2aa268c0efe8fe683" +checksum = "558181096e0df4984f45cfc3a7087052df4a61c36089b135a08ceca9cbd352fb" dependencies = [ "cranelift-bitset", "log", @@ -14533,9 +14758,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f718f4e8cd5fdfa08b3b1d2d25fe288350051be330544305f0a9b93a937b3d42" +checksum = "b5d52e2f14e168d75cdabe9bd5fb1ff18a1b119dc6699684aee895dbc3524da9" dependencies = [ "proc-macro2", "quote", @@ -15002,7 +15227,6 @@ dependencies = [ "task", "telemetry", "ui", - "ui_input", "util", "windows-registry 0.6.1", "workspace", @@ -15012,20 +15236,20 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.16" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -15121,9 +15345,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -15174,6 +15398,7 @@ dependencies = [ "serde_json", "settings", "smol", + "telemetry", "tempfile", "thiserror 2.0.17", "urlencoding", @@ -15266,6 +15491,7 @@ dependencies = [ "smol", "sysinfo 0.37.2", "task", + "telemetry", "tempfile", "theme", "theme_settings", @@ -15276,6 +15502,7 @@ dependencies = [ "uuid", "watch", "windows 0.61.3", + "workspace", "worktree", "zlog", ] @@ -15450,19 +15677,19 @@ dependencies = [ [[package]] name = "resvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ - "gif 0.13.3", + "gif", "image-webp", "log", "pico-args", "rgb", - "svgtypes 0.15.3", + "svgtypes 0.16.1", "tiny-skia", "usvg", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -15605,6 +15832,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rpassword" version = "7.5.2" @@ -15786,7 +16022,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno 0.3.14", "libc", "linux-raw-sys 0.4.15", @@ -15799,11 +16035,11 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno 0.3.14", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -15980,7 +16216,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "core_maths", "log", @@ -16033,8 +16269,15 @@ name = "sandbox" version = "0.1.0" dependencies = [ "anyhow", + "futures 0.3.32", + "gpui", + "http_proxy", "libc", "log", + "nix 0.29.0", + "seccompiler", + "serde", + "serde_json", "smol", "tempfile", ] @@ -16065,7 +16308,7 @@ dependencies = [ "async-task", "backtrace", "chrono", - "flume", + "flume 0.12.0", "futures 0.3.32", "parking_lot", "rand 0.9.4", @@ -16106,7 +16349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", - "indexmap 2.11.4", + "indexmap 2.14.0", "ref-cast", "schemars_derive", "serde", @@ -16302,23 +16545,29 @@ version = "0.1.0" dependencies = [ "any_vec", "anyhow", - "bitflags 2.10.0", + "bitflags 2.13.1", "collections", + "db", "editor", + "file_icons", "fs", "futures 0.3.32", - "futures-lite 1.13.0", "gpui", "itertools 0.14.0", "language", "lsp", "menu", "multi_buffer", + "picker", + "picker_preview", "pretty_assertions", "project", + "regex", "serde", "serde_json", "settings", + "smol", + "text", "theme", "theme_settings", "tracing", @@ -16345,6 +16594,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -16360,7 +16618,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -16373,7 +16631,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.0", "core-foundation-sys", "libc", @@ -16396,7 +16654,7 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cssparser 0.36.0", "derive_more", "log", @@ -16491,7 +16749,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "memchr", "ryu", @@ -16505,7 +16763,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "memchr", "ryu", @@ -16575,7 +16833,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.4", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.0.4", "serde_core", @@ -16602,7 +16860,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -16615,7 +16873,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -16757,6 +17015,7 @@ dependencies = [ "agent_skills", "anyhow", "audio", + "client", "cloud_api_types", "codestral", "collections", @@ -17247,7 +17506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -17283,7 +17542,7 @@ version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -17376,7 +17635,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -17394,7 +17653,7 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots", + "webpki-roots 0.26.8", ] [[package]] @@ -17444,7 +17703,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "bytes 1.11.1", "chrono", @@ -17491,7 +17750,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "chrono", "crc", @@ -17533,7 +17792,7 @@ checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", - "flume", + "flume 0.11.1", "futures-channel", "futures-core", "futures-executor", @@ -17860,11 +18119,11 @@ dependencies = [ [[package]] name = "svgtypes" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo 0.11.3", + "kurbo 0.13.1", "siphasher 1.0.1", ] @@ -18048,6 +18307,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "0.1.2" @@ -18146,7 +18416,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -18190,7 +18460,7 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cap-fs-ext", "cap-std", "fd-lock", @@ -18240,9 +18510,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.10.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" +checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" dependencies = [ "arrayvec", "grid", @@ -18327,6 +18597,8 @@ dependencies = [ "serde", "serde_json", "task", + "tree-sitter", + "tree-sitter-json", "tree-sitter-rust", "tree-sitter-typescript", "ui", @@ -18335,6 +18607,17 @@ dependencies = [ "zed_actions", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.17", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "telemetry" version = "0.1.0" @@ -18364,7 +18647,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -18472,6 +18755,7 @@ dependencies = [ "serde_json", "settings", "shellexpand", + "shlex", "task", "terminal", "theme", @@ -18543,7 +18827,7 @@ dependencies = [ "clap", "collections", "gpui", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "palette", "serde", @@ -18659,7 +18943,7 @@ dependencies = [ "half", "quick-error 2.0.1", "weezl", - "zune-jpeg 0.5.15", + "zune-jpeg", ] [[package]] @@ -18907,7 +19191,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" dependencies = [ "either", - "futures-io", "futures-util", "thiserror 1.0.69", "tokio", @@ -19032,7 +19315,7 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.0.3", "toml_datetime 0.7.3", @@ -19065,7 +19348,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -19079,7 +19362,7 @@ version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "toml_datetime 0.7.3", "toml_parser", "winnow 0.7.13", @@ -19171,7 +19454,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes 1.11.1", "futures-core", "futures-util", @@ -19190,7 +19473,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes 1.11.1", "futures-util", "http 1.3.1", @@ -19215,7 +19498,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes 1.11.1", "futures-util", "http 1.3.1", @@ -19350,7 +19633,7 @@ dependencies = [ [[package]] name = "trash" version = "5.2.5" -source = "git+https://github.com/zed-industries/trash-rs?rev=3bf27effd4eb8699f2e484d3326b852fe3e53af7#3bf27effd4eb8699f2e484d3326b852fe3e53af7" +source = "git+https://github.com/zed-industries/trash-rs?rev=41c6c800d884a89351f3b8856d12894cccee261d#41c6c800d884a89351f3b8856d12894cccee261d" dependencies = [ "chrono", "libc", @@ -19534,7 +19817,7 @@ checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" [[package]] name = "tree-sitter-md" version = "0.3.2" -source = "git+https://github.com/tree-sitter-grammars/tree-sitter-markdown?rev=9a23c1a96c0513d8fc6520972beedd419a973539#9a23c1a96c0513d8fc6520972beedd419a973539" +source = "git+https://github.com/zed-industries/tree-sitter-markdown?rev=b596e737286780d7bfa9fcddceaeeb754574b352#b596e737286780d7bfa9fcddceaeeb754574b352" dependencies = [ "cc", "tree-sitter-language", @@ -19791,10 +20074,13 @@ dependencies = [ "num-format", "schemars 1.0.4", "serde", + "settings", "smallvec", "strum 0.27.2", "theme", + "theme_settings", "ui_macros", + "web-time", "windows 0.61.3", ] @@ -19974,24 +20260,24 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "usvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ "base64 0.22.1", "data-url", "flate2", "fontdb", "imagesize", - "kurbo 0.11.3", + "kurbo 0.13.1", "log", "pico-args", - "roxmltree", + "roxmltree 0.21.1", "rustybuzz", "simplecss", "siphasher 1.0.1", "strict-num", - "svgtypes 0.15.3", + "svgtypes 0.16.1", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -20046,6 +20332,7 @@ dependencies = [ "log", "mach2 0.5.0", "nix 0.29.0", + "path", "percent-encoding", "pretty_assertions", "rand 0.9.4", @@ -20275,7 +20562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags 2.10.0", + "bitflags 2.13.1", "cursor-icon", "log", "memchr", @@ -20438,16 +20725,6 @@ dependencies = [ "leb128", ] -[[package]] -name = "wasm-encoder" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" -dependencies = [ - "leb128", - "wasmparser 0.221.3", -] - [[package]] name = "wasm-encoder" version = "0.227.1" @@ -20478,6 +20755,16 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", +] + [[package]] name = "wasm-metadata" version = "0.201.0" @@ -20485,7 +20772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fd83062c17b9f4985d438603cde0a5e8c5c8198201a6937f778b607924c7da2" dependencies = [ "anyhow", - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_derive", "serde_json", @@ -20503,7 +20790,7 @@ dependencies = [ "anyhow", "auditable-serde", "flate2", - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_derive", "serde_json", @@ -20520,7 +20807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.11.4", + "indexmap 2.14.0", "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -20541,8 +20828,7 @@ dependencies = [ [[package]] name = "wasm_thread" version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" +source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" dependencies = [ "futures 0.3.32", "js-sys", @@ -20556,33 +20842,20 @@ version = "0.201.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" dependencies = [ - "bitflags 2.10.0", - "indexmap 2.11.4", + "bitflags 2.13.1", + "indexmap 2.14.0", "semver", ] -[[package]] -name = "wasmparser" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" -dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.15.5", - "indexmap 2.11.4", - "semver", - "serde", -] - [[package]] name = "wasmparser" version = "0.227.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", ] @@ -20592,9 +20865,9 @@ version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", "serde", ] @@ -20605,10 +20878,23 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indexmap 2.14.0", "semver", + "serde", ] [[package]] @@ -20624,20 +20910,20 @@ dependencies = [ [[package]] name = "wasmtime" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b10306ead921db2c4645ff99867b7539b65e18afd8816d471547f5e6f3b09492" +checksum = "4b4442dc12aa2473def8334f0e0f2b489be52c52507c938bbdc8be69ded4ded6" dependencies = [ "addr2line", "anyhow", "async-trait", - "bitflags 2.10.0", + "bitflags 2.13.1", "bumpalo", "cc", "cfg-if", "encoding_rs", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "libc", "log", "mach2 0.4.3", @@ -20685,16 +20971,16 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7fb2c37ca263d444f33871bf0221e7de0707b2b2bb88165df6db6d58c73375f" +checksum = "5d881c3d6205898a226cc487b117f23b9ed1c7da39952d65bd5eeb6745b3789c" dependencies = [ "anyhow", "cpp_demangle", "cranelift-bitset", "cranelift-entity", "gimli", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "object", "postcard", @@ -20712,9 +20998,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-asm-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c6c0d3c8d2db554a3af8e8d413ff2815362ebce0911808ecfdaaa257438f93" +checksum = "5ab1876bcfa51d6a05dea1c13933f53cbc1e316c783fddebc859f56a736eae07" dependencies = [ "cfg-if", ] @@ -20731,9 +21017,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e3f3752466eb0e1f97149e53bf15c0e18ff520fc0a98b4bee1680e6de1c6f0" +checksum = "8ae1407944a0b13a8a77930b5b951aa7134beccecad7efac1ef9f03adb7d1a0f" dependencies = [ "anyhow", "proc-macro2", @@ -20746,15 +21032,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f54018baf62f4e9c616c31f2aeadcf0c202ff691a390ad53e291ae7160b169e" +checksum = "646a53678ce6aaf6f097e18ca51f650f2841aea6d2bcd7b61931397b8b8f30db" [[package]] name = "wasmtime-internal-cranelift" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a2412f2afb0a5db2a4ac1cfff73247e240aeaa90bf41497ad0a5084b6a24eca" +checksum = "ab3495aa8300e4ca6b53f81a53ce5eff6621fd5ff8378ef9ae552d1479d57371" dependencies = [ "anyhow", "cfg-if", @@ -20779,9 +21065,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecfdc460dd5d343d88ff1ffaf65ae019feeb6124ddcfd3f39d28331068d25b1f" +checksum = "29b5e4023a6b167da157338f5f0f505945eb45e78f1cac2d4dcce0922457d7d4" dependencies = [ "anyhow", "cc", @@ -20795,9 +21081,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5abb428a71827b7f90fc64406749883ccc6e58addf6d36974d5e06942011707" +checksum = "9da71e2d573e3cc6f753a3b7bff98f425ca060c0e8071cc55c3d867a9edf3ecc" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -20805,9 +21091,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6cc13f14c3fb83fb877cb1d5c605e93f7ec1bf7fc1a5e8b361209d2f8ca028" +checksum = "627d8f57909a4f9bb1dbe57a96229a54b89d5995353d0b321f3cb9a1a118977a" dependencies = [ "anyhow", "cfg-if", @@ -20817,24 +21103,24 @@ dependencies = [ [[package]] name = "wasmtime-internal-math" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb209473a09f4dbd9c87bb9f18b8dcb0c9da30d12a260e3eacf7a1a53b41480" +checksum = "45b99315585a8a27125dd9b0150edb115d6f6ff0baae453c21d30822aab77f00" dependencies = [ "libm", ] [[package]] name = "wasmtime-internal-slab" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aab4df5a04752106e1ecef9d40145ef28fa033b0d5dd3c839c9b208b2d522183" +checksum = "8eaee97281dd3fe47ec3d46c16fb9fe2dd32f37d0523c2d5c484f11b348734e4" [[package]] name = "wasmtime-internal-unwinder" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5359875d29bddb6f7e65e698157714d8d35ebd8ea2a92893d05d6b062147b639" +checksum = "d0c005f82c48492b6b44fa19ee5205bd933c4f8baca41e314eca8331dd3c4fd9" dependencies = [ "anyhow", "cfg-if", @@ -20845,9 +21131,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e247bcdd69701743ba386c933b26ebad2ce912ff9cb68b5b71fdb29d39ba04a" +checksum = "7b73639a9c0c0e33a2ef942ca99b6772b48393be92bebbd0767c607e5b0a68e0" dependencies = [ "proc-macro2", "quote", @@ -20856,9 +21142,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0298dfd9f57588222b5a92dcffe75894f1ead4e519850f176bde7fcfd105d54" +checksum = "392ca021d084c7426616ef77e1284315555f11bcbb34f416d74b0732db622811" dependencies = [ "anyhow", "cranelift-codegen", @@ -20873,26 +21159,26 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1706803e83b9bae726a0f55e7c1bbf78a7421cf2da68c940c70978e91dfc0339" +checksum = "7fd4703351476262d715b72431e80d10289908e3494050071d6521267f522d97" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.13.1", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "wit-parser 0.236.1", ] [[package]] name = "wasmtime-wasi" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a430602ec54d0e32fbb61d2d8c7e5885eaa9dbc1664b6ed57fb57df439810a0" +checksum = "21921b6e8e8ed876a288cb3b0b3aee68809fed8182ce26c9977ffc4af4cb77f6" dependencies = [ "anyhow", "async-trait", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes 1.11.1", "cap-fs-ext", "cap-net-ext", @@ -20917,9 +21203,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2ba5dd68962de394cf15c7fb185f138cdd685ced631a7ed8e056de3e071029" +checksum = "2cceb2d110d8de61e7b0e8c501b838d3c6403a14cdca8cb612ff1228db537c6d" dependencies = [ "anyhow", "async-trait", @@ -20965,9 +21251,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -20983,7 +21269,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "rustix 1.1.2", "wayland-backend", "wayland-scanner", @@ -21006,7 +21292,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -21018,7 +21304,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -21031,7 +21317,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -21051,9 +21337,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -21141,10 +21427,19 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webrtc-sys" version = "0.3.23" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "cc", "cxx", @@ -21158,7 +21453,7 @@ dependencies = [ [[package]] name = "webrtc-sys-build" version = "0.3.13" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "anyhow", "fs2", @@ -21177,11 +21472,12 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "cfg-if", "cfg_aliases 0.2.1", @@ -21206,18 +21502,19 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", "bit-set 0.9.1", "bit-vec 0.9.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "cfg_aliases 0.2.1", "document-features", "hashbrown 0.16.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "naga", "once_cell", @@ -21238,38 +21535,42 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set 0.9.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "bytemuck", "cfg-if", @@ -21315,8 +21616,9 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ "naga", "wgpu-types", @@ -21324,10 +21626,11 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", @@ -21395,13 +21698,13 @@ dependencies = [ [[package]] name = "wiggle" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1979d3ed3ffc017538e518da6faa66b129f9229492981fc51004f28cb86db792" +checksum = "55a0751406b641ff50ef42d4a1ca843a03040c488c0c27f92093633447464013" dependencies = [ "anyhow", "async-trait", - "bitflags 2.10.0", + "bitflags 2.13.1", "thiserror 2.0.17", "tracing", "wasmtime", @@ -21410,9 +21713,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d92ae7a084d8543aa7ccef0fac52c86481a7278d0533f7fdeaf89bd7b7e29f" +checksum = "ab62083fdcecdd0cac61b8c46e7de4f2629ebe8699fd9ce790d922cc89d50f5f" dependencies = [ "anyhow", "heck 0.5.0", @@ -21424,9 +21727,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a1b1b93fd9ce569bb40c1eadf5c56533cebfc04ba545c8bc1e74464cff0735" +checksum = "756b7a4a7f57ee2f53e9ef3501ed0faacda4b8dcb169a921cddc8bc09ebd199e" dependencies = [ "proc-macro2", "quote", @@ -21456,7 +21759,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -21467,9 +21770,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e2d7ea2137be52644d9c42ca5a4899bba07c2ed2db1e66c4c1994adfe35d39e" +checksum = "61ec880b20caaa72245944b54cfb22aca111f8c805e12a7542b40d66921e5323" dependencies = [ "anyhow", "cranelift-assembler-x64", @@ -22029,6 +22332,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -22285,7 +22597,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "windows-sys 0.59.0", ] @@ -22304,7 +22616,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "288f992ea30e6b5c531b52cdd5f3be81c148554b09ea416f058d16556ba92c27" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "wit-bindgen-rt 0.22.0", "wit-bindgen-rust-macro 0.22.0", ] @@ -22378,7 +22690,7 @@ version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "futures 0.3.32", "once_cell", ] @@ -22391,7 +22703,7 @@ checksum = "d8a39a15d1ae2077688213611209849cad40e9e5cccf6e61951a425850677ff3" dependencies = [ "anyhow", "heck 0.4.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "wasm-metadata 0.201.0", "wit-bindgen-core 0.22.0", "wit-component 0.201.0", @@ -22405,7 +22717,7 @@ checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata 0.227.1", @@ -22421,7 +22733,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata 0.244.0", @@ -22480,8 +22792,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" dependencies = [ "anyhow", - "bitflags 2.10.0", - "indexmap 2.11.4", + "bitflags 2.13.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22499,8 +22811,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" dependencies = [ "anyhow", - "bitflags 2.10.0", - "indexmap 2.11.4", + "bitflags 2.13.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22518,8 +22830,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", - "indexmap 2.11.4", + "bitflags 2.13.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22538,7 +22850,7 @@ checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22556,7 +22868,7 @@ checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22574,7 +22886,7 @@ checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22592,7 +22904,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22662,6 +22974,7 @@ dependencies = [ "theme", "theme_settings", "ui", + "ui_input", "url", "util", "uuid", @@ -22796,11 +23109,12 @@ dependencies = [ [[package]] name = "xattr" -version = "0.2.3" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", + "rustix 1.1.2", ] [[package]] @@ -22834,7 +23148,7 @@ name = "xim-parser" version = "0.2.1" source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -22896,7 +23210,7 @@ dependencies = [ "clap", "compliance", "gh-workflow", - "indexmap 2.11.4", + "indexmap 2.14.0", "indoc", "itertools 0.14.0", "regex", @@ -22940,23 +23254,24 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yawc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a5d82922135b4ae73a079a4ffb5501e9aadb4d785b8c660eaa0a8b899028c5" +version = "0.3.3" +source = "git+https://github.com/zed-industries/yawc?rev=71a452f551cac178367eaac5d7418a09afa1f3a2#71a452f551cac178367eaac5d7418a09afa1f3a2" dependencies = [ "base64 0.22.1", "bytes 1.11.1", "flate2", "futures 0.3.32", + "getrandom 0.2.16", "http-body-util", "hyper 1.7.0", "hyper-util", "js-sys", + "log", "nom 8.0.0", "pin-project", "rand 0.8.6", "sha1", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-rustls 0.26.4", "tokio-util", @@ -22964,7 +23279,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -23107,7 +23422,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.9.0" +version = "1.15.0" dependencies = [ "acp_thread", "acp_tools", @@ -23195,6 +23510,7 @@ dependencies = [ "languages", "line_ending_selector", "log", + "lsp_locations", "markdown", "markdown_preview", "menu", @@ -23286,7 +23602,7 @@ name = "zed-font-kit" version = "0.14.1-zed" source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "core-foundation 0.10.0", "core-graphics 0.24.0", @@ -23699,12 +24015,6 @@ dependencies = [ name = "ztracing_macro" version = "0.1.0" -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" @@ -23720,22 +24030,13 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 07d2e776f67ed7..120380f2b47bbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,8 +127,10 @@ members = [ "crates/line_ending_selector", "crates/livekit_api", "crates/livekit_client", + "crates/llama_cpp", "crates/lmstudio", "crates/lsp", + "crates/lsp_locations", "crates/markdown", "crates/markdown_preview", "crates/mermaid_render", @@ -144,6 +146,7 @@ members = [ "crates/oauth_callback_server", "crates/ollama", "crates/onboarding", + "crates/openai_subscribed", "crates/opencode", "crates/open_ai", "crates/open_path_prompt", @@ -151,8 +154,10 @@ members = [ "crates/outline", "crates/outline_panel", "crates/panel", + "crates/path", "crates/paths", "crates/picker", + "crates/picker_preview", "crates/platform_title_bar", "crates/prettier", "crates/project", @@ -161,6 +166,7 @@ members = [ "crates/project_symbols", "crates/prompt_store", "crates/proto", + "crates/proxy_handshake", "crates/recent_projects", "crates/refineable", "crates/refineable/derive_refineable", @@ -386,14 +392,17 @@ languages = { path = "crates/languages" } line_ending_selector = { path = "crates/line_ending_selector" } livekit_api = { path = "crates/livekit_api" } livekit_client = { path = "crates/livekit_client" } +llama_cpp = { path = "crates/llama_cpp" } lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } +lsp_locations = { path = "crates/lsp_locations" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } mermaid_render = { path = "crates/mermaid_render" } svg_preview = { path = "crates/svg_preview" } media = { path = "crates/media" } menu = { path = "crates/menu" } +mime = "0.3.17" migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } @@ -404,6 +413,7 @@ notifications = { path = "crates/notifications" } oauth_callback_server = { path = "crates/oauth_callback_server" } ollama = { path = "crates/ollama" } onboarding = { path = "crates/onboarding" } +openai_subscribed = { path = "crates/openai_subscribed" } opencode = { path = "crates/opencode" } open_ai = { path = "crates/open_ai" } open_path_prompt = { path = "crates/open_path_prompt" } @@ -412,8 +422,10 @@ outline = { path = "crates/outline" } outline_panel = { path = "crates/outline_panel" } panel = { path = "crates/panel" } paths = { path = "crates/paths" } +path = { path = "crates/path" } perf = { path = "tooling/perf" } picker = { path = "crates/picker" } +picker_preview = { path = "crates/picker_preview" } prettier = { path = "crates/prettier" } settings_profile_selector = { path = "crates/settings_profile_selector" } project = { path = "crates/project" } @@ -421,6 +433,7 @@ project_panel = { path = "crates/project_panel" } project_symbols = { path = "crates/project_symbols" } prompt_store = { path = "crates/prompt_store" } proto = { path = "crates/proto" } +proxy_handshake = { path = "crates/proxy_handshake" } recent_projects = { path = "crates/recent_projects" } refineable = { path = "crates/refineable" } release_channel = { path = "crates/release_channel" } @@ -501,11 +514,11 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -accesskit = "0.24.0" +accesskit = { version = "0.24.0", features = ["enumn"] } accesskit_macos = "0.26.0" accesskit_unix = "0.21.0" accesskit_windows = "0.33.1" -agent-client-protocol = { version = "=0.14.0", features = ["unstable"] } +agent-client-protocol = { version = "=2.0.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" @@ -527,7 +540,9 @@ async-io = "2.6.0" async-lock = "3.4.2" async-pipe = { git = "https://github.com/zed-industries/async-pipe-rs", rev = "82d00a04211cf4e1236029aa03e6b6ce2a74c553" } async-recursion = "1.0.0" -async-tar = "0.5.1" +# `unstable` is required to compile async-tar's Windows symlink support. +async-std = { version = "1.12", features = ["unstable"] } +async-tar = "0.6" async-task = "4.7" async-trait = "0.1" async-tungstenite = "0.31.0" @@ -540,28 +555,36 @@ aws-credential-types = { version = "1.2.8", features = [ aws-sdk-bedrockruntime = { version = "1.112.0", features = [ "behavior-version-latest", ] } +aws-sigv4 = { version = "1.4.0", features = ["http1"] } 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" bitflags = "2.6.0" +block = "0.1" +block2 = "0.6" brotli = "8.0.2" bytes = "1.0" cargo_metadata = "0.19" cargo_toml = "0.21" brush-parser = "0.3" +cbindgen = { version = "0.28.0", default-features = false } cfg-if = "1.0.3" chardetng = "0.1" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" circular-buffer = "1.0" clap = { version = "4.4", features = ["derive", "wrap_help"] } +clap_complete = { version = "4.4" } +clap_complete_nushell = { version = "4.4" } cocoa = "=0.26.0" cocoa-foundation = "=0.2.0" const_format = "0.2" convert_case = "0.11.0" -core-foundation = "=0.10.0" +core-foundation = "0.10" core-foundation-sys = "0.8.6" +core-graphics = "0.24" +core-text = "21" core-video = { version = "0.5.2", features = ["metal"] } cpal = "0.17" crash-handler = "0.7" @@ -584,13 +607,16 @@ dirs = "6.0" documented = "0.9.1" dotenvy = "0.15.0" dunce = "1.0" -ec4rs = "1.1" +ec4rs = { version = "1.2", features = ["allow-empty-values"] } emojis = "0.6.1" env_logger = "0.11" encoding_rs = "0.8" +etagere = "0.2" exec = "0.3.1" -fancy-regex = "0.17.0" +fancy-regex = "0.18.0" fork = "0.4.0" +flume = "0.12" +foreign-types = "0.5" futures = "0.3.32" futures-concurrency = "7.7.1" futures-lite = "1.13" @@ -611,8 +637,26 @@ http-body = "1.0" httparse = "1.10" idna = "1.0" ignore = "0.4.22" -image = "0.25.1" -imara-diff = "0.1.8" +# image's default features minus "avif", which only adds an encoder (rav1e); +# decoding AVIF would additionally need the non-default "avif-native" feature. +image = { version = "0.25.1", default-features = false, features = [ + "bmp", + "dds", + "exr", + "ff", + "gif", + "hdr", + "ico", + "jpeg", + "png", + "pnm", + "qoi", + "rayon", + "tga", + "tiff", + "webp", +] } +imara-diff = "0.2.0" indexmap = { version = "2.7.0", features = ["serde"] } indoc = "2" inventory = "0.3.19" @@ -638,6 +682,7 @@ moka = { version = "0.12.10", features = ["sync"] } nanoid = "0.4" nbformat = "1.2.0" nix = "0.29" +notify-rust = "4" nucleo = "0.5" num-format = "0.4.4" objc = "0.2" @@ -669,6 +714,7 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "NSProcessInfo", "NSRange", "NSRunLoop", + "NSSet", "NSString", "NSURL", "NSUndoManager", @@ -676,12 +722,14 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "objc2-core-foundation", "std", ] } +objc2-user-notifications = "0.3" open = "5.0.0" ordered-float = "2.1.1" palette = { version = "0.7.5", default-features = false, features = ["std"] } parking_lot = "0.12.1" partial-json-fixer = "0.5.3" parse_int = "0.9" +pathfinder_geometry = "0.5" pciid-parser = "0.8.0" pathdiff = "0.2" percent-encoding = "2.3.2" @@ -692,6 +740,7 @@ pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", re pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "9e61a22af989fe54937bf07c9f9cff1bc53d9056" } pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "9e61a22af989fe54937bf07c9f9cff1bc53d9056" } pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "9e61a22af989fe54937bf07c9f9cff1bc53d9056" } +piper = "0.2" portable-pty = "0.9.0" postage = { version = "0.5", features = ["futures-traits"] } pretty_assertions = { version = "1.3.0", features = ["unstable"] } @@ -710,6 +759,7 @@ quick-xml = "0.38" quote = "1.0.9" rand = "0.9" rayon = "1.8" +raw-window-handle = "0.6" regex = "1.5" # WARNING: If you change this, you must also publish a new version of zed-reqwest to crates.io reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662463bda39148ba154100dd44d3fba5873a4", default-features = false, features = [ @@ -721,17 +771,25 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662 "socks", "stream", ], package = "zed-reqwest", version = "0.12.15-zed" } +resvg = { version = "0.46.0", default-features = false, features = [ + "text", + "system-fonts", + "memmap-fonts", + "raster-images", +] } rsa = "0.9.6" runtimelib = { version = "1.4.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } rust-embed = { version = "8.11", features = ["include-exclude", "debug-embed"] } rustc-hash = "2.1.0" +rustix = { version = "1.1", features = ["fs"] } rustls = { version = "0.23.26" } rustls-platform-verifier = "0.5.0" # WARNING: If you change this, you must also publish a new version of zed-scap to crates.io scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } schemars = { version = "1.0", features = ["indexmap2"] } +seccompiler = "0.5" semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0.221", features = ["derive", "rc"] } serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } @@ -755,6 +813,7 @@ stacksafe = "0.1" streaming-iterator = "0.1" strsim = "0.11" strum = { version = "0.27.2", features = ["derive"] } +swash = "0.2.6" syn = { version = "2.0.101", features = ["full", "extra-traits", "visit-mut"] } sys-locale = "0.3.1" sysinfo = "0.37.0" @@ -771,10 +830,6 @@ time = { version = "0.3", features = [ ] } tiny_http = "0.12" tokio = { version = "1" } -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", @@ -782,7 +837,7 @@ toml_edit = { version = "0.22", default-features = false, features = [ "serde", ] } tower-http = "0.4.4" -tree-sitter = { version = "0.26.9", features = ["wasm"] } +tree-sitter = "0.26.9" tree-sitter-bash = "0.25.1" tree-sitter-c = "0.24.1" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } @@ -798,7 +853,7 @@ tree-sitter-heex = { git = "https://github.com/zed-industries/tree-sitter-heex", tree-sitter-html = "0.23" tree-sitter-jsdoc = "0.23" tree-sitter-json = "0.24" -tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-markdown", rev = "9a23c1a96c0513d8fc6520972beedd419a973539" } +tree-sitter-md = { git = "https://github.com/zed-industries/tree-sitter-markdown", rev = "b596e737286780d7bfa9fcddceaeeb754574b352" } # fork of 9a23c1a with serialize() buffer-overflow fix; https://github.com/tree-sitter-grammars/tree-sitter-markdown/issues/243 tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" @@ -807,17 +862,21 @@ tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter- tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } tracing = "0.1.40" unicase = "2.6" +unicode-bidi = { version = "0.3.18", default-features = false, features = [ + "hardcoded-data", +] } unicode-script = "0.5.7" unicode-segmentation = "1.10" unicode-width = "0.2" unindent = "0.2.0" url = "2.2" urlencoding = "2.1.2" +usvg = { version = "0.46.0", default-features = false } uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } vte = { version = "0.15.0", features = ["ansi"] } walkdir = "2.5" -wasm-encoder = "0.221" -wasmparser = "0.221" +wasm-encoder = "0.252" +wasmparser = "0.252" wasmtime = { version = "36", default-features = false, features = [ "async", "demangle", @@ -831,24 +890,28 @@ wasmtime-wasi = "36" wax = "0.7" which = "6.0.0" wasm-bindgen = "0.2.120" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +console_error_panic_hook = "0.1.7" web-time = "1.1.0" webrtc-sys = "0.3.23" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } +wgpu = "29.0.4" windows-core = "0.61" yaml-rust2 = "0.8" -yawc = "0.2.5" +yawc = { git = "https://github.com/zed-industries/yawc", rev = "71a452f551cac178367eaac5d7418a09afa1f3a2", version = "0.3.3" } zeroize = "1.8" zstd = "0.11" - [workspace.dependencies.windows] version = "0.61" features = [ + "Data_Xml_Dom", "Foundation_Numerics", "Globalization_DateTimeFormatting", "Storage_Search", "Storage_Streams", "System_Threading", + "UI_Notifications", "UI_ViewManagement", "Wdk_System_SystemServices", "Win32_Foundation", @@ -870,6 +933,7 @@ features = [ "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", + "Win32_Storage_Packaging_Appx", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Console", @@ -905,11 +969,11 @@ async-process = { git = "https://github.com/zed-industries/async-process.git", r async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } calloop = { git = "https://github.com/zed-industries/calloop" } -livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } -libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } -notify = { git = "https://github.com/zed-industries/notify", rev = "faecbc33db4f59313e5225ef766bfd9e54a54cfd" } -notify-types = { git = "https://github.com/zed-industries/notify", rev = "faecbc33db4f59313e5225ef766bfd9e54a54cfd" } -webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "d0e27be0cdad89eadab3e36207cda0a2b6e359ee" } +libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "d0e27be0cdad89eadab3e36207cda0a2b6e359ee" } +notify = { git = "https://github.com/zed-industries/notify", rev = "0890bbb8ca40a4b5d1f67031698dd7918b37d991" } +notify-types = { git = "https://github.com/zed-industries/notify", rev = "0890bbb8ca40a4b5d1f67031698dd7918b37d991" } +webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "d0e27be0cdad89eadab3e36207cda0a2b6e359ee" } [profile.dev] split-debuginfo = "unpacked" @@ -969,6 +1033,7 @@ edit_prediction_ui = { codegen-units = 1 } install_cli = { codegen-units = 1 } journal = { codegen-units = 1 } json_schema_store = { codegen-units = 1 } +llama_cpp = { codegen-units = 1 } lmstudio = { codegen-units = 1 } menu = { codegen-units = 1 } notifications = { codegen-units = 1 } @@ -1057,3 +1122,10 @@ ignored = [ "documented", "sea-orm-macros", ] + +# Dylint discovers our custom lints through this entry, so `cargo dylint --all` +# runs them without a `--path` argument. The `lints` package pins its own +# nightly toolchain (see `tooling/lints/rust-toolchain.toml`) and is kept out of +# this workspace on purpose. +[workspace.metadata.dylint] +libraries = [{ path = "tooling/lints" }] diff --git a/Procfile.all b/Procfile.all deleted file mode 100644 index 264b4f6afc3d6f..00000000000000 --- a/Procfile.all +++ /dev/null @@ -1,6 +0,0 @@ -collab: RUST_LOG=${RUST_LOG:-info} cargo run --package=collab serve all -cloud: cd ../cloud; cargo make dev -dashboard: cd ../cloud/packages/dashboard; pnpm dev -website: cd ../zed.dev; pnpm dev --port=3000 -livekit: livekit-server --dev -blob_store: ./script/run-local-minio diff --git a/README.md b/README.md index 9f641fb3841909..84f536569a32ce 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,4 @@ Zed is developed by **Zed Industries, Inc.**, a for-profit company. If you’d like to financially support the project, you can do so via GitHub Sponsors. Sponsorships go directly to Zed Industries and are used as general company revenue. There are no perks or entitlements associated with sponsorship. + diff --git a/assets/icons/ai_anthropic.svg b/assets/icons/ai_anthropic.svg index 12d731fb0b4438..91cc074db3083d 100644 --- a/assets/icons/ai_anthropic.svg +++ b/assets/icons/ai_anthropic.svg @@ -1,11 +1,4 @@ - - - - - - - - - + + diff --git a/assets/icons/ai_edit.svg b/assets/icons/ai_edit.svg index 2f93ab9fd931bc..ed19c2a5255ca8 100644 --- a/assets/icons/ai_edit.svg +++ b/assets/icons/ai_edit.svg @@ -1,10 +1,10 @@ - - - - - - - - + + + + + + + + diff --git a/assets/icons/ai_llama_cpp.svg b/assets/icons/ai_llama_cpp.svg new file mode 100644 index 00000000000000..310ec3793a5c80 --- /dev/null +++ b/assets/icons/ai_llama_cpp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/ai_open_ai_compat.svg b/assets/icons/ai_open_ai_compat.svg index f6557caac33048..825d6885ef80e5 100644 --- a/assets/icons/ai_open_ai_compat.svg +++ b/assets/icons/ai_open_ai_compat.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/archive.svg b/assets/icons/archive.svg index 9ffe3f39d27c7f..95a68f03a0f26e 100644 --- a/assets/icons/archive.svg +++ b/assets/icons/archive.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/bolt_outlined.svg b/assets/icons/bolt_outlined.svg index ca9c75fbfd64be..935f24bd2fbbd0 100644 --- a/assets/icons/bolt_outlined.svg +++ b/assets/icons/bolt_outlined.svg @@ -1,3 +1,4 @@ - + + diff --git a/assets/icons/check_circle.svg b/assets/icons/check_circle.svg deleted file mode 100644 index f9b88c4ce1451e..00000000000000 --- a/assets/icons/check_circle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/circle_check.svg b/assets/icons/circle_check.svg deleted file mode 100644 index 8950aa7a0e1126..00000000000000 --- a/assets/icons/circle_check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/copilot.svg b/assets/icons/copilot.svg index 2584cd631006c1..6dc28b671a8bb8 100644 --- a/assets/icons/copilot.svg +++ b/assets/icons/copilot.svg @@ -1,9 +1,11 @@ - - - - - - - + + + + + + + + + diff --git a/assets/icons/debug.svg b/assets/icons/debug.svg index 6423a2b090c1b8..ec84706b13f177 100644 --- a/assets/icons/debug.svg +++ b/assets/icons/debug.svg @@ -1,12 +1,13 @@ - - - - - - - - - - + + + + + + + + + + + diff --git a/assets/icons/diff_split.svg b/assets/icons/diff_split.svg index dcafeb8df5c28b..ebcd48cbb54fed 100644 --- a/assets/icons/diff_split.svg +++ b/assets/icons/diff_split.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/diff_split_auto.svg b/assets/icons/diff_split_auto.svg index f9dd7076be75aa..e5744dc56114a2 100644 --- a/assets/icons/diff_split_auto.svg +++ b/assets/icons/diff_split_auto.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/diff_unified.svg b/assets/icons/diff_unified.svg index 28735c16f68215..61cc4bc0811299 100644 --- a/assets/icons/diff_unified.svg +++ b/assets/icons/diff_unified.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/fast_forward.svg b/assets/icons/fast_forward.svg index 240bc65aca3558..f5d6cbb5052e9b 100644 --- a/assets/icons/fast_forward.svg +++ b/assets/icons/fast_forward.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/assets/icons/fast_forward_off.svg b/assets/icons/fast_forward_off.svg index 8ea7c41c6582b0..218820dd47019b 100644 --- a/assets/icons/fast_forward_off.svg +++ b/assets/icons/fast_forward_off.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/file.svg b/assets/icons/file.svg index 60cf2537d9e673..037ade6827d2c2 100644 --- a/assets/icons/file.svg +++ b/assets/icons/file.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/file_code.svg b/assets/icons/file_code.svg index 548d5a153ba243..b07f1c36ebc9a5 100644 --- a/assets/icons/file_code.svg +++ b/assets/icons/file_code.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/file_diff.svg b/assets/icons/file_diff.svg index 193dd7392ff1ff..010a68b4ec6cc9 100644 --- a/assets/icons/file_diff.svg +++ b/assets/icons/file_diff.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/file_icons/archive.svg b/assets/icons/file_icons/archive.svg index fd3780164d1fb1..95a68f03a0f26e 100644 --- a/assets/icons/file_icons/archive.svg +++ b/assets/icons/file_icons/archive.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/file_icons/folder.svg b/assets/icons/file_icons/folder.svg index e40613000da5ac..3fa7b66a8e396e 100644 --- a/assets/icons/file_icons/folder.svg +++ b/assets/icons/file_icons/folder.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/file_icons/folder_open.svg b/assets/icons/file_icons/folder_open.svg index 55231fb6abdb87..68e388212350b0 100644 --- a/assets/icons/file_icons/folder_open.svg +++ b/assets/icons/file_icons/folder_open.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/file_icons/lock.svg b/assets/icons/file_icons/lock.svg index 10ae33869a6107..4d21d5db0731dc 100644 --- a/assets/icons/file_icons/lock.svg +++ b/assets/icons/file_icons/lock.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/assets/icons/file_icons/magnifying_glass.svg b/assets/icons/file_icons/magnifying_glass.svg index d0440d905c35bc..10f84017cd9823 100644 --- a/assets/icons/file_icons/magnifying_glass.svg +++ b/assets/icons/file_icons/magnifying_glass.svg @@ -1,3 +1,4 @@ - + + diff --git a/assets/icons/file_ignored.svg b/assets/icons/file_ignored.svg new file mode 100644 index 00000000000000..e15bf125db9c59 --- /dev/null +++ b/assets/icons/file_ignored.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_multiple.svg b/assets/icons/file_multiple.svg new file mode 100644 index 00000000000000..739885eb65ed69 --- /dev/null +++ b/assets/icons/file_multiple.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_text_filled.svg b/assets/icons/file_text_filled.svg index 15c81cca620917..91a4202ffa99fa 100644 --- a/assets/icons/file_text_filled.svg +++ b/assets/icons/file_text_filled.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/file_text_outlined.svg b/assets/icons/file_text_outlined.svg index d2e8897251e31b..f602bd0492eac8 100644 --- a/assets/icons/file_text_outlined.svg +++ b/assets/icons/file_text_outlined.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/assets/icons/filter.svg b/assets/icons/filter.svg index 4aa14e93c003d0..1ec7cbdb066d33 100644 --- a/assets/icons/filter.svg +++ b/assets/icons/filter.svg @@ -1,3 +1,10 @@ - + + + + + + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg index 35f4c1f8acf679..3fa7b66a8e396e 100644 --- a/assets/icons/folder.svg +++ b/assets/icons/folder.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/folder_add.svg b/assets/icons/folder_add.svg new file mode 100644 index 00000000000000..296a1375217cd8 --- /dev/null +++ b/assets/icons/folder_add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/folder_include.svg b/assets/icons/folder_include.svg new file mode 100644 index 00000000000000..69d67aa2cc1285 --- /dev/null +++ b/assets/icons/folder_include.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/folder_open.svg b/assets/icons/folder_open.svg index 55231fb6abdb87..68e388212350b0 100644 --- a/assets/icons/folder_open.svg +++ b/assets/icons/folder_open.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/folder_open_add.svg b/assets/icons/folder_open_add.svg deleted file mode 100644 index d5ebbdaa8b0800..00000000000000 --- a/assets/icons/folder_open_add.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/folder_search.svg b/assets/icons/folder_search.svg index 207ea5c10e8239..899cb35bd22c49 100644 --- a/assets/icons/folder_search.svg +++ b/assets/icons/folder_search.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/folder_share.svg b/assets/icons/folder_share.svg index 09a232a6898896..898af2397c750e 100644 --- a/assets/icons/folder_share.svg +++ b/assets/icons/folder_share.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/folder_shared.svg b/assets/icons/folder_shared.svg index c511390305c5eb..897bef603a182a 100644 --- a/assets/icons/folder_shared.svg +++ b/assets/icons/folder_shared.svg @@ -1,5 +1,6 @@ - - - + + + + diff --git a/assets/icons/forward_arrow_up.svg b/assets/icons/forward_arrow_up.svg index b4abcb2083206f..e196d61892713a 100644 --- a/assets/icons/forward_arrow_up.svg +++ b/assets/icons/forward_arrow_up.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/generic_close.svg b/assets/icons/generic_close.svg index 0fd213daf9c81f..2326c2fcd56cfd 100644 --- a/assets/icons/generic_close.svg +++ b/assets/icons/generic_close.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/generic_maximize.svg b/assets/icons/generic_maximize.svg index f1d7da44efa954..a7db33feb62634 100644 --- a/assets/icons/generic_maximize.svg +++ b/assets/icons/generic_maximize.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/generic_minimize.svg b/assets/icons/generic_minimize.svg index 4b43cde2743e26..b39b215620f391 100644 --- a/assets/icons/generic_minimize.svg +++ b/assets/icons/generic_minimize.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/generic_restore.svg b/assets/icons/generic_restore.svg index d8a3d72bcd001a..c5cbcf3fd72513 100644 --- a/assets/icons/generic_restore.svg +++ b/assets/icons/generic_restore.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/git_worktree.svg b/assets/icons/git_worktree.svg index deb7172584fb43..af98f89591f56e 100644 --- a/assets/icons/git_worktree.svg +++ b/assets/icons/git_worktree.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/library.svg b/assets/icons/library.svg deleted file mode 100644 index fc7f5afcd2fa45..00000000000000 --- a/assets/icons/library.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/assets/icons/linux.svg b/assets/icons/linux.svg index fc76742a3f2366..9b44656c05c9f8 100644 --- a/assets/icons/linux.svg +++ b/assets/icons/linux.svg @@ -1,10 +1,15 @@ - - - + + + + + + + + - + diff --git a/assets/icons/list_filter.svg b/assets/icons/list_filter.svg deleted file mode 100644 index fc3885437da0b5..00000000000000 --- a/assets/icons/list_filter.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/lock_outlined.svg b/assets/icons/lock.svg similarity index 100% rename from assets/icons/lock_outlined.svg rename to assets/icons/lock.svg diff --git a/assets/icons/lock_off.svg b/assets/icons/lock_off.svg new file mode 100644 index 00000000000000..9efaa51aa8cf49 --- /dev/null +++ b/assets/icons/lock_off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/magnifying_glass.svg b/assets/icons/magnifying_glass.svg index 24f00bb51bccc3..10f84017cd9823 100644 --- a/assets/icons/magnifying_glass.svg +++ b/assets/icons/magnifying_glass.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/menu_alt_temp.svg b/assets/icons/menu_alt_temp.svg deleted file mode 100644 index 87add13216d9eb..00000000000000 --- a/assets/icons/menu_alt_temp.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/new_thread.svg b/assets/icons/new_thread.svg deleted file mode 100644 index 19b8fa25ea30ed..00000000000000 --- a/assets/icons/new_thread.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/on_call.svg b/assets/icons/on_call.svg new file mode 100644 index 00000000000000..2e86c5d6605701 --- /dev/null +++ b/assets/icons/on_call.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/open_folder.svg b/assets/icons/open_folder.svg deleted file mode 100644 index c4aa32b29cc104..00000000000000 --- a/assets/icons/open_folder.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/repl_off.svg b/assets/icons/repl_off.svg deleted file mode 100644 index 3018ceaf8588cd..00000000000000 --- a/assets/icons/repl_off.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/repl_pause.svg b/assets/icons/repl_pause.svg deleted file mode 100644 index 5a69a576c1152d..00000000000000 --- a/assets/icons/repl_pause.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/icons/repl_play.svg b/assets/icons/repl_play.svg deleted file mode 100644 index 0c8f4b0832ba2d..00000000000000 --- a/assets/icons/repl_play.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/assets/icons/reply_arrow_right.svg b/assets/icons/reply_arrow_right.svg index d8321e8b3eb55c..69e74c4c233a3e 100644 --- a/assets/icons/reply_arrow_right.svg +++ b/assets/icons/reply_arrow_right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/share.svg b/assets/icons/share.svg index 00d2d09b93bb85..45f0adb0ba8ecb 100644 --- a/assets/icons/share.svg +++ b/assets/icons/share.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/sliders.svg b/assets/icons/sliders.svg deleted file mode 100644 index 20a6a367dc4963..00000000000000 --- a/assets/icons/sliders.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/icons/split.svg b/assets/icons/split.svg index b2be46a875b461..f762f21bc0e78b 100644 --- a/assets/icons/split.svg +++ b/assets/icons/split.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/square_dot.svg b/assets/icons/square_dot.svg index 72b32734399a2d..a56ced308616c1 100644 --- a/assets/icons/square_dot.svg +++ b/assets/icons/square_dot.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/square_minus.svg b/assets/icons/square_minus.svg index 5ba458e8b53bf6..31b2c97d74b98f 100644 --- a/assets/icons/square_minus.svg +++ b/assets/icons/square_minus.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/square_plus.svg b/assets/icons/square_plus.svg index 063c7dbf8261d9..88ada58011f40e 100644 --- a/assets/icons/square_plus.svg +++ b/assets/icons/square_plus.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/supermaven.svg b/assets/icons/supermaven.svg deleted file mode 100644 index af778c70b71c52..00000000000000 --- a/assets/icons/supermaven.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/icons/supermaven_disabled.svg b/assets/icons/supermaven_disabled.svg deleted file mode 100644 index 25eea54cdeffa8..00000000000000 --- a/assets/icons/supermaven_disabled.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/supermaven_error.svg b/assets/icons/supermaven_error.svg deleted file mode 100644 index a0a12e17c32963..00000000000000 --- a/assets/icons/supermaven_error.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/supermaven_init.svg b/assets/icons/supermaven_init.svg deleted file mode 100644 index 6851aad49dc7e7..00000000000000 --- a/assets/icons/supermaven_init.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/assets/icons/terminal_alt.svg b/assets/icons/terminal_alt.svg index d03c05423e24fa..fb47bacd6a920d 100644 --- a/assets/icons/terminal_alt.svg +++ b/assets/icons/terminal_alt.svg @@ -1,5 +1,6 @@ - - - + + + + diff --git a/assets/icons/thinking_mode_off.svg b/assets/icons/thinking_mode_off.svg index e313950ce41303..ff74e4089e19c7 100644 --- a/assets/icons/thinking_mode_off.svg +++ b/assets/icons/thinking_mode_off.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/tool_folder.svg b/assets/icons/tool_folder.svg deleted file mode 100644 index 35f4c1f8acf679..00000000000000 --- a/assets/icons/tool_folder.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/undo.svg b/assets/icons/undo.svg index ccd45e246c6911..4c286f6fb299b3 100644 --- a/assets/icons/undo.svg +++ b/assets/icons/undo.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/user_arrow_up.svg b/assets/icons/user_arrow_up.svg new file mode 100644 index 00000000000000..8320108fa79590 --- /dev/null +++ b/assets/icons/user_arrow_up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/user_group.svg b/assets/icons/user_group.svg index 30d2e5a7eac519..4783f2492a3c57 100644 --- a/assets/icons/user_group.svg +++ b/assets/icons/user_group.svg @@ -1,5 +1,6 @@ - - - + + + + diff --git a/assets/icons/x_circle.svg b/assets/icons/x_circle.svg index 8807e5fa1fe691..6cd7912b3a7497 100644 --- a/assets/icons/x_circle.svg +++ b/assets/icons/x_circle.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/zed_agent_two.svg b/assets/icons/zed_agent_two.svg index c352be84d2f1be..9bf1affcbe1564 100644 --- a/assets/icons/zed_agent_two.svg +++ b/assets/icons/zed_agent_two.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/zed_assistant.svg b/assets/icons/zed_assistant.svg index 812277a100b7e6..8eb906b2657b05 100644 --- a/assets/icons/zed_assistant.svg +++ b/assets/icons/zed_assistant.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/images/vip_stamp.svg b/assets/images/vip_stamp.svg new file mode 100644 index 00000000000000..896a6b39cb00d6 --- /dev/null +++ b/assets/images/vip_stamp.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2220068078d28c..ffa1fc54562d46 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -34,12 +34,6 @@ "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "ctrl-shift-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f7": "debugger::StepOver", - "ctrl-f11": "debugger::StepInto", - "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RatePredictions", "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", @@ -152,6 +146,7 @@ "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", "alt-enter": "editor::OpenSelectionsInMultibuffer", + "ctrl-alt-f": "text_finder::Toggle", }, }, { @@ -319,6 +314,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-alt-pageup": "agent::ScrollOutputPageUp", "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", "ctrl-alt-home": "agent::ScrollOutputToTop", @@ -337,6 +333,7 @@ "ctrl-shift-alt-enter": "agent::SendNextQueuedMessage", "ctrl-shift-backspace": "agent::RemoveFirstQueuedMessage", "ctrl-alt-e": "agent::EditFirstQueuedMessage", + "ctrl-alt-s": "agent::ToggleSteerFirstQueuedMessage", "ctrl-alt-backspace": "agent::ClearMessageQueue", "ctrl-shift-v": "agent::PasteRaw", "ctrl-i": "agent::ToggleProfileSelector", @@ -417,6 +414,7 @@ "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", "ctrl-l": "search::ToggleSelection", + "ctrl-alt-f": "text_finder::Toggle", }, }, { @@ -451,6 +449,7 @@ "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", "alt-ctrl-x": "search::ToggleRegex", + "ctrl-alt-f": "project_search::OpenTextFinder", }, }, { @@ -480,6 +479,7 @@ "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", "alt-ctrl-x": "search::ToggleRegex", + "ctrl-alt-f": "project_search::OpenTextFinder", }, }, { @@ -625,6 +625,13 @@ { "context": "Workspace", "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `ctrl-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "ctrl-f6": "workspace::FocusNextPart", "alt-open": "projects::OpenRecent", // Change the default action on `menu::Confirm` by setting the parameter // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }], @@ -675,6 +682,7 @@ "ctrl-shift-f": "pane::DeploySearch", "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-shift-t": "pane::ReopenClosedItem", + "ctrl-k ctrl-p": "workspace::ReopenLastPicker", "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", "ctrl-k ctrl-shift-t": "theme::ToggleMode", @@ -952,6 +960,7 @@ "left": "project_panel::CollapseSelectedEntry", "ctrl-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", + "ctrl-right": "project_panel::ExpandAllEntries", "new": "project_panel::NewFile", "ctrl-n": "project_panel::NewFile", "alt-new": "project_panel::NewDirectory", @@ -1003,7 +1012,7 @@ }, }, { - "context": "GitPanel && ChangesList && !GitBranchSelector", + "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "bindings": { "left": "git_panel::CollapseSelectedEntry", "right": "git_panel::ExpandSelectedEntry", @@ -1091,6 +1100,21 @@ "alt-shift-escape": "git::ToggleFillCommitEditor", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `ctrl-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "bindings": { @@ -1157,6 +1181,10 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides.json, which is + // loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1176,30 +1204,14 @@ "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-p": "file_finder::Toggle", - "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "bindings": { - "ctrl-shift-p": "file_finder::SelectPrevious", - "ctrl-j": "pane::SplitDown", - "ctrl-k": "pane::SplitUp", - "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight", + "ctrl-shift-i": "search::ToggleIncludeIgnored", }, }, { "context": "RecentProjects || (RecentProjects > Picker > Editor)", "bindings": { "ctrl-k": "recent_projects::ToggleActionsMenu", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-shift-a": "workspace::AddFolderToProject", "shift-backspace": "recent_projects::RemoveSelected", "ctrl-shift-enter": "recent_projects::AddToWorkspace", @@ -1241,6 +1253,7 @@ "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], + "ctrl-q": ["terminal::SendKeystroke", "ctrl-q"], "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], "ctrl-r": ["terminal::SendKeystroke", "ctrl-r"], "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], @@ -1271,7 +1284,9 @@ }, { "context": "AgentPanel > Terminal", + "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-n": "agent::NewThread", }, }, @@ -1289,13 +1304,6 @@ "ctrl-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, @@ -1336,6 +1344,7 @@ "alt-down": "markdown::ScrollDownByItem", "ctrl-home": "markdown::ScrollToTop", "ctrl-end": "markdown::ScrollToBottom", + "ctrl-shift-v": "markdown::CloseAndReturnToEditor", "find": "buffer_search::Deploy", "ctrl-f": "buffer_search::Deploy", }, @@ -1527,7 +1536,8 @@ "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", "ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch", - "ctrl-shift-i": "branch_picker::FilterRemotes", + "ctrl-shift-i": "branch_picker::CycleBranchFilter", + "ctrl-k": "branch_picker::ToggleFilterMenu", }, }, { @@ -1564,6 +1574,7 @@ "bindings": { "ctrl-shift-backspace": "worktree_picker::DeleteWorktree", "ctrl-alt-shift-backspace": "worktree_picker::ForceDeleteWorktree", + "ctrl-shift-c": "zed::OpenWorktreeSetupTasks", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 9310fce07255b2..9aa17d53025fb6 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -4,12 +4,6 @@ "use_key_equivalents": true, "bindings": { "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "shift-cmd-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f7": "debugger::StepOver", - "ctrl-f11": "debugger::StepInto", - "shift-f11": "debugger::StepOut", "home": "menu::SelectFirst", "shift-pageup": "menu::SelectFirst", "pageup": "menu::SelectFirst", @@ -169,12 +163,12 @@ "cmd-shift-enter": "editor::NewlineAbove", "cmd-k z": "editor::ToggleSoftWrap", "cmd-f": "buffer_search::Deploy", - "cmd-alt-f": "buffer_search::DeployReplace", "cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }], "cmd-e": "buffer_search::UseSelectionForFind", "cmd->": "agent::AddSelectionToThread", "cmd-alt-e": "editor::SelectEnclosingSymbol", "alt-enter": "editor::OpenSelectionsInMultibuffer", + "alt-cmd-f": "text_finder::Toggle", }, }, { @@ -288,12 +282,6 @@ "alt-enter": "editor::Newline", }, }, - { - "context": "AgentConfiguration", - "bindings": { - "ctrl--": "pane::GoBack", - }, - }, { "context": "AcpThread > ModeSelector", "bindings": { @@ -363,6 +351,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "cmd-f": "agent::ToggleSearch", "ctrl-pageup": "agent::ScrollOutputPageUp", "ctrl-pagedown": "agent::ScrollOutputPageDown", "ctrl-home": "agent::ScrollOutputToTop", @@ -381,6 +370,7 @@ "cmd-shift-alt-enter": "agent::SendNextQueuedMessage", "cmd-shift-backspace": "agent::RemoveFirstQueuedMessage", "cmd-ctrl-e": "agent::EditFirstQueuedMessage", + "cmd-ctrl-s": "agent::ToggleSteerFirstQueuedMessage", "cmd-alt-backspace": "agent::ClearMessageQueue", "cmd-shift-v": "agent::PasteRaw", "cmd-i": "agent::ToggleProfileSelector", @@ -462,9 +452,9 @@ "cmd-shift-enter": "editor::ToggleFoldAll", "alt-enter": "search::SelectAllMatches", "cmd-f": "search::FocusSearch", - "cmd-alt-f": "search::ToggleReplace", "cmd-alt-l": "search::ToggleSelection", "cmd-shift-o": "outline::Toggle", + "alt-cmd-f": "text_finder::Toggle", }, }, { @@ -490,6 +480,13 @@ "down": "search::NextHistoryQuery", }, }, + { + "context": "BufferSearchBar || ProjectSearchBar", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "editor::Newline", + }, + }, { "context": "ProjectSearchBar", "use_key_equivalents": true, @@ -501,6 +498,7 @@ "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex", + "alt-cmd-f": "project_search::OpenTextFinder", }, }, { @@ -534,6 +532,7 @@ "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex", + "alt-cmd-f": "project_search::OpenTextFinder", }, }, { @@ -562,7 +561,6 @@ "alt-enter": "search::SelectAllMatches", "alt-cmd-c": "search::ToggleCaseSensitive", "alt-cmd-w": "search::ToggleWholeWord", - "alt-cmd-f": "project_search::ToggleFilters", "alt-cmd-x": "search::ToggleRegex", "cmd-k shift-enter": "pane::TogglePinTab", }, @@ -689,6 +687,13 @@ "context": "Workspace", "use_key_equivalents": true, "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `cmd-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "cmd-f6": "workspace::FocusNextPart", // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], "alt-cmd-o": "projects::OpenRecent", @@ -730,6 +735,7 @@ "cmd-shift-f": "pane::DeploySearch", "cmd-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "cmd-shift-t": "pane::ReopenClosedItem", + "cmd-k cmd-p": "workspace::ReopenLastPicker", "cmd-k cmd-s": "zed::OpenKeymap", "cmd-k cmd-t": "theme_selector::Toggle", "cmd-k cmd-shift-t": "theme::ToggleMode", @@ -1009,6 +1015,7 @@ "left": "project_panel::CollapseSelectedEntry", "cmd-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", + "cmd-right": "project_panel::ExpandAllEntries", "cmd-n": "project_panel::NewFile", "cmd-d": "project_panel::Duplicate", "alt-cmd-n": "project_panel::NewDirectory", @@ -1064,7 +1071,7 @@ }, }, { - "context": "GitPanel && ChangesList && !GitBranchSelector", + "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "up": "git_panel::PreviousEntry", @@ -1151,6 +1158,22 @@ "alt-tab": "git::GenerateCommitMessage", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `cmd-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "use_key_equivalents": true, + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "shift-cmd-f5": "debugger::RerunSession", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "bindings": { @@ -1210,6 +1233,10 @@ "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides-macos.json, which + // is loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1230,25 +1257,7 @@ "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "use_key_equivalents": true, - "bindings": { - "cmd-shift-p": "file_finder::SelectPrevious", - "cmd-j": "pane::SplitDown", - "cmd-k": "pane::SplitUp", - "cmd-h": "pane::SplitLeft", - "cmd-l": "pane::SplitRight", + "cmd-shift-i": "search::ToggleIncludeIgnored", }, }, { @@ -1342,6 +1351,7 @@ "context": "AgentPanel > Terminal", "use_key_equivalents": true, "bindings": { + "cmd-f": "agent::ToggleSearch", "cmd-n": "agent::NewThread", }, }, @@ -1381,13 +1391,6 @@ "cmd-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, @@ -1429,6 +1432,7 @@ "alt-down": "markdown::ScrollDownByItem", "cmd-up": "markdown::ScrollToTop", "cmd-down": "markdown::ScrollToBottom", + "cmd-shift-v": "markdown::CloseAndReturnToEditor", "cmd-f": "buffer_search::Deploy", }, }, @@ -1582,7 +1586,8 @@ "bindings": { "cmd-shift-backspace": "branch_picker::DeleteBranch", "cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch", - "cmd-shift-i": "branch_picker::FilterRemotes", + "cmd-shift-i": "branch_picker::CycleBranchFilter", + "cmd-k": "branch_picker::ToggleFilterMenu", }, }, { @@ -1620,6 +1625,7 @@ "bindings": { "cmd-shift-backspace": "worktree_picker::DeleteWorktree", "cmd-alt-shift-backspace": "worktree_picker::ForceDeleteWorktree", + "cmd-shift-c": "zed::OpenWorktreeSetupTasks", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1833efec322efe..b9adaf88dd359b 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -33,11 +33,6 @@ "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "ctrl-shift-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f10": "debugger::StepOver", - "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-shift-i": "edit_prediction::ToggleMenu", "shift-alt-l": "lsp_tool::ToggleMenu", @@ -147,6 +142,7 @@ "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", "alt-enter": "editor::OpenSelectionsInMultibuffer", + "ctrl-alt-f": "text_finder::Toggle", }, }, { @@ -320,6 +316,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-alt-pageup": "agent::ScrollOutputPageUp", "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", "ctrl-alt-home": "agent::ScrollOutputToTop", @@ -338,6 +335,7 @@ "ctrl-shift-alt-enter": "agent::SendNextQueuedMessage", "ctrl-shift-backspace": "agent::RemoveFirstQueuedMessage", "ctrl-alt-e": "agent::EditFirstQueuedMessage", + "ctrl-alt-s": "agent::ToggleSteerFirstQueuedMessage", "ctrl-alt-backspace": "agent::ClearMessageQueue", "ctrl-shift-v": "agent::PasteRaw", "ctrl-i": "agent::ToggleProfileSelector", @@ -419,6 +417,7 @@ "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", "ctrl-l": "search::ToggleSelection", + "ctrl-alt-f": "text_finder::Toggle", }, }, { @@ -452,6 +451,7 @@ "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-r": "search::ToggleRegex", // vscode + "ctrl-alt-f": "project_search::OpenTextFinder", }, }, { @@ -482,6 +482,7 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-r": "search::ToggleRegex", // vscode + "ctrl-alt-f": "project_search::OpenTextFinder", }, }, { @@ -623,6 +624,13 @@ "context": "Workspace", "use_key_equivalents": true, "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `ctrl-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "ctrl-f6": "workspace::FocusNextPart", // Change the default action on `menu::Confirm` by setting the parameter // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }], "ctrl-r": "projects::OpenRecent", @@ -663,6 +671,7 @@ "ctrl-shift-f": "pane::DeploySearch", "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-shift-t": "pane::ReopenClosedItem", + "ctrl-k ctrl-p": "workspace::ReopenLastPicker", "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", "ctrl-k ctrl-shift-t": "theme::ToggleMode", @@ -953,6 +962,7 @@ "left": "project_panel::CollapseSelectedEntry", "ctrl-left": "project_panel::CollapseAllEntries", "right": "project_panel::ExpandSelectedEntry", + "ctrl-right": "project_panel::ExpandAllEntries", "ctrl-n": "project_panel::NewFile", "alt-n": "project_panel::NewDirectory", "ctrl-x": "project_panel::Cut", @@ -995,7 +1005,7 @@ }, }, { - "context": "GitPanel && ChangesList && !GitBranchSelector", + "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "up": "git_panel::PreviousEntry", @@ -1088,6 +1098,21 @@ "alt-shift-escape": "git::ToggleFillCommitEditor", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `ctrl-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "use_key_equivalents": true, + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f10": "debugger::StepOver", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "use_key_equivalents": true, @@ -1162,6 +1187,10 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides.json, which is + // loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1183,25 +1212,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-p": "file_finder::Toggle", - "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-p": "file_finder::SelectPrevious", - "ctrl-j": "pane::SplitDown", - "ctrl-k": "pane::SplitUp", - "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight", + "ctrl-shift-i": "search::ToggleIncludeIgnored", }, }, { @@ -1209,6 +1220,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-k": "recent_projects::ToggleActionsMenu", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-shift-a": "workspace::AddFolderToProject", "shift-backspace": "recent_projects::RemoveSelected", "ctrl-shift-enter": "recent_projects::AddToWorkspace", @@ -1287,6 +1299,7 @@ "context": "AgentPanel > Terminal", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-n": "agent::NewThread", }, }, @@ -1312,13 +1325,6 @@ "ctrl-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, @@ -1361,6 +1367,7 @@ "alt-down": "markdown::ScrollDownByItem", "ctrl-home": "markdown::ScrollToTop", "ctrl-end": "markdown::ScrollToBottom", + "ctrl-shift-v": "markdown::CloseAndReturnToEditor", "find": "buffer_search::Deploy", "ctrl-f": "buffer_search::Deploy", }, @@ -1508,7 +1515,8 @@ "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", "ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch", - "ctrl-shift-i": "branch_picker::FilterRemotes", + "ctrl-shift-i": "branch_picker::CycleBranchFilter", + "ctrl-k": "branch_picker::ToggleFilterMenu", }, }, { @@ -1545,6 +1553,7 @@ "bindings": { "ctrl-shift-backspace": "worktree_picker::DeleteWorktree", "ctrl-alt-shift-backspace": "worktree_picker::ForceDeleteWorktree", + "ctrl-shift-,": "zed::OpenWorktreeSetupTasks", }, }, { diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 5b6f841de07ac2..68df92d2841a59 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -17,8 +17,11 @@ "bindings": { "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 + // `null` only stops binding resolution in a user keymap, so these use a targeted unbind: + // `editor::MoveUp`/`MoveDown` propagate when the cursor doesn't move, which at the ends of + // the buffer would otherwise fall through to these. + "ctrl-p": ["zed::Unbind", "file_finder::Toggle"], + "ctrl-n": ["zed::Unbind", "workspace::NewFile"], }, }, { diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index de4d538d40d3ba..889f89def325a3 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -190,6 +190,17 @@ { "context": "DebugPanel", "bindings": { "alt-5": "workspace::CloseActiveDock" } }, { "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } }, { "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } }, + { + // `ctrl-alt-l` is bound to `editor::Format` (jetbrains "Reformat Code") in + // the Editor context above, which collides with the default + // `agent::OpenRulesLibrary` binding. Mirror the windows keymap and use + // `shift-alt-l` instead so the menu hint and the action stay in sync. + "context": "AgentPanel", + "bindings": { + "ctrl-alt-l": null, + "shift-alt-l": "agent::OpenRulesLibrary", + }, + }, { "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel", "bindings": { diff --git a/assets/keymaps/linux/vscode.json b/assets/keymaps/linux/vscode.json new file mode 100644 index 00000000000000..a333e19ff9acf3 --- /dev/null +++ b/assets/keymaps/linux/vscode.json @@ -0,0 +1,72 @@ +[ + // VS Code for Linux (and Windows). See: https://code.visualstudio.com/docs/reference/default-keybindings + // + // Zed's default keymap is close to VS Code's, so this overlay only contains + // the bindings where the two diverge. + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "shift-alt-f": "editor::Format", // Format Document (Windows-style; Linux `ctrl-shift-i` is a Zed default already) + "ctrl-k ctrl-f": "editor::FormatSelections", // Format Selection + "shift-alt-i": "editor::SplitSelectionIntoLines", // Insert cursor at end of each line selected + "alt-z": "editor::ToggleSoftWrap", // Toggle Word Wrap + "ctrl-shift-space": "editor::ShowSignatureHelp", // Trigger Parameter Hints + "ctrl-shift-a": "editor::ToggleBlockComments", // Toggle Block Comment + "ctrl-k f12": "editor::GoToDefinitionSplit", // Open Definition to the Side + }, + }, + { + "context": "Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + // In VS Code `ctrl-k z` is Zen Mode; the word wrap toggle moves to `alt-z`. + "ctrl-k z": "workspace::ToggleCenteredLayout", + "ctrl-i": "assistant::InlineAssist", // Inline Chat + }, + }, + { + "context": "!AcpThread > Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "editor::NewlineBelow", // Insert Line Below (Inline Chat lives on `ctrl-i`) + }, + }, + { + "context": "Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": null, + }, + }, + { + "context": "Workspace", + "use_key_equivalents": true, + "bindings": { + "f5": "debugger::Start", // Start Debugging + "ctrl-alt-i": "agent::ToggleFocus", // Open Chat + }, + }, + { + "context": "Workspace && debugger_running", + "use_key_equivalents": true, + "bindings": { + "f5": null, + }, + }, + { + "context": "Workspace && debugger_stopped", + "use_key_equivalents": true, + "bindings": { + "f5": "debugger::Continue", + }, + }, + { + "context": "Workspace && debugger_session", + "use_key_equivalents": true, + "bindings": { + "f10": "debugger::StepOver", + "f11": "debugger::StepInto", + }, + }, +] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 291d7d5e7a8303..9df47fe2412054 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -194,6 +194,17 @@ { "context": "DebugPanel", "bindings": { "cmd-5": "workspace::CloseActiveDock" } }, { "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } }, { "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } }, + { + // `cmd-alt-l` is bound to `editor::Format` (jetbrains "Reformat Code") in + // the Editor context above, which collides with the default + // `agent::OpenRulesLibrary` binding. Mirror the windows keymap and use + // `shift-alt-l` instead so the menu hint and the action stay in sync. + "context": "AgentPanel", + "bindings": { + "cmd-alt-l": null, + "shift-alt-l": "agent::OpenRulesLibrary", + }, + }, { "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel", "bindings": { diff --git a/assets/keymaps/macos/vscode.json b/assets/keymaps/macos/vscode.json new file mode 100644 index 00000000000000..532671e5080d50 --- /dev/null +++ b/assets/keymaps/macos/vscode.json @@ -0,0 +1,82 @@ +[ + // VS Code for macOS. See: https://code.visualstudio.com/docs/reference/default-keybindings + // + // Zed's default keymap is close to VS Code's, so this overlay only contains + // the bindings where the two diverge. + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "shift-alt-f": "editor::Format", // Format Document + "cmd-k cmd-f": "editor::FormatSelections", // Format Selection + "shift-alt-i": "editor::SplitSelectionIntoLines", // Insert cursor at end of each line selected + "alt-z": "editor::ToggleSoftWrap", // Toggle Word Wrap + "cmd-shift-space": "editor::ShowSignatureHelp", // Trigger Parameter Hints + "ctrl-shift-cmd-right": "editor::SelectLargerSyntaxNode", // Expand AST Selection + "ctrl-shift-cmd-left": "editor::SelectSmallerSyntaxNode", // Shrink AST Selection + "cmd-k cmd-c": ["editor::ToggleComments", { "advance_downwards": false }], // Add Line Comment (closest analog) + "cmd-k f12": "editor::GoToDefinitionSplit", // Open Definition to the Side + }, + }, + { + "context": "Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + // In VS Code `cmd-k z` is Zen Mode; the word wrap toggle moves to `alt-z`. + "cmd-k z": "workspace::ToggleCenteredLayout", + "cmd-i": "assistant::InlineAssist", // Inline Chat + }, + }, + { + "context": "!AcpThread > Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": null, // Inline Chat lives on `cmd-i` in VS Code + }, + }, + { + "context": "Terminal", + "use_key_equivalents": true, + "bindings": { + "cmd-i": "assistant::InlineAssist", // Terminal Inline Chat + "ctrl-enter": null, + }, + }, + { + "context": "BufferSearchBar || ProjectSearchBar", + "use_key_equivalents": true, + "bindings": { + "alt-cmd-r": "search::ToggleRegex", // Toggle Find Regex + }, + }, + { + "context": "Workspace", + "use_key_equivalents": true, + "bindings": { + "f5": "debugger::Start", // Start Debugging + "ctrl-cmd-i": "agent::ToggleFocus", // Open Chat + }, + }, + { + "context": "Workspace && debugger_running", + "use_key_equivalents": true, + "bindings": { + "f5": null, + }, + }, + { + "context": "Workspace && debugger_stopped", + "use_key_equivalents": true, + "bindings": { + "f5": "debugger::Continue", + }, + }, + { + "context": "Workspace && debugger_session", + "use_key_equivalents": true, + "bindings": { + "f10": "debugger::StepOver", + "f11": "debugger::StepInto", + }, + }, +] diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json new file mode 100644 index 00000000000000..68cfb32f9b047c --- /dev/null +++ b/assets/keymaps/specific-overrides-macos.json @@ -0,0 +1,54 @@ +// Put keybindings bound to a tight specific context that need to overwrite the +// base keymaps here. Only put them here if absolutely needed. In those cases +// also add a comment to the default keymaps that this binding exists here, to +// make it a little more discoverable. +// +// This is loaded after all the base keymaps (Atom, vim etc) but before the user +// keymaps, giving it the highest precedence of all the bindings set by us. +[ + { + "context": "Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-a": "picker::ToggleActionsMenu", + "cmd-shift-s": "picker::ToggleMultiSelect", + }, + }, + { + "context": "(Picker && with_preview) > Editor", + "use_key_equivalents": true, + "bindings": { + "cmd-alt-p": "picker::TogglePreview", + "cmd-alt-right": "picker::SetPreviewRight", + "cmd-alt-down": "picker::SetPreviewBelow", + "cmd-alt-up": "picker::SetPreviewHidden", + }, + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-p": "file_finder::SelectPrevious", + "tab": "picker::MultiSelectNext", + "cmd-j": "pane::SplitDown", + "cmd-k": "pane::SplitUp", + "cmd-h": "pane::SplitLeft", + "cmd-l": "pane::SplitRight", + }, + }, + { + "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "alt-cmd-f": "text_finder::ToProjectSearch", + "tab": "picker::MultiSelectNext", + "alt-cmd-[": "text_finder::Fold", + "alt-cmd-]": "text_finder::Unfold", + "cmd-shift-enter": "text_finder::ToggleFoldAll", + "cmd-j": "pane::SplitDown", + "cmd-k": "pane::SplitUp", + "cmd-h": "pane::SplitLeft", + "cmd-l": "pane::SplitRight", + }, + }, +] diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json new file mode 100644 index 00000000000000..494fd8d59d06e2 --- /dev/null +++ b/assets/keymaps/specific-overrides.json @@ -0,0 +1,50 @@ +// Put keybindings bound to a tight specific context that need to overwrite the +// base keymaps here. Only put them here if absolutely needed. In those cases +// also add a comment to the default keymaps that this binding exists here, to +// make it a little more discoverable. +// +// This is loaded after all the base keymaps (Atom, vim etc) but before the user +// keymaps, giving it the highest precedence of all the bindings set by us. +[ + { + "context": "Picker > Editor", + "bindings": { + "ctrl-shift-a": "picker::ToggleActionsMenu", + "ctrl-shift-s": "picker::ToggleMultiSelect", + }, + }, + { + "context": "(Picker && with_preview) > Editor", + "bindings": { + "ctrl-alt-p": "picker::TogglePreview", + "ctrl-alt-right": "picker::SetPreviewRight", + "ctrl-alt-down": "picker::SetPreviewBelow", + "ctrl-alt-up": "picker::SetPreviewHidden", + }, + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "bindings": { + "ctrl-shift-p": "file_finder::SelectPrevious", + "tab": "picker::MultiSelectNext", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight", + }, + }, + { + "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", + "bindings": { + "ctrl-alt-f": "text_finder::ToProjectSearch", + "tab": "picker::MultiSelectNext", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight", + "ctrl-{": "text_finder::Fold", + "ctrl-}": "text_finder::Unfold", + "ctrl-shift-enter": "text_finder::ToggleFoldAll", + }, + }, +] diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json deleted file mode 100644 index 432bdc7004a4c6..00000000000000 --- a/assets/keymaps/storybook.json +++ /dev/null @@ -1,33 +0,0 @@ -[ - // Standard macOS bindings - { - "bindings": { - "home": "menu::SelectFirst", - "shift-pageup": "menu::SelectFirst", - "pageup": "menu::SelectFirst", - "cmd-up": "menu::SelectFirst", - "end": "menu::SelectLast", - "shift-pagedown": "menu::SelectLast", - "pagedown": "menu::SelectLast", - "cmd-down": "menu::SelectLast", - "tab": "menu::SelectNext", - "ctrl-n": "menu::SelectNext", - "down": "menu::SelectNext", - "shift-tab": "menu::SelectPrevious", - "ctrl-p": "menu::SelectPrevious", - "up": "menu::SelectPrevious", - "enter": "menu::Confirm", - "ctrl-enter": "menu::SecondaryConfirm", - "cmd-enter": "menu::SecondaryConfirm", - "ctrl-escape": "menu::Cancel", - "cmd-escape": "menu::Cancel", - "ctrl-c": "menu::Cancel", - "escape": "menu::Cancel", - "cmd-q": "storybook::Quit", - "backspace": "editor::Backspace", - "delete": "editor::Delete", - "left": "editor::MoveLeft", - "right": "editor::MoveRight", - }, - }, -] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index ef41401868168f..3acbbc73dadba5 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -454,6 +454,8 @@ "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }], "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }], "alt-.": "vim::RepeatFind", + "alt-b": "editor::MoveToStartOfLargerSyntaxNode", + "alt-e": "editor::MoveToEndOfLargerSyntaxNode", // Changes "shift-r": "editor::Paste", @@ -483,7 +485,9 @@ "alt-shift-c": "vim::HelixDuplicateAbove", "%": "editor::SelectAll", "x": "vim::HelixSelectLine", + "*": "buffer_search::UseSelectionForFind", "shift-x": "editor::SelectLine", + "_": "vim::HelixTrimSelections", "ctrl-c": "editor::ToggleComments", "alt-o": "editor::SelectLargerSyntaxNode", "alt-i": "editor::SelectSmallerSyntaxNode", @@ -547,6 +551,21 @@ "space y": "editor::Copy", "space /": "pane::DeploySearch", + // View mode + "z c": "editor::ScrollCursorCenter", + + // Debug mode (Helix space G) + "space shift-g l": "debugger::Start", + "space shift-g r": "debugger::Restart", + "space shift-g b": "editor::ToggleBreakpoint", + "space shift-g c": "debugger::Continue", + "space shift-g h": "debugger::Pause", + "space shift-g i": "debugger::StepInto", + "space shift-g o": "debugger::StepOut", + "space shift-g n": "debugger::StepOver", + "space shift-g t": "debugger::Stop", + "space shift-g ctrl-l": "editor::EditLogBreakpoint", + // Other ":": "command_palette::Toggle", "m": "vim::PushHelixMatch", @@ -676,7 +695,8 @@ "shift-b": "pane::ActivateLastItem", "x": "editor::SelectSmallerSyntaxNode", "d": "editor::GoToDiagnostic", - "c": "editor::GoToHunk", + "c": "vim::NextComment", + "g": "editor::GoToHunk", "space": "vim::InsertEmptyLineBelow", }, }, @@ -694,7 +714,8 @@ "shift-b": ["pane::ActivateItem", 0], "x": "editor::SelectLargerSyntaxNode", "d": "editor::GoToPreviousDiagnostic", - "c": "editor::GoToPreviousHunk", + "c": "vim::PreviousComment", + "g": "editor::GoToPreviousHunk", "space": "vim::InsertEmptyLineAbove", }, }, @@ -990,6 +1011,7 @@ "ctrl-d": "project_panel::ScrollDown", "z t": "project_panel::ScrollCursorTop", "z z": "project_panel::ScrollCursorCenter", + "z c": "project_panel::ScrollCursorCenter", "z b": "project_panel::ScrollCursorBottom", "0": ["vim::Number", 0], "1": ["vim::Number", 1], @@ -1021,6 +1043,7 @@ "ctrl-d": "outline_panel::ScrollDown", "z t": "outline_panel::ScrollCursorTop", "z z": "outline_panel::ScrollCursorCenter", + "z c": "outline_panel::ScrollCursorCenter", "z b": "outline_panel::ScrollCursorBottom", "0": ["vim::Number", 0], "1": ["vim::Number", 1], @@ -1066,7 +1089,7 @@ }, }, { - "context": "GitPanel && ChangesList && !GitBranchSelector", + "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "k": "menu::SelectPrevious", @@ -1110,6 +1133,13 @@ "escape": "menu::Cancel", }, }, + { + "context": "CommitEditor > Editor && VimControl && !menu", + "bindings": { + "tab": "git_panel::FocusChanges", + "shift-tab": "git_panel::FocusChanges", + }, + }, { "context": "Editor && edit_prediction && edit_prediction_mode == eager && !showing_completions", "bindings": { diff --git a/assets/settings/default.json b/assets/settings/default.json index b51f9c0de29a01..a0f4f066cc45b1 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -13,16 +13,18 @@ }, "icon_theme": "Zed (Default)", // The name of a base set of key bindings to use. - // This setting can take six values, each named after another - // text editor: + // This setting can take the following values: // - // 1. "VSCode" - // 2. "Atom" - // 3. "JetBrains" - // 4. "None" + // 1. "Zed" + // 2. "VSCode" + // 3. "Atom" + // 4. "JetBrains" // 5. "SublimeText" // 6. "TextMate" - "base_keymap": "VSCode", + // 7. "Emacs" + // 8. "Cursor" + // 9. "None" + "base_keymap": "Zed", // The name of a font to use for rendering text in the editor // ".ZedMono" currently aliases to Lilex // but this may change in the future. @@ -67,12 +69,22 @@ "ui_font_weight": 400, // The default font size for text in the UI "ui_font_size": 16, + // The font family for agent responses in the agent panel. Falls back to the UI font family if unset. + "agent_ui_font_family": null, // The default font size for agent responses in the agent panel. Falls back to the UI font size if unset. "agent_ui_font_size": null, + // The font family for user messages in the agent panel. Falls back to the buffer font family if unset. + "agent_buffer_font_family": null, // The default font size for user messages in the agent panel. "agent_buffer_font_size": 12, // The default font size for the commit editor in the git panel and commit modal. "git_commit_buffer_font_size": 12, + // The default font size for the markdown preview. Falls back to the UI font size if unset. + "markdown_preview_font_size": null, + // The font family for the markdown preview. Falls back to the UI font family if unset. + "markdown_preview_font_family": null, + // The font family for code blocks in the markdown preview. Falls back to the editor font family if unset. + "markdown_preview_code_font_family": null, // How much to fade out unused code. "unnecessary_code_fade": 0.3, // Active pane styling settings. @@ -156,8 +168,7 @@ // May take 2 values: // 1. Open directories as a new workspace in the current Zed window's sidebar // "cli_default_open_behavior": "existing_window" - // 2. Open directories in a new window (reuse existing windows for files - // that are already part of an open project) + // 2. Open paths in a new window, unless they are subpaths of an existing project // "cli_default_open_behavior": "new_window" "cli_default_open_behavior": "existing_window", // The default behavior when opening projects from the UI. @@ -228,6 +239,9 @@ // // Default: "client" "window_decorations": "client", + // Whether to optimize Zed's interface for assistive technology such as screen + // readers. + "accessible_mode": false, // Whether to use the system provided dialogs for Open and Save As. // When set to false, Zed will use the built-in keyboard-first pickers. "use_system_path_prompts": true, @@ -260,6 +274,15 @@ // 3. Hide on typing and on key bindings that resolve to an action: // "on_typing_and_action" "hide_mouse": "on_typing_and_action", + // Whether to reduce non-essential motion in the UI, such as loading + // spinners and pulsating labels, by rendering them in a static state. + // + // May take 2 values: + // 1. Always reduce motion: + // "on" + // 2. Never reduce motion: + // "off" + "reduce_motion": "off", // Determines whether the focused panel follows the mouse location. "focus_follows_mouse": { "enabled": false, @@ -393,6 +416,14 @@ // 4. Preserve the cursor's vertical position within the viewport, falling back to `center` when the cursor is // offscreen: `preserve` "go_to_definition_scroll_strategy": "center", + // Where to show LSP results that can contain multiple locations + // (Go to Definition, Go to Implementation, Find All References). A single + // result always opens directly. Individual actions can override this with + // their `open_results_in` argument. + // + // 1. Open the results in a multibuffer: `multi_buffer` (default) + // 2. Open the results in a filterable picker: `picker` + "lsp_results_location": "multi_buffer", // Which level to use to filter out diagnostics displayed in the editor. // // Affects the editor rendering only, and does not interrupt @@ -523,6 +554,8 @@ "show_branch_status_icon": false, // Whether to show the branch name button in the titlebar. "show_branch_name": true, + // Whether to show the worktree name button in the titlebar. + "show_worktree_name": true, // Whether to show the project host and name in the titlebar. "show_project_items": true, // Whether to show onboarding banners in the titlebar. @@ -974,10 +1007,14 @@ // // Default: main "fallback_branch_name": "main", - // Whether to sort entries in the panel by path or by status (the default). + // How to sort entries in the git panel. // - // Default: false - "sort_by_path": false, + // Default: path + "sort_by": "path", + // How to group entries in the git panel. + // + // Default: status + "group_by": "status", // Whether to collapse untracked files in the diff panel. // // Default: false @@ -1010,15 +1047,18 @@ // // Default: 0 "commit_title_max_length": 0, - }, - "message_editor": { - // Whether to automatically replace emoji shortcodes with emoji characters. - // For example: typing `:wave:` gets replaced with `👋`. - "auto_replace_emoji_shortcode": true, + // Default action when clicking a changed file in the Git panel. + // + // Choices: project_diff, file_diff, view_file + // Default: project_diff + "entry_primary_click_action": "project_diff", }, "agent": { // Whether the inline assistant should use streaming tools, when available "inline_assistant_use_streaming_tools": true, + // Whether to include project rules files (AGENTS.md, CLAUDE.md, .rules, etc.) + // in the prompt when generating git commit messages. + "commit_message_include_project_rules": true, // Whether the agent is enabled. "enabled": true, // Whether to show the agent panel button in the status bar. @@ -1454,9 +1494,15 @@ // The EditorConfig `end_of_line` property overrides this setting and behaves // like `enforce_lf` or `enforce_crlf`. "line_ending": "detect", - // Whether or not to perform a buffer format before saving: [on, off] + // Whether or not to perform a buffer format before saving: + // "on" — format the whole buffer + // "off" — do not format + // "modifications" — format only lines with unstaged changes; skips formatting + // when no git diff is available or the language server lacks range formatting + // "modifications_if_available" — same, but falls back to formatting the whole + // buffer when range formatting cannot be used // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored - "format_on_save": "on", + "format_on_save": "off", // How to perform a buffer format. This setting can take multiple values: // // 1. Default. Format files using Zed's Prettier integration (if applicable), @@ -1636,13 +1682,15 @@ /// /// Default: 0 "gutter_debounce": 0, - // Control whether the git blame information is shown inline, - // in the currently focused line. + // Control whether the git blame information is shown for the currently + // focused line, and where it is rendered. "inline_blame": { "enabled": true, // Sets a delay after which the inline blame information is shown. // Delay is restarted with every cursor movement. "delay_ms": 0, + // Where to render the blame information when it is enabled. + "location": "inline", // The amount of padding between the end of the source line and the start // of the inline blame in units of em widths. "padding": 7, @@ -1658,6 +1706,10 @@ "branch_picker": { "show_author_name": true, }, + "file_diff": { + // Whether newly opened file diffs show the full file instead of changes only. + "show_full_file": true, + }, // How git hunks are displayed visually in the editor. // This setting can take two values: // @@ -1891,6 +1943,12 @@ "copy_on_select": false, // Whether to keep the text selection after copying it to the clipboard. "keep_selection_on_copy": true, + // Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even + // when the terminal application has enabled mouse reporting (e.g. vim with + // mouse=a, htop). When false, these clicks are forwarded to the application + // instead, and hyperlinks can still be opened with shift-cmd-click + // (shift-ctrl-click). + "open_links_in_mouse_mode": true, // Whether to show the terminal button in the status bar "button": true, // Any key-value pairs added to this list will be added to the terminal's @@ -2161,6 +2219,7 @@ // Different settings for specific languages. "languages": { "Astro": { + "format_on_save": "on", "language_servers": ["astro-language-server", "..."], "prettier": { "allowed": true, @@ -2173,14 +2232,12 @@ }, }, "C": { - "format_on_save": "off", "use_on_type_format": false, "prettier": { "allowed": false, }, }, "C++": { - "format_on_save": "off", "use_on_type_format": false, "prettier": { "allowed": false, @@ -2195,6 +2252,7 @@ }, }, "Dart": { + "format_on_save": "on", "tab_size": 2, }, "Diff": { @@ -2203,12 +2261,15 @@ "ensure_final_newline_on_save": false, }, "EEx": { + "format_on_save": "on", "language_servers": ["elixir-ls", "!expert", "!dexter", "!next-ls", "!lexical", "..."], }, "Elixir": { + "format_on_save": "on", "language_servers": ["elixir-ls", "!expert", "!dexter", "!next-ls", "!lexical", "!emmet-language-server", "..."], }, "Elm": { + "format_on_save": "on", "tab_size": 4, }, "Erlang": { @@ -2220,6 +2281,7 @@ "preferred_line_length": 72, }, "Go": { + "format_on_save": "on", "hard_tabs": true, "code_actions_on_format": { "source.organizeImports": true, @@ -2227,11 +2289,13 @@ "debuggers": ["Delve"], }, "GraphQL": { + "format_on_save": "on", "prettier": { "allowed": true, }, }, "HEEx": { + "format_on_save": "on", "language_servers": ["elixir-ls", "!expert", "!dexter", "!next-ls", "!lexical", "..."], }, "HTML": { @@ -2268,6 +2332,7 @@ "language_servers": ["!ruby-lsp", "..."], }, "Kotlin": { + "format_on_save": "on", "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."], }, "LaTeX": { @@ -2279,7 +2344,6 @@ }, }, "Markdown": { - "format_on_save": "off", "use_on_type_format": false, "remove_trailing_whitespace_on_save": false, "allow_rewrap": "anywhere", @@ -2292,7 +2356,7 @@ }, }, "PHP": { - "language_servers": ["phpactor", "!intelephense", "!phptools", "..."], + "language_servers": ["phpactor", "!intelephense", "!phptools", "!phpantom", "..."], "prettier": { "allowed": true, "plugins": ["@prettier/plugin-php"], @@ -2334,6 +2398,7 @@ ], }, "Rust": { + "format_on_save": "on", "debuggers": ["CodeLLDB"], }, "SCSS": { @@ -2342,6 +2407,7 @@ }, }, "Starlark": { + "format_on_save": "on", "language_servers": ["starpls", "!buck2-lsp", "!tilt", "..."], }, "Svelte": { @@ -2369,7 +2435,6 @@ }, }, "SystemVerilog": { - "format_on_save": "off", "language_servers": ["!slang", "..."], "use_on_type_format": false, }, @@ -2394,6 +2459,7 @@ "language_servers": ["!ruby-lsp", "..."], }, "Zig": { + "format_on_save": "on", "language_servers": ["zls", "..."], }, }, @@ -2410,6 +2476,9 @@ "ollama": { "api_url": "http://localhost:11434", }, + "llama.cpp": { + "api_url": "http://localhost:8080", + }, "openai": { "api_url": "https://api.openai.com/v1", }, @@ -2506,6 +2575,11 @@ // // Default: 120 "request_timeout": 120, + // The maximum line length a buffer may contain before language server features are disabled + // for the entire buffer. + // + // Default: 20000 + "max_buffer_line_length": 20000, "notifications": { // Timeout in milliseconds for automatically dismissing language server notifications. // Set to 0 to disable auto-dismiss. @@ -2638,7 +2712,7 @@ "windows": { "languages": { "PHP": { - "language_servers": ["intelephense", "!phpactor", "!phptools", "..."], + "language_servers": ["intelephense", "!phpactor", "!phptools", "!phpantom", "..."], }, }, }, diff --git a/assets/settings/default_semantic_token_rules.json b/assets/settings/default_semantic_token_rules.json index c070a253d3065f..354fb431ebb30e 100644 --- a/assets/settings/default_semantic_token_rules.json +++ b/assets/settings/default_semantic_token_rules.json @@ -119,20 +119,10 @@ "style": ["type"], }, // References - { - "token_type": "parameter", - "token_modifiers": ["declaration"], - "style": ["variable.parameter"] - }, - { - "token_type": "parameter", - "token_modifiers": ["definition"], - "style": ["variable.parameter"] - }, { "token_type": "parameter", "token_modifiers": [], - "style": ["variable"], + "style": ["variable.parameter"], }, { "token_type": "variable", diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index bb6c9c04ae14db..416383e0f7eaac 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -55,5 +55,8 @@ "save": "none", // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] + // Hooks that cause this task to run automatically on certain events: + // * `create_worktree` — run this task after creating a new git worktree (e.g. to install dependencies or copy untracked config files into it) + // "hooks": ["create_worktree"] }, ] diff --git a/assets/settings/initial_worktree_setup_tasks.json b/assets/settings/initial_worktree_setup_tasks.json new file mode 100644 index 00000000000000..5a3c5a45078ce5 --- /dev/null +++ b/assets/settings/initial_worktree_setup_tasks.json @@ -0,0 +1,23 @@ +// Worktree setup tasks, stored in this project's tasks.json. +// See https://zed.dev/docs/tasks#hooks for the full Tasks Hooks documentation. +// +// Tasks with the `create_worktree` hook run automatically right after Zed +// creates a new git worktree. Use them to get a fresh worktree ready to work +// in — for example, installing dependencies or copying files that git doesn't +// track (like `.env`) from the original repository. +// +// Two variables are available to these tasks: +// * $ZED_WORKTREE_ROOT — the root directory of the newly created worktree +// * $ZED_MAIN_GIT_WORKTREE — the original repository's working directory +// +// Uncomment the example below and adjust the command to get started. +// Note: hooked tasks run as soon as a worktree is created, so only enable +// commands you want to run automatically. +[ + // { + // "label": "Set up new worktree", + // "command": "cp -n \"$ZED_MAIN_GIT_WORKTREE/.env\" \"$ZED_WORKTREE_ROOT/\" && npm install", + // "cwd": "$ZED_WORKTREE_ROOT", + // "hooks": ["create_worktree"] + // } +] diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index f27566c4f72cac..ee5073e7a44f78 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -387,6 +387,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d2a6ffff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#5ac1feff", "font_style": null, @@ -789,6 +794,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#a37accff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#3b9ee5ff", "font_style": null, @@ -1191,6 +1201,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#dfbfffff", + "font_style": null, + "font_weight": null + }, "variant": { "color": "#72cffeff", "font_style": null, diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 4330df54fccae5..75252f781dadc6 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -397,6 +397,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -814,6 +819,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -1231,6 +1241,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#83a598ff", "font_style": null, @@ -1648,6 +1663,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, @@ -2065,6 +2085,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, @@ -2482,6 +2507,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#076678ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#066578ff", "font_style": null, diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index b6b4dfd0c6357a..698b8febceaf53 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -394,6 +394,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d07277ff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#bf956aff", "font_style": null, @@ -806,6 +811,11 @@ "font_style": null, "font_weight": null }, + "variable.parameter": { + "color": "#d3604fff", + "font_style": null, + "font_weight": null + }, "variable.special": { "color": "#ad6e25ff", "font_style": null, diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index ebdd6aa9795b61..3c4f592a554e79 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -35,6 +35,7 @@ language.workspace = true language_model.workspace = true log.workspace = true markdown.workspace = true +mime.workspace = true parking_lot = { workspace = true, optional = true } image.workspace = true portable-pty.workspace = true @@ -61,5 +62,6 @@ indoc.workspace = true parking_lot.workspace = true project = { workspace = true, "features" = ["test-support"] } rand.workspace = true +tempfile.workspace = true util.workspace = true settings.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 9e8e6de55b39f8..5b2130954e251b 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2,8 +2,9 @@ mod connection; mod diff; mod mention; mod terminal; +pub use ::terminal::HeadlessTerminal; use action_log::{ActionLog, ActionLogTelemetry}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::{MaybeUndefined, v1 as acp}; use anyhow::{Context as _, Result, anyhow}; use collections::HashSet; pub use connection::*; @@ -175,12 +176,18 @@ pub struct SandboxAuthorizationDetails { /// builds still render the network request. #[serde(default, alias = "network")] pub network_all_hosts: bool, + #[serde(default)] pub allow_fs_write_all: bool, #[serde(default)] pub unsandboxed: bool, #[serde(default)] - pub write_paths: Vec, + pub write_paths: Vec, + /// Windows/WSL only: the command will write to a path on a Windows drive + /// (DrvFs), whose sandbox-integrity guarantees are weaker. Drives the + /// weaker-guarantee warning banner in the approval prompt. + #[serde(default)] + pub warn_windows_fs: bool, /// The agent-provided justification for requesting these permissions, /// shown to the user (attributed to the agent) in the approval prompt. #[serde(default)] @@ -222,6 +229,11 @@ pub struct SandboxFallbackAuthorizationDetails { /// whether to run the command without a sandbox. #[serde(default)] pub reason: String, + /// Slug of the sandboxing docs section that best explains how to fix this + /// failure (see [`crate::LinuxWslSandboxError::docs_section`]), rendered as a + /// "Learn more" link. `None` when the cause is unknown. + #[serde(default)] + pub docs_section: Option, } pub fn meta_with_sandbox_fallback_authorization( @@ -280,7 +292,9 @@ pub fn subagent_session_info_from_meta(meta: &Option) -> Option, + pub protocol_id: Option, + pub client_id: Option, + pub is_optimistic: bool, pub content: ContentBlock, pub chunks: Vec, pub checkpoint: Option, @@ -342,8 +356,14 @@ impl AssistantMessage { #[derive(Debug, PartialEq)] pub enum AssistantMessageChunk { - Message { block: ContentBlock }, - Thought { block: ContentBlock }, + Message { + id: Option, + block: ContentBlock, + }, + Thought { + id: Option, + block: ContentBlock, + }, } impl AssistantMessageChunk { @@ -354,29 +374,391 @@ impl AssistantMessageChunk { cx: &mut App, ) -> Self { Self::Message { + id: None, block: ContentBlock::new(chunk.into(), language_registry, path_style, cx), } } fn to_markdown(&self, cx: &App) -> String { match self { - Self::Message { block } => block.to_markdown(cx).to_string(), - Self::Thought { block } => { + Self::Message { block, .. } => block.to_markdown(cx).to_string(), + Self::Thought { block, .. } => { format!("\n{}\n", block.to_markdown(cx)) } } } } +fn can_merge_message_chunks( + existing: Option<&acp::MessageId>, + incoming: Option<&acp::MessageId>, +) -> bool { + match (existing, incoming) { + (Some(existing), Some(incoming)) => existing == incoming, + _ => true, + } +} + #[derive(Debug)] pub enum AgentThreadEntry { UserMessage(UserMessage), AssistantMessage(AssistantMessage), ToolCall(ToolCall), + Elicitation(ElicitationEntryId), CompletedPlan(Vec), ContextCompaction(ContextCompaction), } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ElicitationEntryId(pub Arc); + +#[derive(Debug)] +pub struct Elicitation { + pub id: ElicitationEntryId, + pub request: acp::CreateElicitationRequest, + pub status: ElicitationStatus, +} + +#[derive(Debug)] +pub enum ElicitationStatus { + Pending { + respond_tx: oneshot::Sender, + }, + Accepted, + Declined, + Canceled, + Completed, +} + +#[derive(Clone, Debug)] +pub enum ElicitationStoreEvent { + ElicitationRequested(ElicitationEntryId), + ElicitationResponded(ElicitationEntryId), + ElicitationUpdated(ElicitationEntryId), +} + +#[derive(Default)] +pub struct ElicitationStore { + elicitations: Vec, +} + +impl EventEmitter for ElicitationStore {} + +impl ElicitationStore { + pub fn elicitations(&self) -> &[Elicitation] { + &self.elicitations + } + + fn validate_request(request: &acp::CreateElicitationRequest) -> Result<(), acp::Error> { + match &request.mode { + acp::ElicitationMode::Form(_) => {} + acp::ElicitationMode::Url(mode) => { + let url = url::Url::parse(&mode.url) + .map_err(|_| acp::Error::invalid_params().data("invalid elicitation URL"))?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(acp::Error::invalid_params() + .data("elicitation URL must use HTTP or HTTPS and include a host")); + } + } + _ => { + return Err(acp::Error::invalid_params().data("unsupported elicitation mode")); + } + } + + Ok(()) + } + + fn insert_pending_elicitation( + &mut self, + request: acp::CreateElicitationRequest, + ) -> ( + ElicitationEntryId, + oneshot::Receiver, + ) { + let (respond_tx, response_rx) = oneshot::channel(); + let id = ElicitationEntryId(Uuid::new_v4().to_string().into()); + self.elicitations.push(Elicitation { + id: id.clone(), + request, + status: ElicitationStatus::Pending { respond_tx }, + }); + (id, response_rx) + } + + fn response_task( + id: ElicitationEntryId, + response_rx: oneshot::Receiver, + cx: &mut Context, + emit_responded: impl FnOnce(&mut T, &mut Context, ElicitationEntryId) + 'static, + ) -> Task + where + T: 'static, + { + cx.spawn(async move |this, cx| { + let response = response_rx.await.unwrap_or_else(|oneshot::Canceled| { + acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel) + }); + this.update(cx, |this, cx| emit_responded(this, cx, id)) + .ok(); + response + }) + } + + fn respond_to_elicitation_entry( + elicitation: &mut Elicitation, + response: acp::CreateElicitationResponse, + ) -> bool { + if !matches!(elicitation.status, ElicitationStatus::Pending { .. }) { + return false; + } + let ElicitationStatus::Pending { respond_tx } = mem::replace( + &mut elicitation.status, + elicitation_status_for_response(&response), + ) else { + return false; + }; + respond_tx.send(response).ok(); + true + } + + fn complete_url_elicitation_entry(elicitation: &mut Elicitation) -> bool { + let previous_status = mem::replace(&mut elicitation.status, ElicitationStatus::Completed); + match previous_status { + ElicitationStatus::Accepted => true, + previous_status @ (ElicitationStatus::Pending { .. } + | ElicitationStatus::Declined + | ElicitationStatus::Canceled + | ElicitationStatus::Completed) => { + elicitation.status = previous_status; + false + } + } + } + + fn cancel_elicitation_entry( + elicitation: &mut Elicitation, + cancel_accepted_url_elicitations: bool, + ) -> bool { + match mem::replace(&mut elicitation.status, ElicitationStatus::Canceled) { + ElicitationStatus::Pending { respond_tx } => { + respond_tx + .send(acp::CreateElicitationResponse::new( + acp::ElicitationAction::Cancel, + )) + .ok(); + true + } + ElicitationStatus::Accepted + if cancel_accepted_url_elicitations + && matches!(&elicitation.request.mode, acp::ElicitationMode::Url(_)) => + { + true + } + previous_status => { + elicitation.status = previous_status; + false + } + } + } + + fn respond_to_elicitation_by_id( + &mut self, + id: &ElicitationEntryId, + response: acp::CreateElicitationResponse, + ) -> bool { + let Some((_, elicitation)) = self.elicitation_mut(id) else { + return false; + }; + Self::respond_to_elicitation_entry(elicitation, response) + } + + fn complete_url_elicitation_by_id(&mut self, id: &ElicitationEntryId) -> bool { + let Some((_, elicitation)) = self.elicitation_mut(id) else { + return false; + }; + Self::complete_url_elicitation_entry(elicitation) + } + + fn cancel_elicitation_by_id( + &mut self, + id: &ElicitationEntryId, + cancel_accepted_url_elicitations: bool, + ) -> bool { + let Some((_, elicitation)) = self.elicitation_mut(id) else { + return false; + }; + Self::cancel_elicitation_entry(elicitation, cancel_accepted_url_elicitations) + } + + pub fn request_elicitation( + &mut self, + request: acp::CreateElicitationRequest, + cx: &mut Context, + ) -> Result, acp::Error> { + self.request_elicitation_with_id(request, cx) + .map(|(_, task)| task) + } + + pub fn request_elicitation_with_id( + &mut self, + request: acp::CreateElicitationRequest, + cx: &mut Context, + ) -> Result<(ElicitationEntryId, Task), acp::Error> { + Self::validate_request(&request)?; + let (id, response_rx) = self.insert_pending_elicitation(request); + cx.emit(ElicitationStoreEvent::ElicitationRequested(id.clone())); + cx.notify(); + + let task = Self::response_task(id.clone(), response_rx, cx, |_store, cx, id| { + cx.emit(ElicitationStoreEvent::ElicitationResponded(id)); + cx.notify(); + }); + + Ok((id, task)) + } + + pub fn respond_to_elicitation( + &mut self, + id: &ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) { + if !self.respond_to_elicitation_by_id(id, response) { + return; + } + + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone())); + cx.notify(); + } + + pub fn complete_url_elicitation( + &mut self, + elicitation_id: &acp::ElicitationId, + cx: &mut Context, + ) { + let Some(entry_id) = self.entry_id_for_url_elicitation(elicitation_id) else { + return; + }; + if !self.complete_url_elicitation_by_id(&entry_id) { + return; + } + + cx.emit(ElicitationStoreEvent::ElicitationUpdated(entry_id)); + cx.notify(); + } + + pub fn cancel_elicitation(&mut self, id: &ElicitationEntryId, cx: &mut Context) { + if !self.cancel_elicitation_by_id(id, true) { + return; + } + + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone())); + cx.notify(); + } + + pub fn cancel_all(&mut self, cx: &mut Context) { + let canceled_ids = self.cancel_pending(|_| true); + for id in canceled_ids { + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id)); + } + cx.notify(); + } + + pub fn clear(&mut self, cx: &mut Context) { + let canceled_ids = self.cancel_pending(|_| true); + self.elicitations.clear(); + for id in canceled_ids { + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id)); + } + cx.notify(); + } + + pub fn clear_resolved(&mut self, cx: &mut Context) -> Vec { + let mut cleared_ids = Vec::new(); + self.elicitations.retain(|elicitation| { + let keep = matches!( + (&elicitation.status, &elicitation.request.mode), + (ElicitationStatus::Pending { .. }, _) + | (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_)) + ); + if !keep { + cleared_ids.push(elicitation.id.clone()); + } + keep + }); + + if !cleared_ids.is_empty() { + for id in &cleared_ids { + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone())); + } + cx.notify(); + } + + cleared_ids + } + + pub fn cancel_request(&mut self, request_id: &acp::RequestId, cx: &mut Context) { + let canceled_ids = self.cancel_pending(|elicitation| { + matches!( + elicitation.request.scope(), + acp::ElicitationScope::Request(scope) if &scope.request_id == request_id + ) + }); + for id in canceled_ids { + cx.emit(ElicitationStoreEvent::ElicitationUpdated(id)); + } + cx.notify(); + } + + pub fn elicitation(&self, id: &ElicitationEntryId) -> Option<(usize, &Elicitation)> { + self.elicitations + .iter() + .enumerate() + .rev() + .find_map(|(index, elicitation)| { + (&elicitation.id == id).then_some((index, elicitation)) + }) + } + + fn entry_id_for_url_elicitation( + &self, + elicitation_id: &acp::ElicitationId, + ) -> Option { + self.elicitations.iter().rev().find_map(|elicitation| { + if let acp::ElicitationMode::Url(mode) = &elicitation.request.mode + && &mode.elicitation_id == elicitation_id + { + Some(elicitation.id.clone()) + } else { + None + } + }) + } + + fn elicitation_mut(&mut self, id: &ElicitationEntryId) -> Option<(usize, &mut Elicitation)> { + self.elicitations + .iter_mut() + .enumerate() + .rev() + .find_map(|(index, elicitation)| { + (&elicitation.id == id).then_some((index, elicitation)) + }) + } + + fn cancel_pending( + &mut self, + mut should_cancel: impl FnMut(&Elicitation) -> bool, + ) -> Vec { + let mut canceled_ids = Vec::new(); + for elicitation in &mut self.elicitations { + if should_cancel(elicitation) && Self::cancel_elicitation_entry(elicitation, true) { + canceled_ids.push(elicitation.id.clone()); + } + } + canceled_ids + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContextCompactionId(pub Arc); @@ -418,6 +800,7 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.indented, Self::AssistantMessage(message) => message.indented, Self::ToolCall(_) => false, + Self::Elicitation(_) => false, Self::CompletedPlan(_) => false, Self::ContextCompaction(_) => false, } @@ -428,6 +811,7 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.to_markdown(cx), Self::AssistantMessage(message) => message.to_markdown(cx), Self::ToolCall(tool_call) => tool_call.to_markdown(cx), + Self::Elicitation(_) => "## Input Requested\n\n".to_string(), Self::CompletedPlan(entries) => { let mut md = String::from("## Plan\n\n"); for entry in entries { @@ -863,16 +1247,18 @@ impl From for acp::SelectedPermissionOutcome { } } -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum RequestPermissionOutcome { Cancelled, + InterruptedByFollowUp, Selected(SelectedPermissionOutcome), } impl From for acp::RequestPermissionOutcome { fn from(value: RequestPermissionOutcome) -> Self { match value { - RequestPermissionOutcome::Cancelled => Self::Cancelled, + RequestPermissionOutcome::Cancelled + | RequestPermissionOutcome::InterruptedByFollowUp => Self::Cancelled, RequestPermissionOutcome::Selected(outcome) => Self::Selected(outcome.into()), } } @@ -903,7 +1289,7 @@ pub enum ToolCallStatus { WaitingForConfirmation { current_status: acp::ToolCallStatus, options: PermissionOptions, - respond_tx: oneshot::Sender, + respond_tx: oneshot::Sender, kind: AuthorizationKind, }, /// The tool call is currently running. @@ -968,12 +1354,25 @@ impl Display for ToolCallStatus { } } +fn elicitation_status_for_response(response: &acp::CreateElicitationResponse) -> ElicitationStatus { + match &response.action { + acp::ElicitationAction::Accept(_) => ElicitationStatus::Accepted, + acp::ElicitationAction::Decline => ElicitationStatus::Declined, + acp::ElicitationAction::Cancel => ElicitationStatus::Canceled, + _ => ElicitationStatus::Canceled, + } +} + #[derive(Debug, PartialEq, Clone)] pub enum ContentBlock { Empty, Markdown { markdown: Entity, }, + EmbeddedResource { + resource: acp::EmbeddedResource, + markdown: Option>, + }, ResourceLink { resource_link: acp::ResourceLink, }, @@ -1008,6 +1407,26 @@ impl ContentBlock { this } + pub fn new_tool_call_content( + block: acp::ContentBlock, + language_registry: &Arc, + path_style: PathStyle, + cx: &mut App, + ) -> Self { + match block { + acp::ContentBlock::Resource(resource) => { + if let Some((image, dimensions)) = Self::decode_embedded_resource_image(&resource) { + Self::Image { image, dimensions } + } else { + let markdown = + Self::embedded_resource_markdown(&resource, language_registry, cx); + Self::EmbeddedResource { resource, markdown } + } + } + block => Self::new(block, language_registry, path_style, cx), + } + } + pub fn append( &mut self, block: acp::ContentBlock, @@ -1043,6 +1462,13 @@ impl ContentBlock { let combined = format!("{}\n{}", existing_content, new_content); *self = Self::create_markdown_block(combined, language_registry, cx); } + (ContentBlock::EmbeddedResource { resource, .. }, _) => { + let existing_content = + Self::embedded_resource_string_contents(resource, path_style); + let new_content = Self::block_string_contents(&block, path_style); + let combined = format!("{}\n{}", existing_content, new_content); + *self = Self::create_markdown_block(combined, language_registry, cx); + } (ContentBlock::Image { .. }, _) => { let new_content = Self::block_string_contents(&block, path_style); let combined = format!("`Image`\n{}", new_content); @@ -1080,13 +1506,30 @@ impl ContentBlock { fn decode_image( image_content: &acp::ImageContent, + ) -> Option<(Arc, Option>)> { + Self::decode_image_data(&image_content.data, &image_content.mime_type) + } + + fn decode_embedded_resource_image( + resource: &acp::EmbeddedResource, + ) -> Option<(Arc, Option>)> { + let acp::EmbeddedResourceResource::BlobResourceContents(blob) = &resource.resource else { + return None; + }; + let mime_type = blob.mime_type.as_deref()?; + Self::decode_image_data(&blob.blob, mime_type) + } + + fn decode_image_data( + data: &str, + mime_type: &str, ) -> Option<(Arc, Option>)> { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD - .decode(image_content.data.as_bytes()) + .decode(data.as_bytes()) .ok()?; - let format = gpui::ImageFormat::from_mime_type(&image_content.mime_type)?; + let format = gpui::ImageFormat::from_mime_type(mime_type)?; let dimensions = Self::image_dimensions(&bytes, format); Some((Arc::new(gpui::Image::from_bytes(format, bytes)), dimensions)) } @@ -1116,19 +1559,141 @@ impl ContentBlock { cx: &mut App, ) -> ContentBlock { ContentBlock::Markdown { - markdown: cx.new(|cx| { - Markdown::new_with_options( - content.into(), - Some(language_registry.clone()), - None, - MarkdownOptions { - render_mermaid_diagrams: true, - render_metadata_blocks: true, - ..Default::default() - }, - cx, - ) - }), + markdown: Self::create_markdown(content, language_registry, cx), + } + } + + fn create_markdown( + content: String, + language_registry: &Arc, + cx: &mut App, + ) -> Entity { + cx.new(|cx| { + Markdown::new_with_options( + content.into(), + Some(language_registry.clone()), + None, + MarkdownOptions { + render_mermaid_diagrams: true, + render_metadata_blocks: true, + ..Default::default() + }, + cx, + ) + }) + } + + fn embedded_resource_markdown( + resource: &acp::EmbeddedResource, + language_registry: &Arc, + cx: &mut App, + ) -> Option> { + match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text) => Some( + Self::create_markdown(Self::text_resource_markdown(text), language_registry, cx), + ), + acp::EmbeddedResourceResource::BlobResourceContents(_) => None, + _ => None, + } + } + + fn text_resource_markdown(resource: &acp::TextResourceContents) -> String { + match text_resource_render_mode(resource.mime_type.as_deref()) { + TextResourceRenderMode::Markdown => resource.text.clone(), + TextResourceRenderMode::CodeBlock(language) => { + Self::fenced_code_block(&resource.text, language) + } + } + } + + pub fn text_content<'a>(&'a self, cx: &'a App) -> Option<&'a str> { + match self { + ContentBlock::Markdown { markdown } => Some(markdown.read(cx).source()), + ContentBlock::EmbeddedResource { resource, .. } => match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text) => Some(&text.text), + acp::EmbeddedResourceResource::BlobResourceContents(_) => None, + _ => None, + }, + ContentBlock::Empty + | ContentBlock::ResourceLink { .. } + | ContentBlock::Image { .. } => None, + } + } + + fn fenced_code_block(text: &str, language: Option<&str>) -> String { + let fence_len = text + .as_bytes() + .chunk_by(|left, right| left == right) + .filter(|chunk| chunk.first() == Some(&b'`')) + .map(|chunk| chunk.len() + 1) + .max() + .unwrap_or(3) + .max(3); + let fence = "`".repeat(fence_len); + + let mut markdown = String::new(); + markdown.push_str(&fence); + if let Some(language) = language { + markdown.push_str(language); + } + markdown.push('\n'); + markdown.push_str(text); + if !text.ends_with('\n') { + markdown.push('\n'); + } + markdown.push_str(&fence); + markdown + } + + fn embedded_resource_string_contents( + resource: &acp::EmbeddedResource, + path_style: PathStyle, + ) -> String { + match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text) => { + Self::resource_link_md(&text.uri, path_style) + } + acp::EmbeddedResourceResource::BlobResourceContents(blob) => { + Self::resource_link_md(&blob.uri, path_style) + } + _ => String::new(), + } + } + + fn embedded_resource_text(resource: &acp::EmbeddedResource) -> &str { + match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text) => &text.text, + acp::EmbeddedResourceResource::BlobResourceContents(blob) => &blob.uri, + _ => "", + } + } + + fn embedded_resource_label(resource: &acp::EmbeddedResource) -> &str { + match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text) => &text.uri, + acp::EmbeddedResourceResource::BlobResourceContents(blob) => &blob.uri, + _ => "", + } + } + + pub fn embedded_resource(&self) -> Option<(&acp::EmbeddedResource, Option<&Entity>)> { + match self { + ContentBlock::EmbeddedResource { resource, markdown } => { + Some((resource, markdown.as_ref())) + } + _ => None, + } + } + + pub fn visible_content(&self, cx: &App) -> bool { + match self { + ContentBlock::Empty => false, + ContentBlock::Markdown { markdown } => !markdown.read(cx).source().trim().is_empty(), + ContentBlock::EmbeddedResource { resource, markdown } => match markdown { + Some(markdown) => !markdown.read(cx).source().trim().is_empty(), + None => !Self::embedded_resource_text(resource).trim().is_empty(), + }, + ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => true, } } @@ -1167,6 +1732,13 @@ impl ContentBlock { match self { ContentBlock::Empty => "", ContentBlock::Markdown { markdown } => markdown.read(cx).source(), + ContentBlock::EmbeddedResource { resource, markdown } => { + if let Some(markdown) = markdown { + markdown.read(cx).source() + } else { + Self::embedded_resource_label(resource) + } + } ContentBlock::ResourceLink { resource_link } => &resource_link.uri, ContentBlock::Image { .. } => "`Image`", } @@ -1176,6 +1748,7 @@ impl ContentBlock { match self { ContentBlock::Empty => None, ContentBlock::Markdown { markdown } => Some(markdown), + ContentBlock::EmbeddedResource { markdown, .. } => markdown.as_ref(), ContentBlock::ResourceLink { .. } => None, ContentBlock::Image { .. } => None, } @@ -1196,6 +1769,61 @@ impl ContentBlock { } } +enum TextResourceRenderMode { + Markdown, + CodeBlock(Option<&'static str>), +} + +fn text_resource_render_mode(mime_type: Option<&str>) -> TextResourceRenderMode { + let Some(mime_type) = mime_type else { + return TextResourceRenderMode::CodeBlock(None); + }; + let Ok(mime) = mime_type.parse::() else { + return TextResourceRenderMode::CodeBlock(None); + }; + + let type_ = mime.type_().as_str(); + let subtype = mime.subtype().as_str(); + let suffix = mime.suffix().map(|suffix| suffix.as_str()); + + if matches!( + (type_, subtype), + ("text", "markdown") | ("text", "x-markdown") + ) { + return TextResourceRenderMode::Markdown; + } + + let language = match (type_, subtype, suffix) { + (_, "json", _) | (_, _, Some("json")) => Some("json"), + (_, "xml", _) | (_, _, Some("xml")) => Some("xml"), + ("text", "html", _) => Some("html"), + ("text", "css", _) => Some("css"), + ("text", "csv", _) => Some("csv"), + ("text", "tab-separated-values", _) => Some("tsv"), + ("text", "javascript", _) | ("application", "javascript", _) => Some("javascript"), + ("application", "x-javascript", _) => Some("javascript"), + ("text", "typescript", _) | ("application", "typescript", _) => Some("typescript"), + ("text", "x-shellscript", _) | ("application", "x-shellscript", _) => Some("sh"), + ("application", "x-sh", _) => Some("sh"), + ("text", "x-python", _) => Some("python"), + ("text", "x-rust", _) => Some("rust"), + ("text", "x-go", _) => Some("go"), + ("text", "x-ruby", _) => Some("ruby"), + ("text", "x-c", _) => Some("c"), + // `mime` parses `text/x-c++` as subtype `x-c+` with an empty suffix. + ("text", "x-c+", Some("")) => Some("cpp"), + ("text", "plain", _) => None, + ("text", _, _) => None, + ("application", "graphql", _) => Some("graphql"), + ("application", "toml", _) => Some("toml"), + ("application", "yaml", _) | ("application", "x-yaml", _) => Some("yaml"), + (_, _, Some("yaml" | "yml")) => Some("yaml"), + _ => return TextResourceRenderMode::CodeBlock(None), + }; + + TextResourceRenderMode::CodeBlock(language) +} + #[derive(Debug)] pub enum ToolCallContent { ContentBlock(ContentBlock), @@ -1212,14 +1840,14 @@ impl ToolCallContent { cx: &mut App, ) -> Result> { match content { - acp::ToolCallContent::Content(acp::Content { content, .. }) => { - Ok(Some(Self::ContentBlock(ContentBlock::new( + acp::ToolCallContent::Content(acp::Content { content, .. }) => Ok(Some( + Self::ContentBlock(ContentBlock::new_tool_call_content( 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(), @@ -1499,6 +2127,7 @@ pub struct AcpThread { title: Option, provisional_title: Option, entries: Vec, + elicitations: ElicitationStore, plan: Plan, project: Entity, action_log: Entity, @@ -1556,6 +2185,7 @@ impl From<&AcpThread> for ActionLogTelemetry { #[derive(Debug)] pub enum AcpThreadEvent { + StatusChanged, PromptUpdated, NewEntry, TitleUpdated, @@ -1564,6 +2194,8 @@ pub enum AcpThreadEvent { EntriesRemoved(Range), ToolAuthorizationRequested(acp::ToolCallId), ToolAuthorizationReceived(acp::ToolCallId), + ElicitationRequested(ElicitationEntryId), + ElicitationResponded(ElicitationEntryId), Retry(RetryStatus), SubagentSpawned(acp::SessionId), Stopped(acp::StopReason), @@ -1707,6 +2339,7 @@ impl AcpThread { update_last_checkpoint_if_changed_task: None, shared_buffers: Default::default(), entries: Default::default(), + elicitations: ElicitationStore::default(), plan: Default::default(), title, provisional_title: None, @@ -1811,8 +2444,8 @@ impl AcpThread { }; for chunk in chunks { let block = match chunk { - AssistantMessageChunk::Message { block } => block, - AssistantMessageChunk::Thought { block } => block, + AssistantMessageChunk::Message { block, .. } => block, + AssistantMessageChunk::Thought { block, .. } => block, }; if let Some(markdown) = block.markdown() { markdown.update(cx, |markdown, cx| { @@ -1860,7 +2493,17 @@ impl AcpThread { status: ToolCallStatus::WaitingForConfirmation { .. }, .. }) => return true, + AgentThreadEntry::Elicitation(elicitation_id) + if self.elicitations.elicitation(elicitation_id).is_some_and( + |(_, elicitation)| { + matches!(elicitation.status, ElicitationStatus::Pending { .. }) + }, + ) => + { + return true; + } AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::Elicitation(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) | AgentThreadEntry::ContextCompaction(_) => {} @@ -1898,6 +2541,7 @@ impl AcpThread { return true; } AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::Elicitation(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) | AgentThreadEntry::ContextCompaction(_) => {} @@ -1918,6 +2562,7 @@ impl AcpThread { return true; } AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::Elicitation(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) | AgentThreadEntry::ContextCompaction(_) => {} @@ -1933,7 +2578,8 @@ impl AcpThread { AgentThreadEntry::UserMessage(..) => return false, AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) - | AgentThreadEntry::ContextCompaction(_) => continue, + | AgentThreadEntry::ContextCompaction(_) + | AgentThreadEntry::Elicitation(_) => continue, AgentThreadEntry::ToolCall(..) => return true, } } @@ -1947,24 +2593,54 @@ impl AcpThread { cx: &mut Context, ) -> Result<(), acp::Error> { match update { - acp::SessionUpdate::UserMessageChunk(acp::ContentChunk { content, .. }) => { + acp::SessionUpdate::UserMessageChunk(acp::ContentChunk { + content, + message_id, + .. + }) => { // We optimistically add the full user prompt before calling `prompt`. - // Some ACP servers echo user chunks back over updates. Skip the chunk if - // it's already present in the current user message to avoid duplicating content. + // Some ACP servers echo user chunks back over updates. Skip echoed + // chunks only when they match the local optimistic message. let already_in_user_message = self .entries - .last() - .and_then(|entry| entry.user_message()) - .is_some_and(|message| message.chunks.contains(&content)); + .last_mut() + .and_then(|entry| match entry { + AgentThreadEntry::UserMessage(message) => Some(message), + _ => None, + }) + .is_some_and(|message| { + let already_in_user_message = message.is_optimistic + && message.chunks.contains(&content) + && can_merge_message_chunks( + message.protocol_id.as_ref(), + message_id.as_ref(), + ); + if already_in_user_message && message.protocol_id.is_none() { + message.protocol_id = message_id.clone(); + } + already_in_user_message + }); if !already_in_user_message { - self.push_user_content_block(None, content, cx); + self.push_user_content_block_from_agent(message_id, content, cx); } } - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content, .. }) => { - self.push_assistant_content_block(content, false, cx); + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { + content, + message_id, + .. + }) => { + self.push_assistant_content_block_with_message_id( + message_id, content, false, false, cx, + ); } - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { content, .. }) => { - self.push_assistant_content_block(content, true, cx); + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { + content, + message_id, + .. + }) => { + self.push_assistant_content_block_with_message_id( + message_id, content, true, false, cx, + ); } acp::SessionUpdate::ToolCall(tool_call) => { self.upsert_tool_call(tool_call, cx)?; @@ -1976,7 +2652,7 @@ impl AcpThread { self.update_plan(plan, cx); } acp::SessionUpdate::SessionInfoUpdate(info_update) => { - if let acp::MaybeUndefined::Value(title) = info_update.title { + if let MaybeUndefined::Value(title) = info_update.title { let had_provisional = self.provisional_title.take().is_some(); let title: SharedString = title.into(); if self.title.as_ref() != Some(&title) { @@ -2021,16 +2697,44 @@ impl AcpThread { pub fn push_user_content_block( &mut self, - message_id: Option, + client_id: Option, chunk: acp::ContentBlock, cx: &mut Context, ) { - self.push_user_content_block_with_indent(message_id, chunk, false, cx) + self.push_user_content_block_with_indent(client_id, chunk, false, cx) } pub fn push_user_content_block_with_indent( &mut self, - message_id: Option, + client_id: Option, + chunk: acp::ContentBlock, + indented: bool, + cx: &mut Context, + ) { + self.push_user_content_block_with_protocol_id( + client_id.clone(), + client_id.is_some(), + None, + chunk, + indented, + cx, + ) + } + + fn push_user_content_block_from_agent( + &mut self, + id: Option, + chunk: acp::ContentBlock, + cx: &mut Context, + ) { + self.push_user_content_block_with_protocol_id(None, false, id, chunk, false, cx) + } + + fn push_user_content_block_with_protocol_id( + &mut self, + incoming_client_id: Option, + is_optimistic: bool, + protocol_id: Option, chunk: acp::ContentBlock, indented: bool, cx: &mut Context, @@ -2041,16 +2745,29 @@ impl AcpThread { if let Some(last_entry) = self.entries.last_mut() && let AgentThreadEntry::UserMessage(UserMessage { - id, + protocol_id: existing_protocol_id, + client_id: existing_client_id, content, chunks, + is_optimistic: existing_is_optimistic, indented: existing_indented, .. }) = last_entry && *existing_indented == indented + && can_merge_message_chunks(existing_protocol_id.as_ref(), protocol_id.as_ref()) + && !(*existing_is_optimistic + && !is_optimistic + && existing_protocol_id.is_none() + && protocol_id.is_some()) { Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); - *id = message_id.or(id.take()); + if let Some(incoming_client_id) = incoming_client_id { + *existing_client_id = Some(incoming_client_id); + } + *existing_is_optimistic |= is_optimistic; + if existing_protocol_id.is_none() { + *existing_protocol_id = protocol_id; + } content.append(chunk.clone(), &language_registry, path_style, cx); chunks.push(chunk); let idx = entries_len - 1; @@ -2059,7 +2776,9 @@ impl AcpThread { let content = ContentBlock::new(chunk.clone(), &language_registry, path_style, cx); self.push_entry( AgentThreadEntry::UserMessage(UserMessage { - id: message_id, + protocol_id, + client_id: incoming_client_id, + is_optimistic, content, chunks: vec![chunk], checkpoint: None, @@ -2085,13 +2804,26 @@ impl AcpThread { is_thought: bool, indented: bool, cx: &mut Context, + ) { + self.push_assistant_content_block_with_message_id(None, chunk, is_thought, indented, cx) + } + + fn push_assistant_content_block_with_message_id( + &mut self, + message_id: Option, + chunk: acp::ContentBlock, + is_thought: bool, + indented: bool, + cx: &mut Context, ) { let path_style = self.project.read(cx).path_style(cx); // For text chunks going to an existing Markdown block, buffer for smooth // streaming instead of appending all at once which may feel more choppy. if let acp::ContentBlock::Text(text_content) = &chunk { - if let Some(markdown) = self.streaming_markdown_target(is_thought, indented) { + if let Some(markdown) = + self.streaming_markdown_target(message_id.as_ref(), is_thought, indented) + { let entries_len = self.entries.len(); cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); self.buffer_streaming_text(&markdown, text_content.text.clone(), cx); @@ -2113,25 +2845,52 @@ impl AcpThread { Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); cx.emit(AcpThreadEvent::EntryUpdated(idx)); match (chunks.last_mut(), is_thought) { - (Some(AssistantMessageChunk::Message { block }), false) - | (Some(AssistantMessageChunk::Thought { block }), true) => { + ( + Some(AssistantMessageChunk::Message { + id: existing_id, + block, + }), + false, + ) + | ( + Some(AssistantMessageChunk::Thought { + id: existing_id, + block, + }), + true, + ) if can_merge_message_chunks(existing_id.as_ref(), message_id.as_ref()) => { + if existing_id.is_none() { + *existing_id = message_id; + } block.append(chunk, &language_registry, path_style, cx) } _ => { let block = ContentBlock::new(chunk, &language_registry, path_style, cx); if is_thought { - chunks.push(AssistantMessageChunk::Thought { block }) + chunks.push(AssistantMessageChunk::Thought { + id: message_id, + block, + }) } else { - chunks.push(AssistantMessageChunk::Message { block }) + chunks.push(AssistantMessageChunk::Message { + id: message_id, + block, + }) } } } } else { let block = ContentBlock::new(chunk, &language_registry, path_style, cx); let chunk = if is_thought { - AssistantMessageChunk::Thought { block } + AssistantMessageChunk::Thought { + id: message_id, + block, + } } else { - AssistantMessageChunk::Message { block } + AssistantMessageChunk::Message { + id: message_id, + block, + } }; self.push_entry( @@ -2146,32 +2905,40 @@ impl AcpThread { } fn streaming_markdown_target( - &self, + &mut self, + message_id: Option<&acp::MessageId>, is_thought: bool, indented: bool, ) -> Option> { - let last_entry = self.entries.last()?; + let last_entry = self.entries.last_mut()?; if let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks, indented: existing_indented, .. }) = last_entry && *existing_indented == indented - && let [.., chunk] = chunks.as_slice() + && let [.., chunk] = chunks.as_mut_slice() { match (chunk, is_thought) { ( AssistantMessageChunk::Message { + id: existing_id, block: ContentBlock::Markdown { markdown }, }, false, ) | ( AssistantMessageChunk::Thought { + id: existing_id, block: ContentBlock::Markdown { markdown }, }, true, - ) => Some(markdown.clone()), + ) if can_merge_message_chunks(existing_id.as_ref(), message_id) => { + if existing_id.is_none() { + *existing_id = message_id.cloned(); + } + Some(markdown.clone()) + } _ => None, } } else { @@ -2681,10 +3448,7 @@ impl AcpThread { )); Ok(cx.spawn(async move |this, cx| { - let outcome = match rx.await { - Ok(outcome) => RequestPermissionOutcome::Selected(outcome), - Err(oneshot::Canceled) => RequestPermissionOutcome::Cancelled, - }; + let outcome = rx.await.unwrap_or(RequestPermissionOutcome::Cancelled); this.update(cx, |_this, cx| { cx.emit(AcpThreadEvent::ToolAuthorizationReceived(tool_call_id)) }) @@ -2693,6 +3457,19 @@ impl AcpThread { })) } + pub fn cancel_tool_call_authorization(&mut self, id: &acp::ToolCallId, cx: &mut Context) { + let Some((ix, call)) = self.tool_call_mut(id) else { + return; + }; + if !matches!(call.status, ToolCallStatus::WaitingForConfirmation { .. }) { + return; + } + + call.status = ToolCallStatus::Canceled; + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + cx.emit(AcpThreadEvent::ToolAuthorizationReceived(id.clone())); + } + pub fn authorize_tool_call( &mut self, id: acp::ToolCallId, @@ -2732,12 +3509,107 @@ impl AcpThread { let curr_status = mem::replace(&mut call.status, new_status); if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status { - respond_tx.send(outcome).ok(); + respond_tx + .send(RequestPermissionOutcome::Selected(outcome)) + .ok(); + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + pub fn request_elicitation( + &mut self, + request: acp::CreateElicitationRequest, + cx: &mut Context, + ) -> Result, acp::Error> { + self.request_elicitation_with_id(request, cx) + .map(|(_, task)| task) + } + + pub fn request_elicitation_with_id( + &mut self, + request: acp::CreateElicitationRequest, + cx: &mut Context, + ) -> Result<(ElicitationEntryId, Task), acp::Error> { + ElicitationStore::validate_request(&request)?; + + let (id, response_rx) = self.elicitations.insert_pending_elicitation(request); + self.push_entry(AgentThreadEntry::Elicitation(id.clone()), cx); + cx.emit(AcpThreadEvent::ElicitationRequested(id.clone())); + + let task = + ElicitationStore::response_task(id.clone(), response_rx, cx, |_thread, cx, id| { + cx.emit(AcpThreadEvent::ElicitationResponded(id)) + }); + + Ok((id, task)) + } + + pub fn respond_to_elicitation( + &mut self, + id: &ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) { + let Some(ix) = self.elicitation_entry_ix(id) else { + return; + }; + if !self.elicitations.respond_to_elicitation_by_id(id, response) { + return; + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + pub fn complete_url_elicitation( + &mut self, + elicitation_id: &acp::ElicitationId, + cx: &mut Context, + ) { + let Some(entry_id) = self + .elicitations + .entry_id_for_url_elicitation(elicitation_id) + else { + return; + }; + let Some(ix) = self.elicitation_entry_ix(&entry_id) else { + return; + }; + if !self.elicitations.complete_url_elicitation_by_id(&entry_id) { + return; + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + pub fn cancel_elicitation(&mut self, id: &ElicitationEntryId, cx: &mut Context) { + let Some(ix) = self.elicitation_entry_ix(id) else { + return; + }; + if !self.elicitations.cancel_elicitation_by_id(id, true) { + return; } cx.emit(AcpThreadEvent::EntryUpdated(ix)); } + fn elicitation_entry_ix(&self, id: &ElicitationEntryId) -> Option { + self.entries + .iter() + .enumerate() + .rev() + .find_map(|(index, entry)| { + matches!(entry, AgentThreadEntry::Elicitation(elicitation_id) if elicitation_id == id) + .then_some(index) + }) + } + + pub fn elicitation(&self, id: &ElicitationEntryId) -> Option<(usize, &Elicitation)> { + let index = self.elicitation_entry_ix(id)?; + let (_, elicitation) = self.elicitations.elicitation(id)?; + Some((index, elicitation)) + } + pub fn plan(&self) -> &Plan { &self.plan } @@ -2830,14 +3702,19 @@ impl AcpThread { let request = acp::PromptRequest::new(self.session_id.clone(), message.clone()); let git_store = self.project.read(cx).git_store().clone(); - let message_id = UserMessageId::new(); + let client_user_message_ids = self.connection.client_user_message_ids(cx); + let client_id = client_user_message_ids + .as_ref() + .map(|client_user_message_ids| client_user_message_ids.new_id()); self.run_turn(cx, async move |this, cx| { if push_user_message { this.update(cx, |this, cx| { this.push_entry( AgentThreadEntry::UserMessage(UserMessage { - id: Some(message_id.clone()), + protocol_id: None, + client_id: client_id.clone(), + is_optimistic: true, content: block, chunks: message, checkpoint: None, @@ -2865,7 +3742,11 @@ impl AcpThread { } this.update(cx, |this, cx| { - this.connection.prompt(message_id, request, cx) + if let (Some(prompt), Some(client_id)) = (client_user_message_ids, client_id) { + prompt.prompt(client_id, request, cx) + } else { + this.connection.prompt(request, cx) + } })? .await }) @@ -2901,7 +3782,7 @@ impl AcpThread { self.turn_token_usage = None; let (tx, rx) = oneshot::channel(); - let cancel_task = self.cancel(cx); + let cancel_task = self.cancel_inner(RequestPermissionOutcome::InterruptedByFollowUp, cx); self.turn_id += 1; let turn_id = self.turn_id; @@ -2915,6 +3796,7 @@ impl AcpThread { }), stopped_emitted, }); + cx.emit(AcpThreadEvent::StatusChanged); cx.spawn(async move |this, cx| { let response = rx.await; @@ -2943,6 +3825,9 @@ impl AcpThread { } let Ok(response) = response else { + if is_same_turn { + cx.emit(AcpThreadEvent::StatusChanged); + } // tx dropped — the send_task was cancelled (e.g. rapid cancel // sequence dropped a Task in the chain). Only emit Stopped if // cancel() hasn't already done so synchronously. cancel() emits @@ -2961,6 +3846,9 @@ impl AcpThread { Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); if r.stop_reason == acp::StopReason::MaxTokens { + if is_same_turn { + cx.emit(AcpThreadEvent::StatusChanged); + } this.had_error = true; cx.emit(AcpThreadEvent::Error); log::error!("Max tokens reached. Usage: {:?}", this.token_usage); @@ -2980,14 +3868,14 @@ impl AcpThread { log::error!("Max tokens reached. Usage: {:?}", this.token_usage); } if is_same_turn { - this.mark_pending_entries_as_canceled(cx); + this.cancel_pending_turn_entries(cx); } return Err(anyhow!(MaxOutputTokensError)); } let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); if canceled && is_same_turn { - this.mark_pending_entries_as_canceled(cx); + this.cancel_pending_turn_entries(cx); } if !canceled { @@ -3051,6 +3939,9 @@ impl AcpThread { cx.emit(AcpThreadEvent::TokenUsageUpdated); } + if is_same_turn { + cx.emit(AcpThreadEvent::StatusChanged); + } // Guard against duplicate Stopped emission: cancel() // may have already emitted Stopped synchronously (and // set stopped_emitted). Without this check, a race @@ -3065,9 +3956,12 @@ impl AcpThread { Ok(Some(r)) } Err(e) => { + if is_same_turn { + cx.emit(AcpThreadEvent::StatusChanged); + } Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); if is_same_turn { - this.mark_pending_entries_as_canceled(cx); + this.cancel_pending_turn_entries(cx); } this.had_error = true; cx.emit(AcpThreadEvent::Error); @@ -3081,13 +3975,23 @@ impl AcpThread { } pub fn cancel(&mut self, cx: &mut Context) -> Task<()> { + self.cancel_inner(RequestPermissionOutcome::Cancelled, cx) + } + + fn cancel_inner( + &mut self, + permission_outcome: RequestPermissionOutcome, + cx: &mut Context, + ) -> Task<()> { + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); + self.cancel_outstanding_elicitations(cx); + let Some(turn) = self.running_turn.take() else { return Task::ready(()); }; + self.mark_pending_entries_as_canceled(permission_outcome, cx); self.connection.cancel(&self.session_id, cx); - - Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); - self.mark_pending_entries_as_canceled(cx); + cx.emit(AcpThreadEvent::StatusChanged); // Emit Stopped(Cancelled) SYNCHRONOUSLY before dropping the send_task. // This guarantees message_completed is sent to the server BEFORE the @@ -3114,7 +4018,16 @@ impl AcpThread { Task::ready(()) } - fn mark_pending_entries_as_canceled(&mut self, cx: &mut Context) { + fn cancel_pending_turn_entries(&mut self, cx: &mut Context) { + self.mark_pending_entries_as_canceled(RequestPermissionOutcome::Cancelled, cx); + self.cancel_outstanding_elicitations(cx); + } + + fn mark_pending_entries_as_canceled( + &mut self, + permission_outcome: RequestPermissionOutcome, + cx: &mut Context, + ) { for (ix, entry) in self.entries.iter_mut().enumerate() { match entry { AgentThreadEntry::ToolCall(call) => { @@ -3125,7 +4038,16 @@ impl AcpThread { | ToolCallStatus::InProgress ); if cancel { - call.status = ToolCallStatus::Canceled; + let previous_status = + mem::replace(&mut call.status, ToolCallStatus::Canceled); + if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = + previous_status + && respond_tx.send(permission_outcome.clone()).is_err() + { + log::debug!( + "Permission request closed before cancellation was delivered" + ); + } cx.emit(AcpThreadEvent::EntryUpdated(ix)); } } @@ -3140,13 +4062,27 @@ impl AcpThread { } } + fn cancel_outstanding_elicitations(&mut self, cx: &mut Context) { + for ix in 0..self.entries.len() { + let Some(AgentThreadEntry::Elicitation(elicitation_id)) = self.entries.get(ix) else { + continue; + }; + if self + .elicitations + .cancel_elicitation_by_id(elicitation_id, true) + { + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + } + } + /// Restores the git working tree to the state at the given checkpoint (if one exists) pub fn restore_checkpoint( &mut self, - id: UserMessageId, + client_id: ClientUserMessageId, cx: &mut Context, ) -> Task> { - let Some((_, message)) = self.user_message_mut(&id) else { + let Some((_, message)) = self.user_message_mut(&client_id) else { return Task::ready(Err(anyhow!("message not found"))); }; @@ -3157,7 +4093,7 @@ impl AcpThread { // Cancel any in-progress generation before restoring let cancel_task = self.cancel(cx); - let rewind = self.rewind(id.clone(), cx); + let rewind = self.rewind(client_id.clone(), cx); let git_store = self.project.read(cx).git_store().clone(); cx.spawn(async move |_, cx| { @@ -3176,7 +4112,11 @@ impl AcpThread { /// Rewinds this thread to before the entry at `index`, removing it and all /// subsequent entries while rejecting any action_log changes made from that point. /// Unlike `restore_checkpoint`, this method does not restore from git. - pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context) -> Task> { + pub fn rewind( + &mut self, + client_id: ClientUserMessageId, + cx: &mut Context, + ) -> Task> { let Some(truncate) = self.connection.truncate(&self.session_id, cx) else { return Task::ready(Err(anyhow!("not supported"))); }; @@ -3184,9 +4124,9 @@ impl AcpThread { Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); let telemetry = ActionLogTelemetry::from(&*self); cx.spawn(async move |this, cx| { - cx.update(|cx| truncate.run(id.clone(), cx)).await?; + cx.update(|cx| truncate.run(client_id.clone(), cx)).await?; this.update(cx, |this, cx| { - if let Some((ix, _)) = this.user_message_mut(&id) { + if let Some((ix, _)) = this.user_message_mut(&client_id) { // Collect all terminals from entries that will be removed let terminals_to_remove: Vec = this.entries[ix..] .iter() @@ -3223,13 +4163,11 @@ impl AcpThread { let git_store = self.project.read(cx).git_store().clone(); - let Some((user_message_id, checkpoint)) = - self.last_user_message().and_then(|(_, message)| { - let id = message.id.clone()?; - let checkpoint = message.checkpoint.as_ref()?; - Some((id, checkpoint)) - }) - else { + let Some((client_id, checkpoint)) = self.last_user_message().and_then(|(_, message)| { + let id = message.client_id.clone()?; + let checkpoint = message.checkpoint.as_ref()?; + Some((id, checkpoint)) + }) else { return Task::ready(Ok(())); }; if checkpoint.show { @@ -3274,7 +4212,7 @@ impl AcpThread { let Some((ix, message)) = this.last_user_message() else { return; }; - if message.id.as_ref() != Some(&user_message_id) { + if message.client_id.as_ref() != Some(&client_id) { return; } if let Some(checkpoint) = message.checkpoint.as_mut() @@ -3295,7 +4233,7 @@ impl AcpThread { let Some((_, message)) = self.last_user_message() else { return Task::ready(Ok(())); }; - let Some(user_message_id) = message.id.clone() else { + let Some(client_id) = message.client_id.clone() else { return Task::ready(Ok(())); }; let Some(checkpoint) = message.checkpoint.as_ref() else { @@ -3313,15 +4251,19 @@ impl AcpThread { return Ok(()); }; - let equal = git_store + let Some(equal) = git_store .update(cx, |git, cx| { git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx) }) .await - .unwrap_or(true); + .context("failed to compare checkpoints") + .log_err() + else { + return Ok(()); + }; this.update(cx, |this, cx| { - if let Some((ix, message)) = this.user_message_mut(&user_message_id) { + if let Some((ix, message)) = this.user_message_mut(&client_id) { if let Some(checkpoint) = message.checkpoint.as_mut() { checkpoint.show = !equal; cx.emit(AcpThreadEvent::EntryUpdated(ix)); @@ -3347,10 +4289,13 @@ impl AcpThread { }) } - fn user_message_mut(&mut self, id: &UserMessageId) -> Option<(usize, &mut UserMessage)> { + fn user_message_mut( + &mut self, + client_id: &ClientUserMessageId, + ) -> Option<(usize, &mut UserMessage)> { self.entries.iter_mut().enumerate().find_map(|(ix, entry)| { if let AgentThreadEntry::UserMessage(message) = entry { - if message.id.as_ref() == Some(id) { + if message.client_id.as_ref() == Some(client_id) { Some((ix, message)) } else { None @@ -3567,12 +4512,16 @@ impl AcpThread { let project = self.project.clone(); let language_registry = project.read(cx).languages().clone(); let is_windows = project.read(cx).path_style(cx).is_windows(); + // Headless hosts (e.g. the eval CLI) have no controlling TTY, so PTY + // setup fails with `ENOTTY`. Run the command non-interactively and + // without a PTY in that case. + let headless = HeadlessTerminal::is_enabled(cx); 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| { - let mut env = env.await; + let env = env.await; let shell = project .update(cx, |project, cx| { project @@ -3580,73 +4529,73 @@ impl AcpThread { .and_then(|r| r.read(cx).default_system_shell()) }) .unwrap_or_else(|| get_default_system_shell_preferring_bash()); - // Spawn the network proxy (if the wrap requests network) before - // generating the sandbox policy, since the policy must pin the - // child to the proxy's loopback port. This also injects the - // child's proxy env vars. On Windows the WSL sandbox can only - // toggle the network wholesale, so this never spawns a proxy - // there, but it still resolves the allow/deny `network_policy`. - let (proxy_handle, network_policy) = - setup_network_proxy(sandbox_wrap.as_ref(), &mut env, cx)?; + // The sandbox owns the network proxy (for restricted-network + // policies) and injects the child's proxy env vars, returning + // the env to spawn with. On Windows, restricted host access is + // rejected inside the sandbox before command preparation. #[cfg(target_os = "windows")] - let (task_command, task_args, sandbox_config, spawn_cwd) = - if let Some(sandbox_wrap) = sandbox_wrap { - // Run the wrap on a background task: it probes WSL - // (possibly booting its VM) and stats UNC paths, - // either of which can take seconds and must not block - // the foreground thread this task runs on. Bound it - // with a timeout so a wedged `wsl.exe` can't stall - // this command forever; on timeout, dropping the task - // cancels the wrap future, which kills any in-flight - // `wsl.exe` child (see `windows_wsl::wrap_invocation`). - let wrap = cx.background_spawn(apply_windows_wsl_sandbox_wrap( - command.clone(), - args.clone(), + let (task_command, task_args, task_env, sandbox, spawn_cwd) = + if sandbox_wrap.is_some() { + let (task_command, task_args) = task::ShellBuilder::new( + &Shell::Program("/bin/sh".to_string()), + false, + ) + .non_interactive() + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + let wrap = cx.background_spawn(prepare_sandbox_wrap( + task_command, + task_args, cwd.clone(), sandbox_wrap, - network_policy, - env.clone(), + env, )); let timeout = cx.background_executor().timer(WSL_SANDBOX_WRAP_TIMEOUT); - let (task_command, task_args, sandbox_config) = futures::select_biased! { + let (task_command, task_args, task_env, sandbox) = futures::select_biased! { result = wrap.fuse() => result?, - // A wedged `wsl.exe` is an environment failure, so - // surface it as `WslSandboxUnavailable` (like the - // probe failures inside `wrap_invocation`) so the - // agent offers the run-unsandboxed fallback rather - // than returning a bad request to the model. _ = timeout.fuse() => return Err(anyhow::Error::new( - sandbox::windows_wsl::WslSandboxUnavailable::new(format!( - "WSL did not respond within {} seconds while preparing the \ - sandboxed command", + sandbox::SandboxError::WslUnavailable(format!( + "WSL did not respond within {} seconds while preparing the sandboxed command", WSL_SANDBOX_WRAP_TIMEOUT.as_secs() )), )), }; - (task_command, task_args, sandbox_config, None) + (task_command, task_args, task_env, sandbox, None) } else { - let (task_command, task_args) = - ShellBuilder::new(&Shell::Program(shell), is_windows) - .redirect_stdin_to_dev_null() - .build(Some(command.clone()), &args); - (task_command, task_args, None, cwd.clone()) + // No sandbox wrap means we're running unsandboxed, and + // on Windows that deliberately changes the shell: the + // sandboxed path runs under WSL's Linux bash, but this + // fallback uses the host's `shell` against the native cwd. + let mut builder = ShellBuilder::new(&Shell::Program(shell), is_windows); + if headless { + builder = builder.non_interactive(); + } + let (task_command, task_args) = builder + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + (task_command, task_args, env, None, cwd.clone()) }; #[cfg(not(target_os = "windows"))] - let (task_command, task_args, sandbox_config, spawn_cwd) = { - let (task_command, task_args) = - ShellBuilder::new(&Shell::Program(shell), is_windows) - .redirect_stdin_to_dev_null() - .build(Some(command.clone()), &args); - let (task_command, task_args, sandbox_config) = apply_sandbox_wrap( - task_command, - task_args, - cwd.as_deref(), - sandbox_wrap, - network_policy, - )?; - (task_command, task_args, sandbox_config, cwd.clone()) + let (task_command, task_args, task_env, sandbox, spawn_cwd) = { + let mut builder = ShellBuilder::new(&Shell::Program(shell), is_windows); + if headless { + builder = builder.non_interactive(); + } + let (task_command, task_args) = builder + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + let (task_command, task_args, task_env, sandbox) = cx + .background_spawn(prepare_sandbox_wrap( + task_command, + task_args, + cwd.clone(), + sandbox_wrap, + env, + )) + .await?; + (task_command, task_args, task_env, sandbox, cwd.clone()) }; let terminal = project .update(cx, |project, cx| { @@ -3655,7 +4604,7 @@ impl AcpThread { command: Some(task_command), args: task_args, cwd: spawn_cwd, - env, + env: task_env, ..Default::default() }, cx, @@ -3671,8 +4620,7 @@ impl AcpThread { output_byte_limit.map(|l| l as usize), terminal, language_registry, - sandbox_config, - proxy_handle, + sandbox, cx, ) })) @@ -3726,7 +4674,19 @@ impl AcpThread { } pub fn to_markdown(&self, cx: &App) -> String { - self.entries.iter().map(|e| e.to_markdown(cx)).collect() + self.entries + .iter() + .map(|entry| match entry { + AgentThreadEntry::Elicitation(elicitation_id) => self + .elicitations + .elicitation(elicitation_id) + .map(|(_, elicitation)| { + format!("## Input Requested\n\n{}\n\n", elicitation.request.message) + }) + .unwrap_or_else(|| entry.to_markdown(cx)), + _ => entry.to_markdown(cx), + }) + .collect() } pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context) { @@ -3755,7 +4715,6 @@ impl AcpThread { // External terminal providers manage their own sandboxing // (if any). We don't wrap their commands. None, - None, cx, ) }); @@ -3806,7 +4765,8 @@ impl AcpThread { } if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) { - entity.update(cx, |_term, cx| { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, _| inner.shrink_to_used()); cx.notify(); }); } @@ -3842,7 +4802,8 @@ impl AcpThread { status, } => { if let Some(entity) = self.terminals.get(&terminal_id) { - entity.update(cx, |_term, cx| { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, _| inner.shrink_to_used()); cx.notify(); }); } else { @@ -4036,11 +4997,13 @@ fn format_shell_output(output: &serde_json::Value) -> Option { mod tests { use super::*; use anyhow::anyhow; + use feature_flags::FeatureFlag as _; use futures::stream::StreamExt as _; use futures::{channel::mpsc, future::LocalBoxFuture, select}; + use gpui::UpdateGlobal as _; use gpui::{App, AsyncApp, TestAppContext, WeakEntity}; use indoc::indoc; - use project::{AgentId, FakeFs, Fs}; + use project::{AgentId, FakeFs, Fs, RemoveOptions}; use rand::{distr, prelude::*}; use serde_json::json; use settings::SettingsStore; @@ -4075,38 +5038,275 @@ mod tests { assert_eq!(command_category_from_meta(&Some(unknown)), None); } + #[test] + fn client_user_message_id_serializes_as_string() { + let serialized = + serde_json::to_value(ClientUserMessageId::new()).expect("serialize client message id"); + assert!( + serialized.is_string(), + "expected string, got {serialized:?}" + ); + + let deserialized: ClientUserMessageId = + serde_json::from_value(json!("client-id")).expect("deserialize client message id"); + assert_eq!( + serde_json::to_value(deserialized).expect("serialize client message id"), + json!("client-id") + ); + } + fn init_test(cx: &mut TestAppContext) { env_logger::try_init().ok(); cx.update(|cx| { - let settings_store = SettingsStore::test(cx); + let mut settings_store = SettingsStore::test(cx); + settings_store.register_setting::(); cx.set_global(settings_store); }); } + fn enable_acp_beta(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + } + + fn set_acp_beta_override(value: &str, cx: &mut TestAppContext) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert(AcpBetaFeatureFlag::NAME.to_string(), value.to_string()); + }); + }); + }); + } + #[test] - fn sandbox_authorization_details_deserialize_legacy_network_bool() { - // Older builds persisted `network: bool`; the `alias` on - // `network_all_hosts` must keep those details rendering as a - // network request rather than silently dropping it. - let details: SandboxAuthorizationDetails = - serde_json::from_value(json!({ "network": true })).unwrap(); - assert!(details.network_all_hosts); - assert!(details.network_hosts.is_empty()); + fn text_resource_markdown_uses_mime_type_for_code_blocks() { + let shell = acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview") + .mime_type("text/x-shellscript".to_string()); + assert_eq!( + ContentBlock::text_resource_markdown(&shell), + "```sh\necho 'hello from exec test'\n```" + ); - let details: SandboxAuthorizationDetails = - serde_json::from_value(json!({ "network": false })).unwrap(); - assert!(!details.network_all_hosts); + let markdown = acp::TextResourceContents::new("**approval** requested", "tool://preview") + .mime_type("text/markdown".to_string()); + assert_eq!( + ContentBlock::text_resource_markdown(&markdown), + "**approval** requested" + ); + + let plain = acp::TextResourceContents::new("plain preview", "tool://preview") + .mime_type("text/plain".to_string()); + assert_eq!( + ContentBlock::text_resource_markdown(&plain), + "```\nplain preview\n```" + ); + + let cpp = acp::TextResourceContents::new("int main() {}", "tool://preview") + .mime_type("text/x-c++; charset=utf-8".to_string()); + assert_eq!( + ContentBlock::text_resource_markdown(&cpp), + "```cpp\nint main() {}\n```" + ); + + let untyped = acp::TextResourceContents::new("# plain preview", "tool://preview"); + assert_eq!( + ContentBlock::text_resource_markdown(&untyped), + "```\n# plain preview\n```" + ); } #[gpui::test] - async fn test_terminal_output_buffered_before_created_renders(cx: &mut gpui::TestAppContext) { + async fn test_tool_call_content_preserves_embedded_text_resource( + cx: &mut gpui::TestAppContext, + ) { init_test(cx); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - let connection = Rc::new(FakeAgentConnection::new()); - let thread = cx - .update(|cx| { + cx.update(|cx| { + let language_registry = + Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let content = acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview") + .mime_type("text/x-shellscript".to_string()), + ), + )); + + let block = ContentBlock::new_tool_call_content( + content, + &language_registry, + PathStyle::local(), + cx, + ); + + let ContentBlock::EmbeddedResource { resource, markdown } = &block else { + panic!("expected embedded resource block, got {block:?}"); + }; + match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text) => { + assert_eq!(text.text, "echo 'hello from exec test'"); + assert_eq!(text.uri, "tool://preview"); + assert_eq!(text.mime_type.as_deref(), Some("text/x-shellscript")); + } + other => panic!("expected text resource contents, got {other:?}"), + } + + let markdown = markdown + .as_ref() + .expect("text resources should have renderable markdown") + .read(cx) + .source() + .to_string(); + assert_eq!(markdown, "```sh\necho 'hello from exec test'\n```"); + assert_eq!( + block.to_markdown(cx), + "```sh\necho 'hello from exec test'\n```" + ); + assert_eq!(block.text_content(cx), Some("echo 'hello from exec test'")); + + let untyped = ContentBlock::new_tool_call_content( + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new("# plain preview", "tool://preview"), + ), + )), + &language_registry, + PathStyle::local(), + cx, + ); + assert_eq!(untyped.to_markdown(cx), "```\n# plain preview\n```"); + assert_eq!(untyped.text_content(cx), Some("# plain preview")); + }); + } + + #[gpui::test] + async fn test_tool_call_content_renders_embedded_image_blob_resource( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx); + + cx.update(|cx| { + let language_registry = + Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let image_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::BlobResourceContents( + acp::BlobResourceContents::new( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "tool://preview.png", + ) + .mime_type("image/png".to_string()), + ), + )); + + let block = ContentBlock::new_tool_call_content( + image_blob, + &language_registry, + PathStyle::local(), + cx, + ); + + let ContentBlock::Image { image, dimensions } = &block else { + panic!("expected image block, got {block:?}"); + }; + assert_eq!(image.format(), gpui::ImageFormat::Png); + assert_eq!( + dimensions.as_ref().map(|size| (size.width, size.height)), + Some((1, 1)) + ); + assert_eq!(block.to_markdown(cx), "`Image`"); + assert_eq!(block.text_content(cx), None); + }); + } + + #[gpui::test] + async fn test_tool_call_content_falls_back_for_non_image_blob_resource( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx); + + cx.update(|cx| { + let language_registry = + Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let archive_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::BlobResourceContents( + acp::BlobResourceContents::new("not an image", "tool://archive.bin") + .mime_type("application/octet-stream".to_string()), + ), + )); + + let block = ContentBlock::new_tool_call_content( + archive_blob, + &language_registry, + PathStyle::local(), + cx, + ); + + let ContentBlock::EmbeddedResource { resource, markdown } = &block else { + panic!("expected embedded resource block, got {block:?}"); + }; + assert!(markdown.is_none()); + match &resource.resource { + acp::EmbeddedResourceResource::BlobResourceContents(blob) => { + assert_eq!(blob.uri, "tool://archive.bin"); + assert_eq!(blob.mime_type.as_deref(), Some("application/octet-stream")); + } + other => panic!("expected blob resource contents, got {other:?}"), + } + assert_eq!(block.to_markdown(cx), "tool://archive.bin"); + assert_eq!(block.text_content(cx), None); + + let invalid_image_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::BlobResourceContents( + acp::BlobResourceContents::new("not-base64", "tool://preview.png") + .mime_type("image/png".to_string()), + ), + )); + let invalid = ContentBlock::new_tool_call_content( + invalid_image_blob, + &language_registry, + PathStyle::local(), + cx, + ); + let ContentBlock::EmbeddedResource { resource, markdown } = &invalid else { + panic!("expected embedded resource block, got {invalid:?}"); + }; + assert!(markdown.is_none()); + assert_eq!( + ContentBlock::embedded_resource_label(resource), + "tool://preview.png" + ); + assert_eq!(invalid.to_markdown(cx), "tool://preview.png"); + }); + } + + #[test] + fn sandbox_authorization_details_deserialize_legacy_network_bool() { + // Older builds persisted `network: bool`; the `alias` on + // `network_all_hosts` must keep those details rendering as a + // network request rather than silently dropping it. + let details: SandboxAuthorizationDetails = + serde_json::from_value(json!({ "network": true })).unwrap(); + assert!(details.network_all_hosts); + assert!(details.network_hosts.is_empty()); + + let details: SandboxAuthorizationDetails = + serde_json::from_value(json!({ "network": false })).unwrap(); + assert!(!details.network_all_hosts); + } + + #[gpui::test] + async fn test_terminal_output_buffered_before_created_renders(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { connection.new_session( project, PathList::new(&[std::path::Path::new(path!("/test"))]), @@ -4167,6 +5367,83 @@ mod tests { ); } + #[gpui::test] + async fn test_terminal_exit_preserves_visible_scrollback(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) + .await + .unwrap(); + + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); + let lower = cx.new(|cx| { + let builder = ::terminal::TerminalBuilder::new_display_only( + ::terminal::terminal_settings::CursorShape::default(), + ::terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ); + builder.subscribe(cx) + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: "Buffered Test".to_string(), + cwd: None, + output_byte_limit: None, + terminal: lower.clone(), + }, + cx, + ); + }); + + let mut output = String::new(); + for line in 0..15_000 { + output.push_str(&format!("line {line}\n")); + } + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data: output.into_bytes(), + }, + cx, + ); + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id.clone(), + status: acp::TerminalExitStatus::new().exit_code(0), + }, + cx, + ); + }); + + let content = thread.read_with(cx, |thread, cx| { + let term = thread.terminal(terminal_id.clone()).unwrap(); + term.read_with(cx, |term, cx| term.inner().read(cx).get_content()) + }); + + assert!( + content.contains("line 14999"), + "expected output to remain visible after terminal exit, got: {content}" + ); + } + #[gpui::test] async fn test_terminal_output_and_exit_buffered_before_created(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -4417,7 +5694,8 @@ mod tests { thread.update(cx, |thread, cx| { assert_eq!(thread.entries.len(), 1); if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] { - assert_eq!(user_msg.id, None); + assert_eq!(user_msg.protocol_id, None); + assert_eq!(user_msg.client_id, None); assert_eq!(user_msg.content.to_markdown(cx), "Hello, "); } else { panic!("Expected UserMessage"); @@ -4425,7 +5703,7 @@ mod tests { }); // Test appending to existing user message - let message_1_id = UserMessageId::new(); + let message_1_id = ClientUserMessageId::new(); thread.update(cx, |thread, cx| { thread.push_user_content_block(Some(message_1_id.clone()), "world!".into(), cx); }); @@ -4433,7 +5711,8 @@ mod tests { thread.update(cx, |thread, cx| { assert_eq!(thread.entries.len(), 1); if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] { - assert_eq!(user_msg.id, Some(message_1_id)); + assert_eq!(user_msg.protocol_id, None); + assert_eq!(user_msg.client_id, Some(message_1_id)); assert_eq!(user_msg.content.to_markdown(cx), "Hello, world!"); } else { panic!("Expected UserMessage"); @@ -4445,7 +5724,7 @@ mod tests { thread.push_assistant_content_block("Assistant response".into(), false, cx); }); - let message_2_id = UserMessageId::new(); + let message_2_id = ClientUserMessageId::new(); thread.update(cx, |thread, cx| { thread.push_user_content_block( Some(message_2_id.clone()), @@ -4457,7 +5736,8 @@ mod tests { thread.update(cx, |thread, cx| { assert_eq!(thread.entries.len(), 3); if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[2] { - assert_eq!(user_msg.id, Some(message_2_id)); + assert_eq!(user_msg.protocol_id, None); + assert_eq!(user_msg.client_id, Some(message_2_id)); assert_eq!(user_msg.content.to_markdown(cx), "New user message"); } else { panic!("Expected UserMessage at index 2"); @@ -4466,38 +5746,14 @@ mod tests { } #[gpui::test] - async fn test_thinking_concatenation(cx: &mut gpui::TestAppContext) { + async fn test_user_message_chunks_use_protocol_message_id_boundaries( + cx: &mut gpui::TestAppContext, + ) { init_test(cx); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let connection = Rc::new(FakeAgentConnection::new().on_user_message( - |_, thread, mut cx| { - async move { - thread.update(&mut cx, |thread, cx| { - thread - .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( - "Thinking ".into(), - )), - cx, - ) - .unwrap(); - thread - .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( - "hard!".into(), - )), - cx, - ) - .unwrap(); - })?; - Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) - } - .boxed_local() - }, - )); - + let connection = Rc::new(FakeAgentConnection::new()); let thread = cx .update(|cx| { connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) @@ -4505,27 +5761,330 @@ mod tests { .await .unwrap(); - thread - .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx)) - .await - .unwrap(); - - let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); - assert_eq!( - output, - indoc! {r#" - ## User + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::UserMessageChunk( + acp::ContentChunk::new("First ".into()).message_id("msg_user_1"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::UserMessageChunk( + acp::ContentChunk::new("message".into()).message_id("msg_user_1"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::UserMessageChunk( + acp::ContentChunk::new("Second message".into()).message_id("msg_user_2"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::UserMessageChunk( + acp::ContentChunk::new("Echo".into()).message_id("msg_user_3"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::UserMessageChunk( + acp::ContentChunk::new("Echo".into()).message_id("msg_user_3"), + ), + cx, + ) + .unwrap(); + }); - Hello from Zed! + thread.update(cx, |thread, cx| { + assert_eq!(thread.entries.len(), 3); - ## Assistant + let AgentThreadEntry::UserMessage(first_message) = &thread.entries[0] else { + panic!("expected first entry to be a user message") + }; + assert_eq!(first_message.content.to_markdown(cx), "First message"); + assert_eq!( + first_message + .protocol_id + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some("msg_user_1") + ); - - Thinking hard! - + let AgentThreadEntry::UserMessage(second_message) = &thread.entries[1] else { + panic!("expected second entry to be a user message") + }; + assert_eq!(second_message.content.to_markdown(cx), "Second message"); + assert_eq!( + second_message + .protocol_id + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some("msg_user_2") + ); - "#} - ); + let AgentThreadEntry::UserMessage(third_message) = &thread.entries[2] else { + panic!("expected third entry to be a user message") + }; + assert_eq!(third_message.content.to_markdown(cx), "EchoEcho"); + assert_eq!( + third_message + .protocol_id + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some("msg_user_3") + ); + }); + } + + #[gpui::test] + async fn test_protocol_user_chunk_does_not_merge_into_optimistic_prompt( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + thread.update(cx, |thread, cx| { + thread.push_user_content_block_with_protocol_id( + None, + true, + None, + "Typed prompt".into(), + false, + cx, + ); + thread + .handle_session_update( + acp::SessionUpdate::UserMessageChunk( + acp::ContentChunk::new("Agent user chunk".into()) + .message_id("agent_user_chunk"), + ), + cx, + ) + .unwrap(); + }); + + thread.update(cx, |thread, cx| { + assert_eq!(thread.entries.len(), 2); + + let AgentThreadEntry::UserMessage(optimistic_message) = &thread.entries[0] else { + panic!("expected first entry to be optimistic user message") + }; + assert!(optimistic_message.is_optimistic); + assert_eq!(optimistic_message.content.to_markdown(cx), "Typed prompt"); + assert!(optimistic_message.protocol_id.is_none()); + assert!(optimistic_message.client_id.is_none()); + + let AgentThreadEntry::UserMessage(agent_message) = &thread.entries[1] else { + panic!("expected second entry to be protocol user chunk") + }; + assert!(!agent_message.is_optimistic); + assert_eq!(agent_message.content.to_markdown(cx), "Agent user chunk"); + assert_eq!( + agent_message + .protocol_id + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some("agent_user_chunk") + ); + }); + } + + #[gpui::test] + async fn test_assistant_chunks_use_protocol_message_id_boundaries( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AgentThoughtChunk( + acp::ContentChunk::new("Thinking ".into()).message_id("msg_thought_1"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::AgentThoughtChunk( + acp::ContentChunk::new("hard".into()).message_id("msg_thought_1"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::AgentThoughtChunk( + acp::ContentChunk::new("A separate thought".into()) + .message_id("msg_thought_2"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Answer ".into()).message_id("msg_agent_1"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("done".into()).message_id("msg_agent_1"), + ), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Follow-up".into()).message_id("msg_agent_2"), + ), + cx, + ) + .unwrap(); + }); + + thread.update(cx, |thread, cx| { + assert_eq!(thread.entries.len(), 1); + let AgentThreadEntry::AssistantMessage(message) = &thread.entries[0] else { + panic!("expected assistant entry") + }; + assert_eq!(message.chunks.len(), 4); + + let AssistantMessageChunk::Thought { id, block } = &message.chunks[0] else { + panic!("expected first chunk to be a thought") + }; + assert_eq!(block.to_markdown(cx), "Thinking hard"); + assert_eq!( + id.as_ref().map(ToString::to_string).as_deref(), + Some("msg_thought_1") + ); + + let AssistantMessageChunk::Thought { id, block } = &message.chunks[1] else { + panic!("expected second chunk to be a thought") + }; + assert_eq!(block.to_markdown(cx), "A separate thought"); + assert_eq!( + id.as_ref().map(ToString::to_string).as_deref(), + Some("msg_thought_2") + ); + + let AssistantMessageChunk::Message { id, block } = &message.chunks[2] else { + panic!("expected third chunk to be a message") + }; + assert_eq!(block.to_markdown(cx), "Answer done"); + assert_eq!( + id.as_ref().map(ToString::to_string).as_deref(), + Some("msg_agent_1") + ); + + let AssistantMessageChunk::Message { id, block } = &message.chunks[3] else { + panic!("expected fourth chunk to be a message") + }; + assert_eq!(block.to_markdown(cx), "Follow-up"); + assert_eq!( + id.as_ref().map(ToString::to_string).as_deref(), + Some("msg_agent_2") + ); + }); + } + + #[gpui::test] + async fn test_thinking_concatenation(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new().on_user_message( + |_, thread, mut cx| { + async move { + thread.update(&mut cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "Thinking ".into(), + )), + cx, + ) + .unwrap(); + thread + .handle_session_update( + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "hard!".into(), + )), + cx, + ) + .unwrap(); + })?; + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + }, + )); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + thread + .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx)) + .await + .unwrap(); + + let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); + assert_eq!( + output, + indoc! {r#" + ## User + + Hello from Zed! + + ## Assistant + + + Thinking hard! + + + "#} + ); } /// `send_command` runs the turn (the connection receives the typed command) @@ -4610,27 +6169,29 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let connection = Rc::new(FakeAgentConnection::new().on_user_message( - |request, thread, mut cx| { - async move { - let prompt = request.prompt.first().cloned().unwrap_or_else(|| "".into()); + let connection = Rc::new( + FakeAgentConnection::new() + .without_truncate_support() + .on_user_message(|request, thread, mut cx| { + async move { + let prompt = request.prompt.first().cloned().unwrap_or_else(|| "".into()); - thread.update(&mut cx, |thread, cx| { - thread - .handle_session_update( - acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new( - prompt, - )), - cx, - ) - .unwrap(); - })?; + thread.update(&mut cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new( + prompt, + )), + cx, + ) + .unwrap(); + })?; - Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) - } - .boxed_local() - }, - )); + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + }), + ); let thread = cx .update(|cx| { @@ -4646,6 +6207,14 @@ mod tests { let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); assert_eq!(output.matches("Hello from Zed!").count(), 1); + thread.read_with(cx, |thread, _cx| { + let Some(AgentThreadEntry::UserMessage(message)) = thread.entries.first() else { + panic!("expected optimistic user message"); + }; + assert_eq!(message.protocol_id, None); + assert_eq!(message.client_id, None); + assert!(message.is_optimistic); + }); } #[gpui::test] @@ -5169,7 +6738,8 @@ mod tests { assert_eq!(outcome.option_id, allow_option_id); assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce); } - RequestPermissionOutcome::Cancelled => { + RequestPermissionOutcome::Cancelled + | RequestPermissionOutcome::InterruptedByFollowUp => { panic!("permission request should remain open after duplicate tool call update") } } @@ -5281,7 +6851,8 @@ mod tests { assert_eq!(outcome.option_id, acp::PermissionOptionId::new("allow")); assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce); } - RequestPermissionOutcome::Cancelled => { + RequestPermissionOutcome::Cancelled + | RequestPermissionOutcome::InterruptedByFollowUp => { panic!("resolved permission request should select an outcome") } } @@ -5365,14 +6936,15 @@ mod tests { assert_eq!(outcome.option_id, acp::PermissionOptionId::new("allow")); assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce); } - RequestPermissionOutcome::Cancelled => { + RequestPermissionOutcome::Cancelled + | RequestPermissionOutcome::InterruptedByFollowUp => { panic!("permission request should resolve after authorization") } } } #[gpui::test] - async fn test_terminal_tool_call_update_closes_open_permission_request( + async fn test_cancel_tool_call_authorization_resolves_permission_request( cx: &mut TestAppContext, ) { init_test(cx); @@ -5387,7 +6959,7 @@ mod tests { .await .unwrap(); - let tool_call_id = acp::ToolCallId::new("toolu_01completed_while_waiting"); + let tool_call_id = acp::ToolCallId::new("toolu_01cancelled_permission"); let permission_task = thread .update(cx, |thread, cx| { thread.request_tool_call_authorization( @@ -5406,59 +6978,115 @@ mod tests { }) .unwrap(); - thread - .update(cx, |thread, cx| { - thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( - tool_call_id.clone(), - acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), - )), - cx, - ) - }) - .unwrap(); + thread.update(cx, |thread, cx| { + thread.cancel_tool_call_authorization(&tool_call_id, cx); + }); thread.read_with(cx, |thread, _cx| { let (_, tool_call) = thread .tool_call(&tool_call_id) .expect("tool call should exist"); - assert!(matches!(tool_call.status, ToolCallStatus::Completed)); + assert!(matches!(tool_call.status, ToolCallStatus::Canceled)); }); match permission_task.await { RequestPermissionOutcome::Cancelled => {} - RequestPermissionOutcome::Selected(_) => { - panic!("terminal tool call update should close pending permission request") + RequestPermissionOutcome::InterruptedByFollowUp + | RequestPermissionOutcome::Selected(_) => { + panic!("cancelled permission request should not select an outcome") } } } #[gpui::test] - async fn test_no_pending_edits_if_tool_calls_are_completed(cx: &mut TestAppContext) { + async fn test_terminal_tool_call_update_closes_open_permission_request( + cx: &mut TestAppContext, + ) { init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree(path!("/test"), json!({})).await; - let project = Project::test(fs, [path!("/test").as_ref()], cx).await; - let connection = Rc::new(FakeAgentConnection::new().on_user_message({ - move |_, thread, mut cx| { - async move { - thread - .update(&mut cx, |thread, cx| { - thread.handle_session_update( - 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() + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let tool_call_id = acp::ToolCallId::new("toolu_01completed_while_waiting"); + let permission_task = thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + acp::ToolCall::new(tool_call_id.clone(), "Needs permission") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::Pending) + .into(), + PermissionOptions::Flat(vec![acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + )]), + AuthorizationKind::PermissionGrant, + cx, + ) + }) + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), + cx, + ) + }) + .unwrap(); + + thread.read_with(cx, |thread, _cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert!(matches!(tool_call.status, ToolCallStatus::Completed)); + }); + + match permission_task.await { + RequestPermissionOutcome::Cancelled => {} + RequestPermissionOutcome::InterruptedByFollowUp + | RequestPermissionOutcome::Selected(_) => { + panic!("terminal tool call update should close pending permission request") + } + } + } + + #[gpui::test] + async fn test_no_pending_edits_if_tool_calls_are_completed(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/test"), json!({})).await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + move |_, thread, mut cx| { + async move { + thread + .update(&mut cx, |thread, cx| { + thread.handle_session_update( + 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::new(acp::StopReason::EndTurn)) } @@ -5638,7 +7266,7 @@ mod tests { let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else { panic!("unexpected entries {:?}", thread.entries) }; - thread.restore_checkpoint(message.id.clone().unwrap(), cx) + thread.restore_checkpoint(message.client_id.clone().unwrap(), cx) }) .await .unwrap(); @@ -5873,148 +7501,1454 @@ mod tests { } #[gpui::test] - async fn test_user_prompt_refusal_emits_event(cx: &mut TestAppContext) { + async fn test_user_prompt_refusal_emits_event(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + + let refuse_next = Arc::new(AtomicBool::new(false)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let refuse_next = refuse_next.clone(); + move |_request, _thread, _cx| { + if refuse_next.load(SeqCst) { + async move { Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } + .boxed_local() + } else { + async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } + .boxed_local() + } + } + })); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + // Track if we see a Refusal event + let saw_refusal_event = Arc::new(std::sync::Mutex::new(false)); + let saw_refusal_event_captured = saw_refusal_event.clone(); + thread.update(cx, |_thread, cx| { + cx.subscribe( + &thread, + move |_thread, _event_thread, event: &AcpThreadEvent, _cx| { + if matches!(event, AcpThreadEvent::Refusal) { + *saw_refusal_event_captured.lock().unwrap() = true; + } + }, + ) + .detach(); + }); + + // Send a message that will be refused + refuse_next.store(true, SeqCst); + cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))) + .await + .unwrap(); + + // Verify that a Refusal event WAS emitted for user prompt refusal + assert!( + *saw_refusal_event.lock().unwrap(), + "Refusal event should be emitted for user prompt refusals" + ); + + // Verify the message was truncated (user prompt refusal) + thread.read_with(cx, |thread, cx| { + assert_eq!(thread.to_markdown(cx), ""); + }); + } + + #[gpui::test] + async fn test_refusal(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; + + let refuse_next = Arc::new(AtomicBool::new(false)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let refuse_next = refuse_next.clone(); + move |request, thread, mut cx| { + let refuse_next = refuse_next.clone(); + async move { + if refuse_next.load(SeqCst) { + return Ok(acp::PromptResponse::new(acp::StopReason::Refusal)); + } + + let acp::ContentBlock::Text(content) = &request.prompt[0] else { + panic!("expected text content block"); + }; + thread.update(&mut cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), + cx, + ) + .unwrap(); + })?; + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + } + })); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))) + .await + .unwrap(); + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc! {" + ## User + + hello + + ## Assistant + + HELLO + + "} + ); + }); + + // Simulate refusing the second message. The message should be truncated + // when a user prompt is refused. + refuse_next.store(true, SeqCst); + cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx))) + .await + .unwrap(); + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc! {" + ## User + + hello + + ## Assistant + + HELLO + + "} + ); + }); + } + + async fn new_test_thread(cx: &mut TestAppContext) -> Entity { + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + cx.update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap() + } + + fn only_thread_elicitation(thread: &AcpThread) -> (ElicitationEntryId, &Elicitation) { + let [entry] = thread.entries() else { + panic!("expected one elicitation entry, got {:?}", thread.entries()); + }; + let AgentThreadEntry::Elicitation(id) = entry else { + panic!("expected one elicitation entry, got {:?}", thread.entries()); + }; + let Some((_, elicitation)) = thread.elicitation(id) else { + panic!("missing elicitation entry"); + }; + (id.clone(), elicitation) + } + + fn latest_thread_elicitation(thread: &AcpThread) -> (ElicitationEntryId, &Elicitation) { + let Some(AgentThreadEntry::Elicitation(id)) = thread.entries().last() else { + panic!("expected latest entry to be an elicitation"); + }; + let Some((_, elicitation)) = thread.elicitation(id) else { + panic!("missing elicitation entry"); + }; + (id.clone(), elicitation) + } + + #[gpui::test] + async fn test_elicitation_is_available_without_acp_beta_flag(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec![]); + }); + set_acp_beta_override("off", cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let result = thread.update(cx, |thread, cx| { + thread.request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + }); + + assert!(result.is_ok()); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries(), + [AgentThreadEntry::Elicitation(_)] + )); + }); + } + + #[gpui::test] + async fn test_form_elicitation_accepts_response(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let tool_call_id = acp::ToolCallId::new("tool-1"); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id.clone()) + .tool_call_id(tool_call_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = thread.read_with(cx, |thread, _| { + let (elicitation_id, elicitation) = only_thread_elicitation(thread); + let acp::ElicitationScope::Session(scope) = elicitation.request.scope() else { + panic!("expected session-scoped elicitation"); + }; + assert_eq!(scope.tool_call_id.as_ref(), Some(&tool_call_id)); + elicitation_id + }); + + let expected_content = std::collections::BTreeMap::from([( + "name".to_string(), + acp::ElicitationContentValue::from("Ada"), + )]); + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(expected_content.clone()), + )), + cx, + ); + }); + + let response = response_task.await; + assert_eq!( + response.action, + acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(expected_content) + ) + ); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Accepted)); + }); + } + + #[gpui::test] + async fn test_url_elicitation_can_be_completed(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = thread.read_with(cx, |thread, _| { + let (entry_id, _) = only_thread_elicitation(thread); + entry_id + }); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!( + elicitation.status, + ElicitationStatus::Pending { .. } + )); + }); + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Completed)); + }); + } + + #[gpui::test] + async fn test_idle_cancel_cancels_accepted_url_elicitation(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = thread.read_with(cx, |thread, _| { + let (entry_id, _) = only_thread_elicitation(thread); + entry_id + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + + thread.update(cx, |thread, cx| { + thread.cancel(cx).detach(); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_cancel_accepted_url_elicitation_marks_canceled(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = thread.read_with(cx, |thread, _| { + let (entry_id, _) = only_thread_elicitation(thread); + entry_id + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Accepted)); + }); + + thread.update(cx, |thread, cx| { + thread.cancel(cx).detach(); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_turn_cancel_cancels_accepted_url_elicitation_from_previous_turn( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let prompt_count = Rc::new(RefCell::new(0usize)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let prompt_count = prompt_count.clone(); + move |_request, _thread, _cx| { + let stop_reason = { + let mut prompt_count = prompt_count.borrow_mut(); + let stop_reason = if *prompt_count == 0 { + acp::StopReason::EndTurn + } else { + acp::StopReason::Cancelled + }; + *prompt_count += 1; + stop_reason + }; + + async move { Ok(acp::PromptResponse::new(stop_reason)) }.boxed_local() + } + })); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("new session should succeed"); + + let response = thread + .update(cx, |thread, cx| thread.send(vec!["first turn".into()], cx)) + .await + .expect("first turn should succeed") + .expect("first turn should return a response"); + assert_eq!(response.stop_reason, acp::StopReason::EndTurn); + + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .expect("url elicitation should be accepted") + }); + + let entry_id = thread.read_with(cx, |thread, _| { + let (entry_id, _) = latest_thread_elicitation(thread); + entry_id + }); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + + let response = thread + .update(cx, |thread, cx| thread.send(vec!["second turn".into()], cx)) + .await + .expect("second turn should succeed") + .expect("second turn should return a response"); + assert_eq!(response.stop_reason, acp::StopReason::Cancelled); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_elicitation_id, cx); + }); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_request_scoped_elicitation_store_accepts_response(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one elicitation entry, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, acp::RequestId::Number(1)); + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Decline); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Declined)); + }); + } + + #[gpui::test] + async fn test_request_elicitation_store_ignores_duplicate_response(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one elicitation entry, got {:?}", + store.elicitations() + ); + }; + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Decline); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Declined)); + }); + } + + #[gpui::test] + async fn test_cancel_session_elicitation_by_id_resolves_cancel(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let (elicitation_id, response_task) = thread.update(cx, |thread, cx| { + thread + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + thread.update(cx, |thread, cx| { + thread.cancel_elicitation(&elicitation_id, cx); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_cancel_pending_session_elicitation_resolves_cancel(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + let elicitation_id = thread.read_with(cx, |thread, _| { + let (elicitation_id, _) = only_thread_elicitation(thread); + elicitation_id + }); + + thread.update(cx, |thread, cx| { + thread.cancel(cx).detach(); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + fn request_test_session_elicitation( + thread: WeakEntity, + session_id: acp::SessionId, + cx: &mut AsyncApp, + ) -> Result> { + thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .map_err(|error| anyhow!(error)) + })? + } + + #[gpui::test] + async fn test_prompt_error_cancels_pending_session_elicitation(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let elicitation_action = Rc::new(RefCell::new(None)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let elicitation_action = elicitation_action.clone(); + move |request, thread, mut cx| { + let elicitation_action = elicitation_action.clone(); + async move { + let response_task = + request_test_session_elicitation(thread, request.session_id, &mut cx)?; + cx.spawn(async move |_cx| { + let response = response_task.await; + *elicitation_action.borrow_mut() = Some(response.action); + }) + .detach(); + + Err(anyhow!("prompt failed")) + } + .boxed_local() + } + })); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("new session should succeed"); + + let result = thread + .update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)) + .await; + + assert!(result.is_err()); + cx.run_until_parked(); + assert_eq!( + *elicitation_action.borrow(), + Some(acp::ElicitationAction::Cancel) + ); + thread.read_with(cx, |thread, _| { + let Some(elicitation) = thread.entries().iter().find_map(|entry| match entry { + AgentThreadEntry::Elicitation(id) => { + thread.elicitation(id).map(|(_, elicitation)| elicitation) + } + _ => None, + }) else { + panic!("expected an elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_max_tokens_cancels_pending_session_elicitation(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let elicitation_action = Rc::new(RefCell::new(None)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let elicitation_action = elicitation_action.clone(); + move |request, thread, mut cx| { + let elicitation_action = elicitation_action.clone(); + async move { + let response_task = + request_test_session_elicitation(thread, request.session_id, &mut cx)?; + cx.spawn(async move |_cx| { + let response = response_task.await; + *elicitation_action.borrow_mut() = Some(response.action); + }) + .detach(); + + Ok(acp::PromptResponse::new(acp::StopReason::MaxTokens)) + } + .boxed_local() + } + })); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("new session should succeed"); + + let result = thread + .update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)) + .await; + + assert!(result.is_err()); + cx.run_until_parked(); + assert_eq!( + *elicitation_action.borrow(), + Some(acp::ElicitationAction::Cancel) + ); + thread.read_with(cx, |thread, _| { + let Some(elicitation) = thread.entries().iter().find_map(|entry| match entry { + AgentThreadEntry::Elicitation(id) => { + thread.elicitation(id).map(|(_, elicitation)| elicitation) + } + _ => None, + }) else { + panic!("expected an elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_cancel_request_scoped_elicitation_resolves_cancel(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let (elicitation_id, response_task) = store.update(cx, |store, cx| { + store + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + store.update(cx, |store, cx| { + store.cancel_elicitation(&elicitation_id, cx); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_request_elicitation_store_cancel_all_resolves_cancel(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + + store.update(cx, |store, cx| { + store.cancel_all(cx); + }); + + assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel); + } + + #[gpui::test] + async fn test_request_elicitation_store_clear_removes_answered_and_cancels_pending( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let first_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + let second_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(2)), + acp::ElicitationSchema::new().string("account", true), + ), + "Provide an account", + ), + cx, + ) + .unwrap() + }); + + let first_elicitation_id = store.read_with(cx, |store, _| { + let [first, _second] = store.elicitations() else { + panic!("expected two elicitations, got {:?}", store.elicitations()); + }; + first.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &first_elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + store.clear(cx); + }); + + assert_eq!( + first_response_task.await.action, + acp::ElicitationAction::Decline + ); + assert_eq!( + second_response_task.await.action, + acp::ElicitationAction::Cancel + ); + store.read_with(cx, |store, _| assert!(store.elicitations().is_empty())); + } + + #[gpui::test] + async fn test_request_elicitation_store_clear_resolved_preserves_outstanding( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let accepted_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + let pending_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(2)), + acp::ElicitationSchema::new().string("account", true), + ), + "Provide an account", + ), + cx, + ) + .unwrap() + }); + let accepted_url_response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(3)), + url_elicitation_id, + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let (accepted_id, pending_id, accepted_url_id) = store.read_with(cx, |store, _| { + let [accepted, pending, accepted_url] = store.elicitations() else { + panic!( + "expected three request-scoped elicitations, got {:?}", + store.elicitations() + ); + }; + ( + accepted.id.clone(), + pending.id.clone(), + accepted_url.id.clone(), + ) + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &accepted_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + store.respond_to_elicitation( + &accepted_url_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + accepted_response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + assert!(matches!( + accepted_url_response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + + let cleared_ids = store.update(cx, |store, cx| store.clear_resolved(cx)); + assert_eq!(cleared_ids, vec![accepted_id]); + store.read_with(cx, |store, _| { + let [pending, accepted_url] = store.elicitations() else { + panic!( + "expected pending and accepted url elicitations, got {:?}", + store.elicitations() + ); + }; + assert_eq!(pending.id, pending_id); + assert!(matches!(pending.status, ElicitationStatus::Pending { .. })); + assert_eq!(accepted_url.id, accepted_url_id); + assert!(matches!(accepted_url.status, ElicitationStatus::Accepted)); + }); + + store.update(cx, |store, cx| store.clear(cx)); + assert_eq!( + pending_response_task.await.action, + acp::ElicitationAction::Cancel + ); + } + + #[gpui::test] + async fn test_request_url_elicitation_store_can_be_completed(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one request-scoped elicitation, got {:?}", + store.elicitations() + ); + }; + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.complete_url_elicitation(&url_elicitation_id, cx); + }); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!( + elicitation.status, + ElicitationStatus::Pending { .. } + )); + }); + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + store.update(cx, |store, cx| { + store.complete_url_elicitation(&url_elicitation_id, cx); + }); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Completed)); + }); + } + + #[gpui::test] + async fn test_request_url_elicitation_store_cancel_all_cancels_accepted_url( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_acp_beta(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + let url_elicitation_id = acp::ElicitationId::new("url-1"); + + let response_task = store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + url_elicitation_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .unwrap() + }); + + let entry_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one elicitation entry, got {:?}", + store.elicitations() + ); + }; + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + assert!(matches!( + response_task.await.action, + acp::ElicitationAction::Accept(_) + )); + store.update(cx, |store, cx| { + store.cancel_all(cx); + }); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + + store.update(cx, |store, cx| { + store.complete_url_elicitation(&url_elicitation_id, cx); + }); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&entry_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Canceled)); + }); + } + + #[gpui::test] + async fn test_cancel_pending_elicitations_preserves_responded_statuses( + cx: &mut TestAppContext, + ) { init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, None, cx).await; - - let refuse_next = Arc::new(AtomicBool::new(false)); - let connection = Rc::new(FakeAgentConnection::new().on_user_message({ - let refuse_next = refuse_next.clone(); - move |_request, _thread, _cx| { - if refuse_next.load(SeqCst) { - async move { Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } - .boxed_local() - } else { - async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } - .boxed_local() - } - } - })); - - let thread = cx - .update(|cx| { - connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) - }) - .await - .unwrap(); - - // Track if we see a Refusal event - let saw_refusal_event = Arc::new(std::sync::Mutex::new(false)); - let saw_refusal_event_captured = saw_refusal_event.clone(); - thread.update(cx, |_thread, cx| { - cx.subscribe( - &thread, - move |_thread, _event_thread, event: &AcpThreadEvent, _cx| { - if matches!(event, AcpThreadEvent::Refusal) { - *saw_refusal_event_captured.lock().unwrap() = true; - } - }, - ) - .detach(); + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() }); - // Send a message that will be refused - refuse_next.store(true, SeqCst); - cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))) - .await - .unwrap(); + let elicitation_id = thread.read_with(cx, |thread, _| { + let (elicitation_id, _) = only_thread_elicitation(thread); + elicitation_id + }); - // Verify that a Refusal event WAS emitted for user prompt refusal - assert!( - *saw_refusal_event.lock().unwrap(), - "Refusal event should be emitted for user prompt refusals" - ); + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + thread.cancel(cx).detach(); + }); - // Verify the message was truncated (user prompt refusal) - thread.read_with(cx, |thread, cx| { - assert_eq!(thread.to_markdown(cx), ""); + assert_eq!(response_task.await.action, acp::ElicitationAction::Decline); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Declined)); }); } #[gpui::test] - async fn test_refusal(cx: &mut TestAppContext) { + async fn test_session_elicitation_ignores_duplicate_response(cx: &mut TestAppContext) { init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree(path!("/"), json!({})).await; - let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; - - let refuse_next = Arc::new(AtomicBool::new(false)); - let connection = Rc::new(FakeAgentConnection::new().on_user_message({ - let refuse_next = refuse_next.clone(); - move |request, thread, mut cx| { - let refuse_next = refuse_next.clone(); - async move { - if refuse_next.load(SeqCst) { - return Ok(acp::PromptResponse::new(acp::StopReason::Refusal)); - } - - let acp::ContentBlock::Text(content) = &request.prompt[0] else { - panic!("expected text content block"); - }; - thread.update(&mut cx, |thread, cx| { - thread - .handle_session_update( - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( - content.text.to_uppercase().into(), - )), - cx, - ) - .unwrap(); - })?; - Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) - } - .boxed_local() - } - })); - let thread = cx - .update(|cx| { - connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) - }) - .await - .unwrap(); - - cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))) - .await - .unwrap(); - thread.read_with(cx, |thread, cx| { - assert_eq!( - thread.to_markdown(cx), - indoc! {" - ## User + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); - hello - - ## Assistant + let response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); - HELLO + let elicitation_id = thread.read_with(cx, |thread, _| { + let (elicitation_id, _) = only_thread_elicitation(thread); + elicitation_id + }); - "} + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, ); }); - // Simulate refusing the second message. The message should be truncated - // when a user prompt is refused. - refuse_next.store(true, SeqCst); - cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx))) - .await - .unwrap(); - thread.read_with(cx, |thread, cx| { - assert_eq!( - thread.to_markdown(cx), - indoc! {" - ## User + assert_eq!(response_task.await.action, acp::ElicitationAction::Decline); + thread.read_with(cx, |thread, _| { + let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else { + panic!("missing elicitation entry"); + }; + assert!(matches!(elicitation.status, ElicitationStatus::Declined)); + }); + } - hello + #[gpui::test] + async fn test_url_elicitation_rejects_non_browser_urls(cx: &mut TestAppContext) { + init_test(cx); + enable_acp_beta(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); + + for invalid_url in [ + "not a url", + "file:///tmp/authorize", + "data:text/plain,authorize", + "mailto:user@example.com", + "zed://settings", + ] { + let result = thread.update(cx, |thread, cx| { + thread.request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(session_id.clone()), + "url-1", + invalid_url, + ), + "Complete this in the browser", + ), + cx, + ) + }); - ## Assistant + let Err(error) = result else { + panic!("{invalid_url} should not be accepted for URL elicitation"); + }; + assert_eq!(error.code, acp::ErrorCode::InvalidParams); + } + thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty())); + } - HELLO + #[gpui::test] + async fn test_elicitation_rejects_unadvertised_mode(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone()); - "} - ); + let result = thread.update(cx, |thread, cx| { + thread.request_elicitation( + acp::CreateElicitationRequest::new( + acp::OtherElicitationMode::new( + "future", + acp::ElicitationSessionScope::new(session_id), + std::collections::BTreeMap::new(), + ), + "Use a future input mode", + ), + cx, + ) + }); + + let Err(error) = result else { + panic!("unadvertised elicitation mode should be rejected"); + }; + assert_eq!(error.code, acp::ErrorCode::InvalidParams); + thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty())); + } + + #[gpui::test] + async fn test_request_elicitation_store_rejects_unadvertised_mode(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + + let result = store.update(cx, |store, cx| { + store.request_elicitation( + acp::CreateElicitationRequest::new( + acp::OtherElicitationMode::new( + "future", + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + std::collections::BTreeMap::new(), + ), + "Use a future input mode", + ), + cx, + ) }); + + let Err(error) = result else { + panic!("unadvertised elicitation mode should be rejected"); + }; + assert_eq!(error.code, acp::ErrorCode::InvalidParams); + store.read_with(cx, |store, _| assert!(store.elicitations().is_empty())); } async fn run_until_first_tool_call( @@ -6157,7 +9091,6 @@ mod tests { fn prompt( &self, - _id: UserMessageId, params: acp::PromptRequest, cx: &mut App, ) -> Task> { @@ -6172,6 +9105,17 @@ mod tests { } } + fn client_user_message_ids( + &self, + _cx: &App, + ) -> Option> { + self.supports_truncate.then(|| { + Rc::new(FakeAgentSessionClientUserMessageIds { + connection: self.clone(), + }) as Rc + }) + } + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} fn truncate( @@ -6217,11 +9161,30 @@ mod tests { } impl AgentSessionTruncate for FakeAgentSessionEditor { - fn run(&self, _message_id: UserMessageId, _cx: &mut App) -> Task> { + fn run( + &self, + _client_user_message_id: ClientUserMessageId, + _cx: &mut App, + ) -> Task> { Task::ready(Ok(())) } } + struct FakeAgentSessionClientUserMessageIds { + connection: FakeAgentConnection, + } + + impl AgentSessionClientUserMessageIds for FakeAgentSessionClientUserMessageIds { + fn prompt( + &self, + _client_user_message_id: ClientUserMessageId, + params: acp::PromptRequest, + cx: &mut App, + ) -> Task> { + self.connection.prompt(params, cx) + } + } + #[gpui::test] async fn test_tool_call_not_found_creates_failed_entry(cx: &mut TestAppContext) { init_test(cx); @@ -6271,6 +9234,9 @@ mod tests { ContentBlock::ResourceLink { .. } => { panic!("Expected markdown content, got resource link") } + ContentBlock::EmbeddedResource { .. } => { + panic!("Expected markdown content, got embedded resource") + } ContentBlock::Image { .. } => { panic!("Expected markdown content, got image") } @@ -6424,7 +9390,7 @@ mod tests { let AgentThreadEntry::UserMessage(message) = &thread.entries[1] else { panic!("expected user message at index 1"); }; - message.id.clone().unwrap() + message.client_id.clone().unwrap() }); // Create a terminal AFTER the checkpoint we'll restore to. @@ -6630,7 +9596,9 @@ mod tests { thread.update(cx, |thread, cx| { thread.push_entry( AgentThreadEntry::UserMessage(UserMessage { - id: Some(UserMessageId::new()), + protocol_id: None, + client_id: Some(ClientUserMessageId::new()), + is_optimistic: true, content: ContentBlock::Empty, chunks: vec!["Injected message (no checkpoint)".into()], checkpoint: None, @@ -6650,6 +9618,84 @@ mod tests { ); } + /// This is a regression test for a bug where update_last_checkpoint would + /// swallow a checkpoint comparison error and hide an already-visible + /// "Restore checkpoint" button without logging anything. + #[gpui::test] + async fn test_update_last_checkpoint_compare_error_keeps_checkpoint_visible( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({".git": {}, "file.txt": "content"})) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/test"))], cx).await; + + // The handler waits for this signal so the repository can be swapped + // out while the turn is still running. + let (complete_tx, complete_rx) = futures::channel::oneshot::channel::<()>(); + let complete_rx = RefCell::new(Some(complete_rx)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message( + move |_, _thread, _cx| { + let complete_rx = complete_rx.borrow_mut().take(); + async move { + if let Some(rx) = complete_rx { + rx.await.ok(); + } + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + }, + )); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let send_future = thread.update(cx, |thread, cx| thread.send_raw("message", cx)); + let send_task = cx.background_executor.spawn(send_future); + cx.run_until_parked(); + + // Show the checkpoint, as update_last_checkpoint_if_changed does when + // files change during the turn. + thread.update(cx, |thread, _| { + let (_, message) = thread.last_user_message().unwrap(); + message.checkpoint.as_mut().unwrap().show = true; + }); + + // Recreate `.git` so the git store reopens the repository. The fresh + // fake repository doesn't contain the checkpoint recorded at send + // time, so the end-of-turn comparison fails. + fs.remove_dir( + Path::new(path!("/test/.git")), + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + cx.run_until_parked(); + fs.create_dir(Path::new(path!("/test/.git"))).await.unwrap(); + cx.run_until_parked(); + + complete_tx.send(()).unwrap(); + send_task.await.unwrap(); + cx.run_until_parked(); + + thread.update(cx, |thread, _| { + let (_, message) = thread.last_user_message().unwrap(); + assert!( + message.checkpoint.as_ref().unwrap().show, + "a checkpoint comparison failure must not hide the restore checkpoint button" + ); + }); + } + /// Tests that when a follow-up message is sent during generation, /// the first turn completing does NOT clear `running_turn` because /// it now belongs to the second turn. @@ -6695,10 +9741,24 @@ mod tests { // Send first message (turn_id=1) - handler will block let first_request = thread.update(cx, |thread, cx| thread.send_raw("first", cx)); assert_eq!(thread.read_with(cx, |t, _| t.turn_id), 1); + let permission_task = thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + acp::ToolCall::new("permission", "Needs permission").into(), + PermissionOptions::Flat(Vec::new()), + AuthorizationKind::PermissionGrant, + cx, + ) + }) + .unwrap(); // Send second message (turn_id=2) while first is still blocked // This calls cancel() which takes turn 1's running_turn and sets turn 2's let second_request = thread.update(cx, |thread, cx| thread.send_raw("second", cx)); + assert!(matches!( + permission_task.await, + RequestPermissionOutcome::InterruptedByFollowUp + )); assert_eq!(thread.read_with(cx, |t, _| t.turn_id), 2); let running_turn_after_second_send = @@ -6901,6 +9961,31 @@ mod tests { // (didn't hang) and emitted Stopped. } + // HELIX DIVERGENCE — intentionally ignored, do not "fix" by changing cancel(). + // + // Upstream (5c90b0664f, PR #59014) asserts that when a second send displaces + // an in-flight turn, the displaced turn still delivers its PromptResponse + // (`Ok(Some(response))` with StopReason::Cancelled). That holds upstream + // because cancel() awaits the displaced turn's send_task. + // + // Helix Critical Fix #8 (6e0e6db32b) deliberately does `drop(turn.send_task)` + // instead of awaiting it, because claude-agent-acp does not reliably return + // from prompt() on CancelNotification (claude-agent-acp#442, #423) and + // awaiting deadlocks the next turn forever. Dropping the task drops the + // oneshot tx, so `rx.await` in run_turn returns Err and the displaced turn + // resolves to `Ok(None)` — which is exactly what this test forbids. + // + // The two behaviours are mutually exclusive; Helix keeps #8 because a + // permanently wedged thread is far worse than a lost stale response. The + // compaction-status invariant the test actually cares about is unaffected: + // the stale turn returns None rather than a Cancelled response, so it still + // cannot cancel the current compaction. + // + // This has failed since the 002100-extension merge absorbed the upstream + // test on 2026-06-18; it went unnoticed because the rebase checklist only + // ran `cargo test -p acp_thread test_second_send`, never the full suite. + // Verified failing identically on pre-merge 06e9ce8059 — not a merge regression. + #[ignore = "incompatible with Helix Critical Fix #8 (cancel drops send_task); see comment above"] #[gpui::test] async fn test_stale_cancelled_response_does_not_cancel_current_compaction( cx: &mut TestAppContext, @@ -6998,7 +10083,9 @@ mod tests { } #[gpui::test] - async fn test_send_assigns_message_id_without_truncate_support(cx: &mut TestAppContext) { + async fn test_send_omits_message_id_without_client_user_message_id_support( + cx: &mut TestAppContext, + ) { init_test(cx); let fs = FakeFs::new(cx.executor()); @@ -7021,10 +10108,9 @@ mod tests { let AgentThreadEntry::UserMessage(message) = &thread.entries[0] else { panic!("expected first entry to be a user message") }; - assert!( - message.id.is_some(), - "user message should always have an id" - ); + assert_eq!(message.protocol_id, None); + assert_eq!(message.client_id, None); + assert!(message.is_optimistic); }); } diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 1f75a72a10c871..3d5a5edec9e199 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -1,10 +1,10 @@ -use crate::AcpThread; -use agent_client_protocol::schema as acp; +use crate::{AcpThread, ElicitationStore}; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; use gpui::{Entity, SharedString, Task}; -use language_model::LanguageModelProviderId; +use language_model::DisabledReason; use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc}; @@ -13,10 +13,11 @@ use ui::{App, IconName}; use util::path_list::PathList; use uuid::Uuid; +/// A user-message ID generated by Zed and passed to agents that support client IDs. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] -pub struct UserMessageId(SharedString); +pub struct ClientUserMessageId(SharedString); -impl UserMessageId { +impl ClientUserMessageId { pub fn new() -> Self { Self(Uuid::new_v4().to_string().into()) } @@ -210,12 +211,16 @@ pub trait AgentConnection { Task::ready(Err(anyhow::Error::msg("Logout is not supported"))) } - fn prompt( + /// Returns a capability for agents that accept client-generated user message IDs. + fn client_user_message_ids( &self, - user_message_id: UserMessageId, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task>; + _cx: &App, + ) -> Option> { + None + } + + fn prompt(&self, params: acp::PromptRequest, cx: &mut App) + -> Task>; fn retry(&self, _session_id: &acp::SessionId, _cx: &App) -> Option> { None @@ -223,6 +228,13 @@ pub trait AgentConnection { fn cancel(&self, session_id: &acp::SessionId, cx: &mut App); + /// Request-scoped elicitations are connection-level because they can arrive before a session + /// thread exists. Session-scoped elicitations stay in the thread timeline, but use + /// `ElicitationStore` for shared processing. + fn request_elicitations(&self) -> Option> { + None + } + fn truncate( &self, _session_id: &acp::SessionId, @@ -290,7 +302,20 @@ impl dyn AgentConnection { } pub trait AgentSessionTruncate { - fn run(&self, message_id: UserMessageId, cx: &mut App) -> Task>; + fn run(&self, client_user_message_id: ClientUserMessageId, cx: &mut App) -> Task>; +} + +pub trait AgentSessionClientUserMessageIds { + fn new_id(&self) -> ClientUserMessageId { + ClientUserMessageId::new() + } + + fn prompt( + &self, + client_user_message_id: ClientUserMessageId, + params: acp::PromptRequest, + cx: &mut App, + ) -> Task>; } pub trait AgentSessionRetry { @@ -328,7 +353,7 @@ pub trait AgentSessionConfigOptions { fn set_config_option( &self, config_id: acp::SessionConfigId, - value: acp::SessionConfigValueId, + value: acp::SessionConfigOptionValue, cx: &mut App, ) -> Task>>; @@ -432,26 +457,17 @@ impl dyn AgentSessionList { #[derive(Debug)] pub struct AuthRequired { pub description: Option, - pub provider_id: Option, } impl AuthRequired { pub fn new() -> Self { - Self { - description: None, - provider_id: None, - } + Self { description: None } } pub fn with_description(mut self, description: String) -> Self { self.description = Some(description); self } - - pub fn with_language_model_provider(mut self, provider_id: LanguageModelProviderId) -> Self { - self.provider_id = Some(provider_id); - self - } } impl Error for AuthRequired {} @@ -529,6 +545,7 @@ pub struct AgentModelInfo { pub icon: Option, pub is_latest: bool, pub cost: Option, + pub disabled: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -992,7 +1009,6 @@ mod test_support { fn prompt( &self, - _id: UserMessageId, params: acp::PromptRequest, cx: &mut App, ) -> Task> { @@ -1049,6 +1065,15 @@ mod test_support { } } + fn client_user_message_ids( + &self, + _cx: &App, + ) -> Option> { + Some(Rc::new(StubAgentSessionClientUserMessageIds { + connection: self.clone(), + })) + } + fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { if let Some(end_turn_tx) = self .sessions @@ -1091,10 +1116,25 @@ mod test_support { } } + struct StubAgentSessionClientUserMessageIds { + connection: StubAgentConnection, + } + + impl AgentSessionClientUserMessageIds for StubAgentSessionClientUserMessageIds { + fn prompt( + &self, + _client_user_message_id: ClientUserMessageId, + params: acp::PromptRequest, + cx: &mut App, + ) -> Task> { + self.connection.prompt(params, cx) + } + } + struct StubAgentSessionEditor; impl AgentSessionTruncate for StubAgentSessionEditor { - fn run(&self, _: UserMessageId, _: &mut App) -> Task> { + fn run(&self, _: ClientUserMessageId, _: &mut App) -> Task> { Task::ready(Ok(())) } } @@ -1114,6 +1154,7 @@ mod test_support { icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)), is_latest: false, cost: None, + disabled: None, })), } } diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index d297b5fa98f513..0834d7eabbab84 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -177,7 +177,7 @@ impl Diff { }; format!( "Diff: {}\n```\n{}\n```\n", - path.unwrap_or("untitled".into()), + path.unwrap_or(MultiBuffer::DEFAULT_TITLE.into()), buffer_text ) } @@ -260,7 +260,7 @@ impl PendingDiff { let path = new_buffer .file() .map(|file| file.path().display(file.path_style(cx))) - .unwrap_or("untitled".into()) + .unwrap_or(MultiBuffer::DEFAULT_TITLE.into()) .into(); let replica_id = new_buffer.replica_id(); diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 0ebe1712ff17b9..f52e281212eafb 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, bail}; use file_icons::FileIcons; use serde::{Deserialize, Serialize}; @@ -83,33 +83,6 @@ impl MentionUri { .and_then(|input| input.strip_suffix('`')) .unwrap_or(input); - fn parse_line_range(fragment: &str) -> Result> { - let range = fragment.strip_prefix("L").unwrap_or(fragment); - - let (start, end) = if let Some((start, end)) = range.split_once(":") { - (start, end) - } else if let Some((start, end)) = range.split_once("-") { - // Also handle L10-20 or L10-L20 format - (start, end.strip_prefix("L").unwrap_or(end)) - } else { - // Single line number like L1872 - treat as a range of one line - (range, range) - }; - - let start_line = start - .parse::() - .context("Parsing line range start")? - .checked_sub(1) - .context("Line numbers should be 1-based")?; - let end_line = end - .parse::() - .context("Parsing line range end")? - .checked_sub(1) - .context("Line numbers should be 1-based")?; - - Ok(start_line..=end_line) - } - let parse_column = |input: Option| -> Option { input?.parse::().ok()?.checked_sub(1) }; let validate_query_params = |url: &Url, allowed: &[&str]| -> Result<()> { @@ -121,37 +94,6 @@ impl MentionUri { Ok(()) }; - let parse_absolute_path = |input: &str| -> Result { - let (path_input, fragment) = input - .split_once('#') - .map_or((input, None), |(path, fragment)| (path, Some(fragment))); - - if let Some(fragment) = fragment.and_then(|fragment| parse_line_range(fragment).ok()) { - return Ok(MentionUri::Selection { - abs_path: Some(path_input.into()), - line_range: fragment, - column: None, - }); - } - - let path_with_position = PathWithPosition::parse_str(path_input); - let abs_path = path_with_position.path; - if let Some(row) = path_with_position.row { - let line = row - .checked_sub(1) - .context("Line numbers should be 1-based")?; - Ok(MentionUri::Selection { - abs_path: Some(abs_path), - line_range: line..=line, - column: path_with_position - .column - .map(|column| column.saturating_sub(1)), - }) - } else { - Ok(MentionUri::File { abs_path }) - } - }; - if is_absolute(input, path_style) && !input.contains("://") { return parse_absolute_path(input) .with_context(|| format!("Invalid absolute path mention URI: {input}")); @@ -168,7 +110,10 @@ impl MentionUri { }; let decoded = decode(trimmed).unwrap_or(Cow::Borrowed(trimmed)); let normalized: Cow = if path_style.is_windows() { - Cow::Owned(decoded.replace('/', "\\")) + match to_native_windows_path(&decoded) { + Some(native) => Cow::Owned(native), + None => decoded, + } } else { decoded }; @@ -337,6 +282,56 @@ impl MentionUri { } } + /// Parses a hyperlink target from agent-authored Markdown. + /// + /// Unlike [`MentionUri::parse`] — which stays strict so canonical mention + /// URIs round-trip verbatim — bare path targets are normalized first: + /// percent escapes are decoded (see [`decode_path_escapes`]) and + /// Windows-compatible spellings like `/C:/foo` or `/c/foo` become native + /// paths (see [`to_native_windows_path`]). + pub fn parse_hyperlink(input: &str, path_style: PathStyle) -> Result { + if let Some(target) = bare_path_target(input, path_style) { + return parse_hyperlink_path(target, path_style, DecodePercentEscapes::Yes) + .with_context(|| format!("Invalid hyperlink path target: {input}")); + } + Self::parse(input, path_style) + } + + /// Returns the literal (un-decoded) interpretation of a bare-path + /// hyperlink target, for files whose names literally contain an escape + /// sequence (e.g. `a%20b.rs`). Returns `None` when this wouldn't differ + /// from [`MentionUri::parse_hyperlink`], including for URLs, whose + /// escapes are unambiguous. + pub fn parse_hyperlink_literal(input: &str, path_style: PathStyle) -> Option { + let target = bare_path_target(input, path_style)?; + let (path_input, _) = split_path_fragment(target); + if !matches!(decode_path_escapes(path_input), Cow::Owned(_)) { + return None; + } + parse_hyperlink_path(target, path_style, DecodePercentEscapes::No).ok() + } + + /// The absolute path this mention refers to, if it refers to one. + pub fn abs_path(&self) -> Option<&Path> { + match self { + MentionUri::File { abs_path } + | MentionUri::Directory { abs_path } + | MentionUri::Symbol { abs_path, .. } => Some(abs_path), + MentionUri::Selection { abs_path, .. } => abs_path.as_deref(), + MentionUri::Skill { + skill_file_path, .. + } => Some(skill_file_path), + MentionUri::PastedImage { .. } + | MentionUri::Thread { .. } + | MentionUri::Rule { .. } + | MentionUri::Diagnostics { .. } + | MentionUri::Fetch { .. } + | MentionUri::TerminalSelection { .. } + | MentionUri::GitDiff { .. } + | MentionUri::MergeConflict { .. } => None, + } + } + pub fn name(&self) -> String { match self { MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path @@ -599,6 +594,217 @@ impl fmt::Display for MentionLink<'_> { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DecodePercentEscapes { + Yes, + No, +} + +fn parse_line_range(fragment: &str) -> Result> { + let range = fragment.strip_prefix("L").unwrap_or(fragment); + + let (start, end) = if let Some((start, end)) = range.split_once(":") { + (start, end) + } else if let Some((start, end)) = range.split_once("-") { + // Also handle L10-20 or L10-L20 format + (start, end.strip_prefix("L").unwrap_or(end)) + } else { + // Single line number like L1872 - treat as a range of one line + (range, range) + }; + + let start_line = start + .parse::() + .context("Parsing line range start")? + .checked_sub(1) + .context("Line numbers should be 1-based")?; + let end_line = end + .parse::() + .context("Parsing line range end")? + .checked_sub(1) + .context("Line numbers should be 1-based")?; + + Ok(start_line..=end_line) +} + +/// Returns the mention target as a bare absolute path (not a URL), with the +/// backticks agents sometimes add stripped. +fn bare_path_target(input: &str, path_style: PathStyle) -> Option<&str> { + let input = input + .strip_prefix('`') + .and_then(|input| input.strip_suffix('`')) + .unwrap_or(input); + (is_absolute(input, path_style) && !input.contains("://")).then_some(input) +} + +fn split_path_fragment(input: &str) -> (&str, Option<&str>) { + input + .split_once('#') + .map_or((input, None), |(path, fragment)| (path, Some(fragment))) +} + +fn parse_absolute_path(input: &str) -> Result { + let (path_input, fragment) = split_path_fragment(input); + absolute_path_mention(path_input, fragment) +} + +/// Like [`parse_absolute_path`], but normalizes hyperlink spellings first. +fn parse_hyperlink_path( + input: &str, + path_style: PathStyle, + decode_escapes: DecodePercentEscapes, +) -> Result { + let (path_input, fragment) = split_path_fragment(input); + let path_input = normalize_path_mention(path_input, path_style, decode_escapes); + absolute_path_mention(&path_input, fragment) +} + +fn absolute_path_mention(path_input: &str, fragment: Option<&str>) -> Result { + if let Some(fragment) = fragment.and_then(|fragment| parse_line_range(fragment).ok()) { + return Ok(MentionUri::Selection { + abs_path: Some(path_input.into()), + line_range: fragment, + column: None, + }); + } + + let path_with_position = PathWithPosition::parse_str(path_input); + let abs_path = path_with_position.path; + if let Some(row) = path_with_position.row { + let line = row + .checked_sub(1) + .context("Line numbers should be 1-based")?; + Ok(MentionUri::Selection { + abs_path: Some(abs_path), + line_range: line..=line, + column: path_with_position + .column + .map(|column| column.saturating_sub(1)), + }) + } else { + Ok(MentionUri::File { abs_path }) + } +} + +fn normalize_path_mention( + input: &str, + path_style: PathStyle, + decode_escapes: DecodePercentEscapes, +) -> Cow<'_, str> { + let decoded = match decode_escapes { + DecodePercentEscapes::Yes => decode_path_escapes(input), + DecodePercentEscapes::No => Cow::Borrowed(input), + }; + if !path_style.is_windows() { + return decoded; + } + match to_native_windows_path(&decoded) { + Some(native) => Cow::Owned(native), + None => decoded, + } +} + +/// Decodes percent escapes in a path, leaving separator escapes (`%2F`, +/// `%5C`) encoded so decoding can't change which directories the path +/// traverses. Invalid sequences and non-UTF-8 results leave the input +/// unchanged. Returns `Cow::Owned` iff decoding changed the input +/// (`parse_hyperlink_literal` relies on this). +pub fn decode_path_escapes(input: &str) -> Cow<'_, str> { + fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } + } + + if !input.contains('%') { + return Cow::Borrowed(input); + } + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && let Some(high) = bytes.get(index + 1).copied().and_then(hex_digit) + && let Some(low) = bytes.get(index + 2).copied().and_then(hex_digit) + { + let byte = (high << 4) | low; + if byte != b'/' && byte != b'\\' { + decoded.push(byte); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + if decoded == bytes { + return Cow::Borrowed(input); + } + match String::from_utf8(decoded) { + Ok(decoded) => Cow::Owned(decoded), + Err(_) => Cow::Borrowed(input), + } +} + +/// Converts Windows-compatible path spellings into a native Windows path, +/// normalizing separators to backslashes and drive letters to uppercase so +/// parsed paths compare equal to worktree paths. Returns `None` when the +/// input needs no changes. +fn to_native_windows_path(path: &str) -> Option { + fn join_drive(drive: char, rest: &str) -> String { + format!( + "{}:\\{}", + drive.to_ascii_uppercase(), + rest.replace('/', "\\") + ) + } + + if let Some(rest) = path.strip_prefix('/') { + // URL-style path with a leading slash before the drive: `/C:/foo`. + let mut chars = rest.chars(); + if let (Some(drive), Some(':'), Some('/' | '\\')) = + (chars.next(), chars.next(), chars.next()) + && drive.is_ascii_alphabetic() + { + return Some(join_drive(drive, chars.as_str())); + } + + // MSYS/Git Bash style: `/c/foo`. Lowercase-only, since that's what + // those shells emit and uppercase risks misreading real directories. + let mut chars = rest.chars(); + if let (Some(drive), Some('/' | '\\')) = (chars.next(), chars.next()) + && drive.is_ascii_lowercase() + { + return Some(join_drive(drive, chars.as_str())); + } + } + + // A native path with a drive prefix: uppercase the drive and normalize + // separators, e.g. `c:/foo` or `c:\foo`. + let mut chars = path.chars(); + if let (Some(drive), Some(':')) = (chars.next(), chars.next()) + && drive.is_ascii_alphabetic() + { + if drive.is_ascii_uppercase() && !path.contains('/') { + return None; + } + return Some(format!( + "{}:{}", + drive.to_ascii_uppercase(), + chars.as_str().replace('/', "\\") + )); + } + + if path.contains('/') { + return Some(path.replace('/', "\\")); + } + + None +} + fn default_include_errors() -> bool { true } @@ -727,6 +933,296 @@ mod tests { } } + #[test] + fn test_parse_file_uri_with_spaces() { + let parsed = + MentionUri::parse("file:///C:/path%20with%20space/file.rs", PathStyle::Windows) + .unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("C:\\path with space\\file.rs")); + } + other => panic!("Expected File variant, got {other:?}"), + } + assert_eq!( + MentionUri::File { + abs_path: PathBuf::from("C:\\path with space\\file.rs") + } + .to_uri() + .to_string(), + "file:///C:/path%20with%20space/file.rs" + ); + } + + #[test] + fn test_parse_windows_drive_path_with_leading_slash_and_line() { + let parsed = MentionUri::parse_hyperlink( + "/C:/Projects/Example Workspace/Cargo.toml:2", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\Cargo.toml") + ); + assert_eq!(line_range, 1..=1); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_path_with_percent_escaped_spaces_and_line() { + let parsed = MentionUri::parse_hyperlink( + "C:\\Projects\\Example%20Workspace\\path\\to\\filename.ext:42", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\path\\to\\filename.ext") + ); + assert_eq!(line_range, 41..=41); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_compat_path_with_spaces() { + let parsed = MentionUri::parse_hyperlink( + "/c/Projects/Example Workspace/AGENTS.md", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\AGENTS.md") + ); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_drive_path_with_leading_slash_and_fragment_line() { + let parsed = + MentionUri::parse_hyperlink("/C:/Projects/Cargo.toml#L4", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!(abs_path, PathBuf::from("C:\\Projects\\Cargo.toml")); + assert_eq!(line_range, 3..=3); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_windows_drive_path_with_leading_slash_round_trips() { + let parsed = MentionUri::parse_hyperlink("/C:/dir/file.rs", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\file.rs") + } + ); + let uri = parsed.to_uri().to_string(); + assert_eq!(uri, "file:///C:/dir/file.rs"); + assert_eq!(MentionUri::parse(&uri, PathStyle::Windows).unwrap(), parsed); + } + + #[test] + fn test_parse_windows_unc_path() { + let parsed = + MentionUri::parse_hyperlink("//server/share/dir/file.rs", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("\\\\server\\share\\dir\\file.rs")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_drive_letters_are_uppercased() { + for input in [ + "file:///c:/foo/bar.rs", + "/c:/foo/bar.rs", + "/c/foo/bar.rs", + "c:\\foo\\bar.rs", + "c:/foo/bar.rs", + ] { + let parsed = MentionUri::parse_hyperlink(input, PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\foo\\bar.rs") + }, + "input: {input}" + ); + } + } + + #[test] + fn test_msys_style_paths_require_lowercase_drive() { + // Uppercase `/C/foo` is more likely a real directory than a drive. + let parsed = MentionUri::parse_hyperlink("/C/Users/readme.md", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("\\C\\Users\\readme.md")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_posix_paths_are_not_rewritten_as_windows_drives() { + let parsed = MentionUri::parse_hyperlink("/c/Projects/AGENTS.md", PathStyle::Unix).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("/c/Projects/AGENTS.md")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_hyperlink_percent_escapes_are_decoded() { + let parsed = MentionUri::parse_hyperlink("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a b.rs") + } + ); + + // Invalid escape sequences pass through unchanged. + let parsed = + MentionUri::parse_hyperlink("C:\\dir\\100%_done.txt", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\100%_done.txt") + } + ); + + // Separator escapes stay encoded (no introduced path traversal). + let parsed = MentionUri::parse_hyperlink("/tmp/a%2Fb.rs", PathStyle::Unix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%2Fb.rs") + } + ); + let parsed = MentionUri::parse_hyperlink("/tmp/..%2F..%2Fsecret", PathStyle::Unix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/..%2F..%2Fsecret") + } + ); + } + + #[test] + fn test_parse_keeps_bare_path_targets_verbatim() { + let parsed = MentionUri::parse("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%20b.rs") + } + ); + + let parsed = MentionUri::parse("/c/Projects/AGENTS.md", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/c/Projects/AGENTS.md") + } + ); + } + + #[test] + fn test_parse_hyperlink_literal_keeps_percent_escapes() { + let literal = + MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs", PathStyle::Unix).unwrap(); + assert_eq!( + literal, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%20b.rs") + } + ); + + // Line suffixes still parse. + let literal = + MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs:42", PathStyle::Unix).unwrap(); + assert_eq!( + literal, + MentionUri::Selection { + abs_path: Some(PathBuf::from("/tmp/a%20b.rs")), + line_range: 41..=41, + column: None, + } + ); + + // Windows normalization still applies. + let literal = + MentionUri::parse_hyperlink_literal("/C:/dir/a%20b.rs", PathStyle::Windows).unwrap(); + assert_eq!( + literal, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\a%20b.rs") + } + ); + } + + #[test] + fn test_parse_hyperlink_literal_returns_none_when_unambiguous() { + // No percent escapes: identical to `parse_hyperlink`. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/a b.rs", PathStyle::Unix), + None + ); + // Invalid escape sequences are also left alone by `parse_hyperlink`. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/100%_done.txt", PathStyle::Unix), + None + ); + // Separator escapes are never decoded, so they're not ambiguous. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/a%2Fb.rs", PathStyle::Unix), + None + ); + // URLs are spec-encoded, not ambiguous. + assert_eq!( + MentionUri::parse_hyperlink_literal("file:///tmp/a%20b.rs", PathStyle::Unix), + None + ); + // Relative paths are not bare-path mentions. + assert_eq!( + MentionUri::parse_hyperlink_literal("tmp/a%20b.rs", PathStyle::Unix), + None + ); + } + #[test] fn test_to_directory_uri_without_slash() { let uri = MentionUri::Directory { @@ -978,7 +1474,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_row() { let file_path = "/path/to/file.rs:42"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -996,7 +1492,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_row_and_column() { let file_path = "/path/to/file.rs:42:5"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -1008,7 +1504,7 @@ mod tests { assert_eq!(line_range.end(), &41); assert_eq!(column, &Some(4)); - let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Posix) + let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Unix) .expect("selection URI with column should parse"); assert_eq!(parsed_again, parsed.clone()); } @@ -1019,7 +1515,7 @@ mod tests { #[test] fn test_parse_absolute_file_path_with_fragment_line() { let file_path = "/path/to/file.rs#L42"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, @@ -1091,7 +1587,7 @@ mod tests { #[test] fn test_parse_backticked_absolute_file_path() { let file_path = "`/path/to/file.rs`"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::File { abs_path } => { assert_eq!(abs_path, Path::new("/path/to/file.rs")); @@ -1103,7 +1599,7 @@ mod tests { #[test] fn test_parse_backticked_absolute_file_path_with_fragment_line() { let file_path = "`/path/to/file.rs#L42`"; - let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap(); match &parsed { MentionUri::Selection { abs_path: path, diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index d8b501b82691b4..e52a3367a88f8f 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -1,16 +1,15 @@ -use agent_client_protocol::schema as acp; -#[cfg(target_os = "linux")] -use anyhow::Context as _; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use collections::HashMap; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; -use http_proxy::{Allowlist, ProxyConfig, ProxyEvent, ProxyHandle, UpstreamProxy}; +use http_proxy::Allowlist; use language::LanguageRegistry; use markdown::Markdown; use project::Project; use serde::{Deserialize, Serialize}; use std::{ + collections::HashMap as StdHashMap, path::PathBuf, process::ExitStatus, sync::{ @@ -25,9 +24,9 @@ use util::get_default_system_shell_preferring_bash; /// Request to run a terminal command inside an OS-level sandbox. /// /// Passed to [`super::AcpThread::create_terminal`]. The actual sandboxing -/// mechanism is platform-specific (macOS Seatbelt; Linux Bubblewrap; a no-op -/// on other platforms), so callers describe the *intent* with plain data here -/// rather than constructing platform-specific types directly. +/// mechanism is platform-specific (macOS Seatbelt; Linux Bubblewrap; Windows +/// via Bubblewrap inside WSL), so callers describe the *intent* with plain data +/// here rather than constructing platform-specific types directly. /// /// Default is the fully-sandboxed run (no network, project-only writes). /// Setting `network` / `allow_fs_write` requests a relaxation; the caller is @@ -44,15 +43,31 @@ pub struct SandboxWrap { /// to make the trust boundary explicit: these originate from /// model-requested paths that passed a user-approval prompt. They are /// merged with `writable_paths` when generating the sandbox policy. - pub extra_write_paths: Vec, + /// + /// Each grant carries the canonical target it resolved to at approval + /// time; enforcement rebuilds the location via a verifying reopen (see + /// [`granted_write_path_to_location`]) rather than re-resolving the bare + /// requested path, which closes a symlink TOCTOU. + pub extra_write_paths: Vec, /// Outbound network access explicitly approved for this command. pub network: SandboxNetworkAccess, - /// Allow unrestricted filesystem writes (ignores all writable paths). + /// Additional paths that should remain readable but not writable, even when + /// they fall under writable paths. + pub protected_paths: Vec, + /// Allow unrestricted filesystem writes except for protected paths (ignores + /// ordinary writable paths). pub allow_fs_write: bool, /// Whether the project (and therefore this terminal) is local. The /// enforcing proxy binds a loopback port on this host, so it can only /// confine local commands; a remote terminal can't reach it. pub is_local: bool, + /// Windows/WSL only: `(release channel, version)` of the Linux `zed` to + /// provision inside WSL as the sandbox helper (version `latest` for dev + /// builds). Resolved by the agent (which can read the running app's release + /// info) and forwarded to the sandbox. `None` on other platforms, or when + /// the release can't be determined, in which case the WSL backend falls back + /// to running bwrap without in-sandbox bind validation. + pub wsl_zed_release: Option<(String, String)>, } #[derive(Clone, Debug, Default)] @@ -68,18 +83,9 @@ pub enum SandboxNetworkAccess { All, } -impl SandboxNetworkAccess { - fn restricted_allowlist(&self) -> Option<&Allowlist> { - match self { - Self::Restricted(allowlist) => Some(allowlist), - Self::None | Self::All => None, - } - } -} - /// A structured, serializable reason the OS sandbox could not be created for a -/// command. Mirrors the Linux/WSL launcher's failure modes (Bubblewrap); -/// surfaced to the user (and persisted in tool-call metadata) so the UI can +/// command. Mirrors the Linux/WSL Bubblewrap failure modes; surfaced to the user +/// (and persisted in tool-call metadata) so the UI can /// explain what went wrong. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum LinuxWslSandboxError { @@ -94,6 +100,17 @@ pub enum LinuxWslSandboxError { Other(String), } +impl From for LinuxWslSandboxError { + fn from(error: sandbox::SandboxError) -> Self { + match error { + sandbox::SandboxError::BwrapNotFound => Self::BwrapNotFound, + sandbox::SandboxError::BwrapSetuidRejected => Self::SetuidRejected, + sandbox::SandboxError::SandboxProbeFailed => Self::SandboxProbeFailed, + error => Self::Other(error.to_string()), + } + } +} + impl LinuxWslSandboxError { /// A short, user-facing explanation of why the sandbox couldn't be created, /// suitable for display in the agent panel. @@ -117,6 +134,76 @@ impl LinuxWslSandboxError { LinuxWslSandboxError::Other(message) => message.clone(), } } + + /// The slug of the sandboxing docs section that best explains how to resolve + /// this failure, for deep-linking from the UI. Pair with + /// `client::zed_urls::sandboxing_docs`. + pub fn docs_section(&self) -> &'static str { + match self { + // Both "no bwrap" and "only a setuid-root bwrap" are resolved by + // installing a non-setuid Bubblewrap. + LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => { + "installing-bubblewrap" + } + // A failed probe on Linux is almost always disabled unprivileged + // user namespaces, which the Ubuntu-specific section covers. + LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu", + // Catch-all (includes WSL/Windows messages): point at the platform + // overview for the current OS. + LinuxWslSandboxError::Other(_) => { + if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + } + } + } + } +} + +/// Rebuild a user-approved write grant into an enforceable +/// [`sandbox::HostFilesystemLocation`]. +/// +/// When the grant carries a resolved canonical (the normal case, established at +/// approval time), the location is rebuilt via a verifying +/// [`sandbox::HostFilesystemLocation::reopen`] — the load-bearing step of the +/// TOCTOU fix. A legacy bare-string grant (no resolved canonical) falls back to +/// a fresh [`sandbox::HostFilesystemLocation::capture`]. +pub fn granted_write_path_to_location( + granted: &settings::GrantedWritePath, +) -> std::io::Result { + match &granted.resolved { + Some(resolved) => sandbox::HostFilesystemLocation::reopen(&granted.requested, resolved), + None => sandbox::HostFilesystemLocation::capture(&granted.requested), + } +} + +/// Rebuild a grant for enforcement, or log and drop it (fail-closed) if it +/// can't be verified. +/// +/// A failure here is frequently the symlink-TOCTOU defense firing: the grant's +/// canonical was redirected or replaced by a symlink since approval, so +/// [`sandbox::HostFilesystemLocation::reopen`] refuses it. That is a +/// security-relevant event, so it must be logged rather than silently +/// swallowed. The grant is dropped (the command runs without it) rather than +/// bound unverified. +/// +/// Only for **display** policies (the sandbox-status UI), where a stale grant +/// should simply not be shown. Enforcement must not drop grants silently — a +/// command would run with less access than the user approved with no signal — +/// so [`SandboxWrap::to_policy`] uses the erroring +/// [`granted_write_path_to_location`] instead. +pub fn granted_write_path_to_location_or_log( + granted: &settings::GrantedWritePath, +) -> Option { + granted_write_path_to_location(granted) + .inspect_err(|error| { + log::warn!( + "dropping sandbox write grant {}: {error}", + granted.requested.display() + ); + }) + .ok() } impl SandboxWrap { @@ -129,41 +216,106 @@ impl SandboxWrap { /// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on /// Linux, so call it off the main thread. On platforms whose sandbox can't /// fail to set up this way it always returns `Ok`. - pub fn can_create_sandbox( - &self, - cwd: Option<&std::path::Path>, - ) -> Result<(), LinuxWslSandboxError> { - #[cfg(target_os = "linux")] - { - use sandbox::linux_bubblewrap::LauncherStatus; - - let writable: Vec<&std::path::Path> = self - .writable_paths - .iter() - .chain(self.extra_write_paths.iter()) - .map(|path| path.as_path()) - .collect(); - let allow_network = !matches!(self.network, SandboxNetworkAccess::None); - let permissions = sandbox::SandboxPermissions { - allow_network, - allow_fs_write: self.allow_fs_write, - }; - sandbox::linux_bubblewrap::check_can_create_sandbox(&writable, permissions, cwd) - .map_err(|status| match status { - LauncherStatus::BwrapNotFound => LinuxWslSandboxError::BwrapNotFound, - LauncherStatus::SetuidRejected => LinuxWslSandboxError::SetuidRejected, - LauncherStatus::SandboxProbeFailed => LinuxWslSandboxError::SandboxProbeFailed, - // `Success` never appears in the `Err` arm; map defensively. - LauncherStatus::Success => { - LinuxWslSandboxError::Other(status.describe().to_string()) - } - }) - } - #[cfg(not(target_os = "linux"))] - { - let _ = cwd; - Ok(()) - } + pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> { + let policy = self + .to_policy() + .map_err(|error| LinuxWslSandboxError::Other(format!("{error:#}")))?; + sandbox::Sandbox::can_create(&policy).map_err(LinuxWslSandboxError::from) + } + + /// Translate this request into the cross-platform [`sandbox::SandboxPolicy`]. + /// + /// This is the enforcement-policy construction point, so it **captures** each + /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical + /// path) rather than passing a re-resolvable path. + /// + /// This function has **no filesystem side effects**: it never creates paths, + /// and it **fails** (rather than silently narrowing the policy) when a + /// writable path or approved grant can't be captured — running anyway would + /// give the command silently less access than the model and user were told + /// it has. On Linux a writable grant that doesn't exist can't be captured + /// (bwrap can't bind a missing path); the sanctioned way to get a grant to a + /// new directory is the `create_directory` tool, which creates it (pinning + /// the inode) before the grant is recorded. On macOS a missing leaf still + /// canonicalizes, so such grants are captured directly. + /// + /// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe + /// and by real sandbox construction, and must behave identically. + /// + /// A grant failure here can also be the symlink-TOCTOU defense firing: the + /// grant's canonical was redirected or replaced by a symlink since approval, + /// so the verifying reopen refuses it. Failing the command surfaces that + /// security-relevant event instead of running with the grant quietly + /// missing. + /// + /// Protected paths, by contrast, are **best-effort**: we protect only the + /// ones that exist at creation time (`capture` succeeding *is* the existence + /// check), and silently drop the rest. Unlike a writable grant, a protection + /// can't be materialized — you can't pin the inode of a path that isn't + /// there — and there is an inherent, *accepted* loophole regardless: a + /// command in a non-git directory can `git init` and write hooks into a + /// `.git` that didn't exist when the sandbox was built. Since the protection + /// is defeatable that way no matter what, failing sandbox creation over a + /// currently-absent (or otherwise uncapturable) `.git` would only break + /// legitimate cases — non-git projects, single-file worktrees whose + /// synthesized `settings.json/.git` routes through a file — without closing + /// the hole. So we drop and move on. + fn to_policy(&self) -> Result { + let protected_paths = self + .protected_paths + .iter() + .filter_map(|path| sandbox::HostFilesystemLocation::capture(path).ok()) + .collect::>(); + let fs = if self.allow_fs_write { + sandbox::SandboxFsPolicy::Unrestricted { protected_paths } + } else { + // Project worktree paths are captured fresh; user-approved grants are + // rebuilt via the verifying reopen (or captured when legacy bare + // strings) through `granted_write_path_to_location`. A path that + // can't be captured fails the whole construction (never created). + let mut locations = Vec::new(); + for path in &self.writable_paths { + let location = sandbox::HostFilesystemLocation::capture(path).map_err(|error| { + anyhow::anyhow!(error).context(format!( + "cannot capture writable sandbox path `{}`", + path.display() + )) + })?; + locations.push(location); + } + for granted in &self.extra_write_paths { + let location = granted_write_path_to_location(granted).map_err(|error| { + anyhow::anyhow!(error).context(format!( + "cannot re-verify approved sandbox write grant `{}` (if the \ + directory was removed, remove the grant or recreate the \ + directory)", + granted.requested.display() + )) + })?; + locations.push(location); + } + // Dedupe to a minimal cover on the captured canonical paths, so a + // grant nested under a worktree root (or another grant) is dropped + // rather than bound redundantly. + let writable_paths = + sandbox::normalize_host_filesystem_locations(locations.into_iter()); + sandbox::SandboxFsPolicy::Restricted { + writable_paths, + protected_paths, + } + }; + let network = match &self.network { + SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked, + SandboxNetworkAccess::All => sandbox::SandboxNetPolicy::Unrestricted, + SandboxNetworkAccess::Restricted(allowlist) => sandbox::SandboxNetPolicy::Restricted { + allowed_domains: allowlist + .patterns() + .iter() + .map(|pattern| pattern.to_string()) + .collect(), + }, + }; + Ok(sandbox::SandboxPolicy { fs, network }) } } @@ -178,414 +330,73 @@ impl SandboxWrap { /// grow their own failure cases later without a migration. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SandboxNotAppliedReason { - /// Unsandboxed execution is permanently allowed via the `allow_unsandboxed` - /// setting. - DisabledForever, - /// The user allowed unsandboxed execution for the rest of this thread after - /// an earlier sandbox failure. There is always a preceding tool call whose - /// reason is [`SandboxNotAppliedReason::ErrorLinuxWsl`]. + /// The user disabled the sandbox for the rest of this thread, so the command + /// ran without one. This happens either when the user approved a + /// model-requested `unsandboxed: true` escape "for this thread", or when + /// they chose to run unsandboxed for the thread after a sandbox-creation + /// failure (in which case a preceding tool call's reason is + /// [`SandboxNotAppliedReason::ErrorLinuxWsl`]). DisabledForThisThread, /// The Linux/WSL (Bubblewrap) sandbox could not be created for this command. ErrorLinuxWsl(LinuxWslSandboxError), } -/// Opaque RAII handle the sandbox implementation hands back to keep its -/// per-command resources (e.g. an on-disk Seatbelt config file) alive for -/// the duration of the spawned command. `Terminal` holds it in a field -/// whose only job is to drop with the entity. -pub type SandboxConfigHandle = Box; - -/// The outbound-network policy resolved for a sandboxed command. -pub(crate) enum NetworkPolicy { - /// The command requested no outbound network. - Denied, - /// Egress is confined to the in-process proxy on this loopback port. - Proxied(u16), - /// The command explicitly requested, and the user approved, unrestricted - /// outbound network access. - Unrestricted, -} - -/// Apply a [`SandboxWrap`] to a `(program, args)` pair, substituting the -/// platform's sandboxed invocation in place of the original. The returned -/// `SandboxConfigHandle` (when `Some`) must be kept alive for the duration -/// of the spawned command — dropping it deletes any on-disk config the -/// launcher reads at startup. -/// -/// `network_policy` is the decision resolved by [`setup_network_proxy`]. -/// Unrestricted network access must be requested explicitly via -/// [`SandboxNetworkAccess::All`]. -/// -/// There is a dedicated code path per platform: -/// * macOS wraps the command with `sandbox-exec` and a Seatbelt config file -/// (returned as the handle). -/// * Linux re-execs this binary as a launcher that locates `bwrap` and `exec`s -/// it for filesystem and network isolation (see -/// [`sandbox::linux_bubblewrap`]); no handle is needed. The launcher reports -/// back over a status channel whether it could enforce the sandbox, and when -/// it can't (no usable `bwrap`, user namespaces disabled, …) it runs the -/// command unsandboxed and the parent logs a warning rather than failing. -/// * Windows routes the command through WSL and runs it under Bubblewrap -/// there, but that path is async (it performs `wsl.exe` round-trips), so it -/// lives in [`apply_windows_wsl_sandbox_wrap`] rather than this synchronous -/// function. -/// * All other platforms pass the command through unchanged — we have no -/// sandbox integration there, so the command runs with the agent's ambient -/// permissions. -#[cfg(not(target_os = "windows"))] -pub(crate) fn apply_sandbox_wrap( - program: String, - args: Vec, - cwd: Option<&std::path::Path>, - sandbox_wrap: Option, - network_policy: NetworkPolicy, -) -> anyhow::Result<(String, Vec, Option)> { - let Some(sandbox_wrap) = sandbox_wrap else { - return Ok((program, args, None)); - }; - - #[cfg(target_os = "macos")] - { - use sandbox::macos_seatbelt::NetworkAccess; +/// The live sandbox kept alive for its per-command resources (the network proxy +/// and, on macOS, the Seatbelt policy file) until the terminal exits. +type SandboxConfigHandle = sandbox::Sandbox; - let _ = cwd; - let writable: Vec<&std::path::Path> = sandbox_wrap - .writable_paths - .iter() - .chain(sandbox_wrap.extra_write_paths.iter()) - .map(|p| p.as_path()) - .collect(); - let network = match network_policy { - NetworkPolicy::Proxied(port) => NetworkAccess::LocalhostPort(port), - NetworkPolicy::Unrestricted => NetworkAccess::All, - NetworkPolicy::Denied => NetworkAccess::None, - }; - let permissions = sandbox::macos_seatbelt::SandboxPermissions { - network, - allow_fs_write: sandbox_wrap.allow_fs_write, - }; - let (new_program, new_args, config_file) = - sandbox::macos_seatbelt::wrap_invocation(&program, &args, &writable, permissions)?; - Ok(( - new_program, - new_args, - Some(Box::new(config_file) as SandboxConfigHandle), - )) - } - #[cfg(target_os = "linux")] - { - use sandbox::linux_bubblewrap::{self, LauncherStatus, StatusChannel}; - use std::time::Duration; - - let writable: Vec<_> = sandbox_wrap - .writable_paths - .iter() - .chain(sandbox_wrap.extra_write_paths.iter()) - .map(|p| p.as_path()) - .collect(); - let allow_network = match network_policy { - NetworkPolicy::Denied => false, - NetworkPolicy::Unrestricted => true, - NetworkPolicy::Proxied(port) => { - // Bubblewrap can only toggle network access wholesale, so it - // can't confine egress to the proxy's loopback port. - // `setup_network_proxy` never resolves to `Proxied` on Linux; - // deny network rather than silently widening access. - log::debug!( - "[sandbox/network] ignoring proxy port {port}; bubblewrap can't confine to a loopback port" - ); - false - } - }; - let permissions = sandbox::SandboxPermissions { - allow_network, - allow_fs_write: sandbox_wrap.allow_fs_write, - }; - - let launcher = std::env::current_exe() - .context("failed to resolve current executable for sandbox launcher")?; - let launcher = launcher.to_str().with_context(|| { - format!( - "current executable path contains invalid UTF-8: {}", - launcher.display() - ) - })?; - - // Bind a status channel the launcher reports back on, so we can warn - // when it couldn't actually enforce the sandbox. All the sandbox logic - // (locating bwrap, probing it) lives in the launcher; the parent only - // assembles the invocation and listens. - let channel = StatusChannel::bind().context("failed to set up sandbox status channel")?; - let (new_program, new_args) = linux_bubblewrap::wrap_invocation( - launcher, - Some(channel.name()), - permissions, - &writable, - cwd, - &program, - &args, - ); - - // Read the launcher's report in the background, purely for diagnostics. - // Callers are expected to check `SandboxWrap::can_create_sandbox` before - // reaching here, so the launcher should almost always succeed; a failure - // status means the launcher aborted (it never runs a command - // unsandboxed), so the command did not run. - const STATUS_TIMEOUT: Duration = Duration::from_secs(30); - let status_thread = std::thread::Builder::new() - .name("zed-sandbox-status".into()) - .spawn(move || match channel.recv(STATUS_TIMEOUT) { - Some(LauncherStatus::Success) => {} - Some(status) => log::warn!( - "sandbox could not be created ({}); the command was aborted", - status.describe() - ), - None => log::warn!("could not determine terminal command sandbox status"), - }) - .context("failed to spawn sandbox status thread")?; - // The thread is self-contained and bounded by STATUS_TIMEOUT; let it run - // to completion on its own rather than joining here. - drop(status_thread); - - // The sandbox applies in-process via the re-exec'd launcher, so - // there's no on-disk resource to keep alive. - Ok((new_program, new_args, None)) - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - // No sandbox integration available; run with ambient permissions. - if let NetworkPolicy::Proxied(port) = network_policy { - log::debug!( - "[sandbox/network] ignoring proxy port {port} because this platform has no sandbox integration" - ); - } - let _ = (sandbox_wrap, cwd); - Ok((program, args, None)) - } -} - -/// Upper bound on preparing a WSL-sandboxed command (the probe and path -/// resolution `wsl.exe` round-trips in [`apply_windows_wsl_sandbox_wrap`]). -/// Deliberately generous: the first invocation after the WSL utility VM has -/// shut down (or after boot) has to start the VM and the distro, which -/// routinely takes 10-30 seconds on slow disks or under antivirus scanning. -/// The point is not latency policing but turning a wedged `wsl.exe` (a real -/// failure mode when the WSL service is unhealthy) into an actionable error -/// instead of a terminal command that never starts. +/// Upper bound on preparing a WSL-sandboxed command. Deliberately generous: +/// the first invocation after the WSL utility VM has shut down (or after boot) +/// has to start the VM and the distro, which routinely takes 10-30 seconds on +/// slow disks or under antivirus scanning. #[cfg(target_os = "windows")] pub(crate) const WSL_SANDBOX_WRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); -/// Wrap a terminal command so it runs under Bubblewrap inside WSL (see -/// [`sandbox::windows_wsl`]). -/// -/// Async because it performs `wsl.exe` round-trips and UNC-path stats that -/// can take seconds when the WSL VM is cold; callers must run it on a -/// background executor so the UI thread is never blocked, and should bound -/// it with [`WSL_SANDBOX_WRAP_TIMEOUT`]. Parameters are owned so the future -/// is `Send + 'static`. Dropping the future (timeout or caller cancellation) -/// kills any in-flight `wsl.exe` child rather than leaking it. +/// Wrap `(program, args)` for sandboxed execution, returning the wrapped +/// invocation (program, argv, env) plus the live [`sandbox::Sandbox`] that must +/// be kept alive for the command's duration. When `sandbox_wrap` is `None` the +/// command is returned unchanged. /// -/// The Windows sandbox (Bubblewrap inside WSL) can only toggle network access -/// wholesale, so `network_policy` collapses to allow/deny here just as it does -/// on Linux. `setup_network_proxy` never resolves to `Proxied` on Windows. -#[cfg(target_os = "windows")] -pub(crate) async fn apply_windows_wsl_sandbox_wrap( - command: String, +/// The sandbox owns the network proxy (for restricted-network policies) and any +/// per-command policy file; the env it returns already routes through that +/// proxy when applicable. +pub(crate) async fn prepare_sandbox_wrap( + program: String, args: Vec, - cwd: Option, - sandbox_wrap: SandboxWrap, - network_policy: NetworkPolicy, - env: collections::HashMap, -) -> anyhow::Result<(String, Vec, Option)> { - let allow_network = match network_policy { - NetworkPolicy::Denied => false, - NetworkPolicy::Unrestricted => true, - NetworkPolicy::Proxied(port) => { - // Bubblewrap (in WSL) can only toggle network access wholesale, so - // it can't confine egress to the proxy's loopback port. - // `setup_network_proxy` never resolves to `Proxied` on Windows; - // deny network rather than silently widening access. - log::debug!( - "[sandbox/network] ignoring proxy port {port}; bubblewrap in WSL can't confine to a loopback port" - ); - false - } - }; - let (program, args) = task::ShellBuilder::new(&Shell::Program("/bin/sh".to_string()), false) - .non_interactive() - .redirect_stdin_to_dev_null() - .build(Some(command), &args); - let writable: Vec = sandbox_wrap - .writable_paths - .into_iter() - .chain(sandbox_wrap.extra_write_paths) - .collect(); - let permissions = sandbox::SandboxPermissions { - allow_network, - allow_fs_write: sandbox_wrap.allow_fs_write, - }; - let (program, args) = - sandbox::windows_wsl::wrap_invocation(program, args, writable, permissions, cwd, env) - .await?; - Ok((program, args, None)) -} - -/// Spawn the in-process network proxy for a sandboxed command with restricted -/// network access, and wire the child's environment to route through it. -/// -/// Returns the proxy handle (which must outlive the command) alongside the -/// resolved [`NetworkPolicy`] the sandbox should enforce. The handle is `Some` -/// only when a proxy was actually spawned. Unrestricted network access skips -/// proxy setup and resolves to [`NetworkPolicy::Unrestricted`]. Restricted -/// network access requires a local macOS project so the sandbox can confine -/// egress to the proxy; otherwise this rejects the command instead of widening -/// it. -pub(crate) fn setup_network_proxy( - sandbox_wrap: Option<&SandboxWrap>, - env: &mut HashMap, - cx: &mut AsyncApp, -) -> Result<(Option, NetworkPolicy)> { + cwd: Option, + sandbox_wrap: Option, + env: HashMap, +) -> anyhow::Result<( + String, + Vec, + HashMap, + Option, +)> { let Some(sandbox_wrap) = sandbox_wrap else { - return Ok((None, NetworkPolicy::Denied)); - }; - let Some(allowlist) = sandbox_wrap.network.restricted_allowlist() else { - let policy = match &sandbox_wrap.network { - SandboxNetworkAccess::None => NetworkPolicy::Denied, - SandboxNetworkAccess::All => NetworkPolicy::Unrestricted, - SandboxNetworkAccess::Restricted(_) => unreachable!(), - }; - return Ok((None, policy)); + return Ok((program, args, env, None)); }; - // The proxy only buys us anything when a Seatbelt sandbox confines the - // child to its loopback port, and only works for local projects. - if !cfg!(target_os = "macos") || !sandbox_wrap.is_local { - anyhow::bail!("restricted network access requested, but no enforcing proxy is available"); + let mut sandbox = + sandbox::Sandbox::new(sandbox_wrap.to_policy()?).map_err(anyhow::Error::new)?; + // Windows/WSL only: tell the sandbox which Linux `zed` to provision inside + // WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere. + #[cfg(target_os = "windows")] + if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() { + sandbox.set_wsl_zed_release(channel, version); } - - // Chain through the user's real upstream proxy if the command's environment - // names one. A malformed value shouldn't break the terminal, so log and skip. - let upstream = match upstream_proxy_from_child_env(env) { - Ok(upstream) => upstream, - Err(error) => { - log::warn!("[sandbox/network] ignoring upstream proxy env: {error:#}"); - None - } + let command = sandbox::CommandAndArgs { + program, + args, + env: env.into_iter().collect::>(), + cwd, }; - - let (events_tx, events_rx) = futures::channel::mpsc::unbounded(); - let handle = ProxyHandle::spawn(ProxyConfig { - allowlist: allowlist.clone(), - upstream, - events: events_tx, - })?; - let port = handle.port(); - - apply_proxy_env(env, port); - spawn_proxy_event_logger(events_rx, cx); - - Ok((Some(handle), NetworkPolicy::Proxied(port))) -} - -fn upstream_proxy_from_child_env(env: &HashMap) -> Result> { - let url = first_nonempty_env_value( - env, - &[ - "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", - "HTTP_PROXY", - "http_proxy", - ], - ); - let no_proxy = first_nonempty_env_value(env, &["NO_PROXY", "no_proxy"]); - UpstreamProxy::parse(url, no_proxy) -} - -fn first_nonempty_env_value<'a>( - env: &'a HashMap, - names: &[&str], -) -> Option<&'a str> { - for name in names { - if let Some(value) = env.get(*name) - && !value.trim().is_empty() - { - return Some(value.as_str()); - } - } - None -} - -/// Point the child's proxy env vars at the in-process proxy and strip any -/// inherited `NO_PROXY`. -/// -/// Both upper- and lower-case forms are set because some clients (notably -/// curl on macOS) only honor the lowercase variant. `NO_PROXY` is blanked -/// out so all egress goes through our proxy unconditionally: an inherited -/// `NO_PROXY` matching an allowlisted host would make the client attempt a -/// direct connection, which the Seatbelt rule blocks — surfacing as a -/// confusing "connection refused" instead of a clean policy decision. -fn apply_proxy_env(env: &mut HashMap, port: u16) { - let url = format!("http://127.0.0.1:{port}"); - for key in [ - "HTTPS_PROXY", - "https_proxy", - "HTTP_PROXY", - "http_proxy", - "ALL_PROXY", - "all_proxy", - ] { - env.insert(key.to_string(), url.clone()); - } - for key in ["NO_PROXY", "no_proxy"] { - env.insert(key.to_string(), String::new()); - } -} - -/// Drain the proxy's event channel, logging each event. v1 surfacing only; -/// future integrations (UI, telemetry) can replace or fan out this consumer. -fn spawn_proxy_event_logger( - mut events: futures::channel::mpsc::UnboundedReceiver, - cx: &mut AsyncApp, -) { - cx.background_spawn(async move { - use futures::StreamExt as _; - while let Some(event) = events.next().await { - log_proxy_event(&event); - } - }) - .detach(); -} - -fn log_proxy_event(event: &ProxyEvent) { - match event { - ProxyEvent::Ready { .. } => {} - ProxyEvent::RequestAttempt { - host, - port, - method, - outcome, - } => { - log::debug!( - "[sandbox/network] {} {host}:{port} → {outcome:?}", - method.as_str() - ); - } - ProxyEvent::RequestCompleted { - host, - port, - method, - bytes_to_remote, - bytes_from_remote, - duration_ms, - } => { - log::debug!( - "[sandbox/network] completed {} {host}:{port} sent={bytes_to_remote} recv={bytes_from_remote} duration={duration_ms}ms", - method.as_str(), - ); - } - } + let wrapped = sandbox.wrap(&command).await.map_err(anyhow::Error::new)?; + Ok(( + wrapped.program, + wrapped.args, + wrapped.env.into_iter().collect(), + Some(sandbox), + )) } pub struct Terminal { @@ -601,11 +412,11 @@ pub struct Terminal { /// (e.g., clicking the Stop button). This is set before kill() is called /// so that code awaiting wait_for_exit() can check it deterministically. user_stopped: Arc, - /// Seatbelt config kept alive until the sandboxed command exits. - /// `None` when the command isn't sandboxed or after it finishes. - _sandbox_config: Option, - /// In-process network proxy kept alive until the sandboxed command exits. - _network_proxy: Option, + /// The live sandbox (Seatbelt policy file and/or network proxy) kept alive + /// until the sandboxed command exits. `None` when the command isn't + /// sandboxed or after it finishes. Dropping it tears down the proxy on a + /// background thread (see `sandbox::Sandbox`'s `Drop`). + _sandbox: Option, } pub struct TerminalOutput { @@ -624,15 +435,26 @@ impl Terminal { output_byte_limit: Option, terminal: Entity, language_registry: Arc, - sandbox_config: Option, - network_proxy: Option, + sandbox: Option, cx: &mut Context, ) -> Self { let command_task = terminal.read(cx).wait_for_completed_task(cx); + // Tear the sandbox down on a GPUI background thread when this entity is + // released, rather than relying on `Sandbox`'s `Drop` (which would spawn + // a throwaway thread) on whatever thread releases us. `on_release` hands + // us an `App`, so we can drive the teardown through the background + // executor with `drop_on_current_thread`. + cx.on_release(|this, cx| { + if let Some(sandbox) = this._sandbox.take() { + cx.background_executor() + .spawn(async move { sandbox.drop_on_current_thread() }) + .detach(); + } + }) + .detach(); Self { id, - _sandbox_config: sandbox_config, - _network_proxy: network_proxy, + _sandbox: sandbox, command: cx.new(|cx| { Markdown::new( format!("```\n{}\n```", command_label).into(), @@ -662,14 +484,16 @@ impl Terminal { original_content_len, content_line_count, }); - // Dropping the proxy handle joins its listener thread - // (after a loopback wakeup connect); do that off the - // foreground thread so a slow/wedged shutdown can't - // stall the UI. - if let Some(proxy) = this._network_proxy.take() { - cx.background_spawn(async move { drop(proxy) }).detach(); + // Free the sandbox (and its network proxy) as soon as + // the command finishes, rather than holding it until + // this entity is released. The proxy's teardown joins a + // listener thread, so run it on the background executor + // to keep it off the foreground thread. + if let Some(sandbox) = this._sandbox.take() { + cx.background_executor() + .spawn(async move { sandbox.drop_on_current_thread() }) + .detach(); } - this._sandbox_config = None; cx.notify(); }) .ok(); @@ -842,76 +666,89 @@ pub async fn create_terminal_entity( .await } -#[cfg(test)] -mod tests { +#[cfg(all(test, target_os = "linux"))] +mod linux_tests { use super::*; + /// Regression test for the bug where enforcement-policy construction + /// *created* missing write grants — famously turning a granted + /// `~/.config/zed/AGENTS.md` file path into a directory. A grant whose + /// target no longer exists must fail policy construction with an error + /// naming it, and nothing may be created — a required safety grant that + /// can't be honored must stop the command, not silently shrink its access. #[test] - fn only_restricted_network_access_uses_proxy_allowlist() { - assert!(SandboxNetworkAccess::None.restricted_allowlist().is_none()); - assert!(SandboxNetworkAccess::All.restricted_allowlist().is_none()); + fn to_policy_fails_on_missing_grant_and_never_creates_it() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let missing = temp_dir.path().join("AGENTS.md"); + + let wrap = SandboxWrap { + extra_write_paths: vec![settings::GrantedWritePath::resolved( + missing.clone(), + missing.clone(), + )], + ..Default::default() + }; + + let error = wrap + .to_policy() + .expect_err("a grant to a missing path must fail policy construction"); assert!( - SandboxNetworkAccess::Restricted(Allowlist::from_patterns([ - http_proxy::HostPattern::parse("example.com").unwrap() - ])) - .restricted_allowlist() - .is_some() + format!("{error:#}").contains("AGENTS.md"), + "error should name the failing grant: {error:#}" + ); + assert!( + !missing.exists(), + "policy construction must never create the granted path" ); } + /// A baseline writable path (worktree root / scratch dir) that doesn't + /// exist must also fail: silently narrowing the sandbox would hand the + /// command less access than the model was told it has. #[test] - fn upstream_proxy_from_child_env_uses_from_env_precedence() { - let mut env = HashMap::default(); - env.insert("HTTPS_PROXY".to_string(), " ".to_string()); - env.insert("https_proxy".to_string(), "http://lower:1111".to_string()); - env.insert("ALL_PROXY".to_string(), "http://all:2222".to_string()); - env.insert("HTTP_PROXY".to_string(), "http://http:3333".to_string()); - env.insert("NO_PROXY".to_string(), "".to_string()); - env.insert("no_proxy".to_string(), "internal.example".to_string()); - - let upstream = upstream_proxy_from_child_env(&env) - .expect("proxy env should parse") - .expect("proxy env should configure an upstream"); - - assert_eq!(upstream.host, "lower"); - assert_eq!(upstream.port, 1111); - assert!(upstream.bypasses("internal.example", 443)); - assert!(!upstream.bypasses("zed.dev", 443)); + fn to_policy_fails_on_missing_writable_path() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let missing = temp_dir.path().join("gone"); + + let wrap = SandboxWrap { + writable_paths: vec![missing.clone()], + ..Default::default() + }; + + wrap.to_policy() + .expect_err("a missing writable path must fail policy construction"); + assert!( + !missing.exists(), + "policy construction must never create a writable path" + ); } + /// Protected paths are best-effort: an uncapturable one is dropped, never + /// fatal. That covers a missing path (`NotFound`) and one routed through a + /// regular file (`NotADirectory`) — the latter is the synthesized `.git` of + /// a single-file worktree (e.g. `settings.json/.git`). Unlike a writable + /// grant, a protection can't be materialized, and `.git` protection has an + /// inherent accepted loophole (`git init`), so failing here would only break + /// legitimate cases. Unit-level companion to the `settings.json/.git` NixOS + /// check. #[test] - fn apply_proxy_env_points_all_proxy_vars_at_proxy_and_blanks_no_proxy() { - let mut env = HashMap::default(); - env.insert("HTTPS_PROXY".to_string(), "http://corp:3128".to_string()); - env.insert("NO_PROXY".to_string(), "internal.example".to_string()); - env.insert("PATH".to_string(), "/usr/bin".to_string()); - - apply_proxy_env(&mut env, 54321); - - for key in [ - "HTTPS_PROXY", - "https_proxy", - "HTTP_PROXY", - "http_proxy", - "ALL_PROXY", - "all_proxy", - ] { - assert_eq!( - env.get(key).map(String::as_str), - Some("http://127.0.0.1:54321"), - "{key} should point at the in-process proxy" - ); - } - // An inherited NO_PROXY would make clients attempt direct - // connections that the Seatbelt rule blocks; it must be blanked. - for key in ["NO_PROXY", "no_proxy"] { - assert_eq!( - env.get(key).map(String::as_str), - Some(""), - "{key} should be blanked" - ); - } - // Unrelated variables pass through. - assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin")); + fn to_policy_skips_uncapturable_protected_paths() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let writable = temp_dir.path().join("writable"); + std::fs::create_dir(&writable).expect("create writable dir"); + let single_file_root = temp_dir.path().join("settings.json"); + std::fs::write(&single_file_root, b"{}").expect("create single-file worktree root"); + + let wrap = SandboxWrap { + writable_paths: vec![writable], + protected_paths: vec![ + temp_dir.path().join("no-such-.git"), + single_file_root.join(".git"), + ], + ..Default::default() + }; + + wrap.to_policy() + .expect("uncapturable protected paths must be dropped, not fail the policy"); } } diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index a2fcfe531595d0..5675dfa2b55eac 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -1,6 +1,6 @@ use std::{collections::HashSet, fmt::Display, rc::Rc, sync::Arc}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::{AcpDebugMessage, AcpDebugMessageContent, AcpDebugMessageDirection}; use agent_ui::agent_connection_store::AgentConnectionStatus; use agent_ui::{Agent, AgentConnectionStore, AgentPanel}; diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index f8b15f621e9d6b..6e2de669dedfc9 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -758,11 +758,10 @@ impl ActionLog { .read(cx) .entry_id(cx) .and_then(|entry_id| { - self.project.update(cx, |project, cx| { - project.delete_entry(entry_id, false, cx) - }) + self.project + .update(cx, |project, cx| project.delete_entry(entry_id, cx)) }) - .unwrap_or_else(|| Task::ready(Ok(None))); + .unwrap_or_else(|| Task::ready(Ok(()))); cx.background_spawn(async move { task.await?; @@ -1048,23 +1047,12 @@ pub struct DiffStats { } impl DiffStats { - pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { - let mut stats = DiffStats::default(); - let diff_snapshot = diff.snapshot(cx); - let buffer_snapshot = buffer.snapshot(); - let base_text = diff_snapshot.base_text(); - - for hunk in diff_snapshot.hunks(&buffer_snapshot) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - stats.lines_added += added_rows; - - let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; - let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; - let removed_rows = base_end.saturating_sub(base_start); - stats.lines_removed += removed_rows; + pub fn single_file(diff: &BufferDiff) -> Self { + let (lines_added, lines_removed) = diff.changed_row_counts(); + DiffStats { + lines_added, + lines_removed, } - - stats } pub fn all_files( @@ -1072,8 +1060,8 @@ impl DiffStats { cx: &App, ) -> Self { let mut total = DiffStats::default(); - for (buffer, diff) in changed_buffers { - let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + for (_, diff) in changed_buffers { + let stats = DiffStats::single_file(diff.read(cx)); total.lines_added += stats.lines_added; total.lines_removed += stats.lines_removed; } @@ -1831,14 +1819,14 @@ mod tests { action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx)); project .update(cx, |project, cx| { - project.delete_file(file1_path.clone(), false, cx) + project.delete_file(file1_path.clone(), cx) }) .unwrap() .await .unwrap(); project .update(cx, |project, cx| { - project.delete_file(file2_path.clone(), false, cx) + project.delete_file(file2_path.clone(), cx) }) .unwrap() .await @@ -2143,9 +2131,7 @@ mod tests { action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx)); }); project - .update(cx, |project, cx| { - project.delete_file(file_path.clone(), false, cx) - }) + .update(cx, |project, cx| project.delete_file(file_path.clone(), cx)) .unwrap() .await .unwrap(); @@ -3156,7 +3142,7 @@ mod tests { child_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx)); }); project - .update(cx, |project, cx| project.delete_file(file_path, false, cx)) + .update(cx, |project, cx| project.delete_file(file_path, cx)) .unwrap() .await .unwrap(); diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 4ca66790b0eb3d..70871044e6ed1a 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -671,6 +671,7 @@ impl Render for ActivityIndicator { } }) .label_size(LabelSize::Small) + .tab_index(0isize) .map(|this| match content.icon { ActivityIcon::LoadingSpinner => this.loading(true), ActivityIcon::Icon(icon_name) => this.start_icon( diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8420cbede15273..30eb959298ea7e 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -79,6 +79,11 @@ web_search.workspace = true zed_env_vars.workspace = true zstd.workspace = true +# Used only on Windows to resolve the running release channel/version so the WSL +# sandbox helper can fetch a matching Linux `zed`. +[target.'cfg(target_os = "windows")'.dependencies] +release_channel.workspace = true + [dev-dependencies] assets.workspace = true async-io.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 8287776bbe4cde..64e1c3b90a1b60 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -17,6 +17,10 @@ pub use db::*; use itertools::Itertools; pub use native_agent_server::NativeAgentServer; pub use pattern_extraction::*; +pub use sandboxing::{ + ThreadSandbox, sandbox_worktree_writable_paths, settings_sandbox_policy, + settings_thread_sandbox, +}; pub use shell_command_parser::extract_commands; pub use templates::*; pub use thread::*; @@ -26,9 +30,9 @@ pub use tools::*; use acp_thread::{ AcpThread, AgentModelId, AgentModelSelector, AgentSessionInfo, AgentSessionList, - AgentSessionListRequest, AgentSessionListResponse, TokenUsageRatio, UserMessageId, + AgentSessionListRequest, AgentSessionListResponse, ClientUserMessageId, TokenUsageRatio, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_skills::{ AGENTS_DIR_NAME, MAX_SKILL_DESCRIPTIONS_SIZE, MAX_SKILL_FILE_SIZE, ProjectSkillGroup, SKILL_FILE_NAME, Skill, SkillIndex, SkillLoadError, SkillLoadWarning, SkillScopeId, @@ -311,6 +315,7 @@ impl LanguageModels { }), is_latest: model.is_latest(), cost: model.model_cost_info().map(|cost| cost.to_shared_string()), + disabled: model.is_disabled(), } } @@ -370,7 +375,10 @@ impl LanguageModels { } } - cx.update(language_models::update_environment_fallback_model); + cx.update(|cx| { + LanguageModelRegistry::global(cx) + .update(cx, |registry, cx| registry.refresh_fallback_model(cx)) + }); }) } } @@ -439,18 +447,22 @@ impl gpui::EventEmitter for NativeAgent {} static RULES_FILE_REL_PATHS: LazyLock>> = LazyLock::new(|| { RULES_FILE_NAMES .iter() - .filter_map(|name| RelPath::unix(name).ok().map(|path| path.into_arc())) + .filter_map(|name| { + RelPath::from_unix_str(name) + .ok() + .map(|path| path.into_arc()) + }) .collect() }); static AGENTS_PREFIX: LazyLock>> = LazyLock::new(|| { - RelPath::unix(AGENTS_DIR_NAME) + RelPath::from_unix_str(AGENTS_DIR_NAME) .ok() .map(|path| path.into_arc()) }); static SKILLS_PREFIX: LazyLock>> = LazyLock::new(|| { - RelPath::unix(project_skills_relative_path()) + RelPath::from_unix_str(project_skills_relative_path()) .ok() .map(|path| path.into_arc()) }); @@ -485,7 +497,7 @@ async fn expand_project_skills_directories( worktree: &Entity, cx: &mut AsyncApp, ) -> Result<()> { - let agents_dir = RelPath::unix(AGENTS_DIR_NAME)?; + let agents_dir = RelPath::from_unix_str(AGENTS_DIR_NAME)?; let Some(skills_prefix) = SKILLS_PREFIX.as_ref() else { return Ok(()); }; @@ -511,7 +523,7 @@ fn project_skill_files_from_worktree(worktree: &Worktree) -> Vec Vec { - let id = acp_thread::UserMessageId::new(); + let id = acp_thread::ClientUserMessageId::new(); acp_thread.update(cx, |acp_thread, cx| { acp_thread.push_user_content_block_with_indent( @@ -1926,7 +1934,7 @@ impl NativeAgent { /// `/compact` slash command. fn send_compact_command( &self, - message_id: UserMessageId, + client_user_message_id: ClientUserMessageId, session_id: acp::SessionId, cx: &mut Context, ) -> Task> { @@ -1939,7 +1947,8 @@ impl NativeAgent { anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) })??; - let response_stream = thread.update(cx, |thread, cx| thread.compact(message_id, cx))?; + let response_stream = + thread.update(cx, |thread, cx| thread.compact(client_user_message_id, cx))?; acp_thread.update(cx, |acp_thread, cx| { acp_thread.update_token_usage(None, cx); }); @@ -1967,7 +1976,7 @@ impl NativeAgent { /// instructions followed by the user's request. fn send_skill_invocation( &self, - message_id: UserMessageId, + client_user_message_id: ClientUserMessageId, session_id: acp::SessionId, skill: Skill, original_content: Vec, @@ -2026,7 +2035,7 @@ impl NativeAgent { // the user can see what context was loaded for the skill. The // user's own typed message is already rendered by the normal // prompt flow, so we don't push it to the UI again here. - let injected_id = acp_thread::UserMessageId::new(); + let injected_id = acp_thread::ClientUserMessageId::new(); acp_thread.update(cx, |acp_thread, cx| { acp_thread.push_user_content_block_with_indent( Some(injected_id), @@ -2043,7 +2052,7 @@ impl NativeAgent { combined.extend(user_blocks); thread.update(cx, |thread, cx| { - thread.push_acp_user_block(message_id, combined, path_style, cx); + thread.push_acp_user_block(client_user_message_id, combined, path_style, cx); }); let response_stream = thread.update(cx, |thread, cx| thread.send_existing(cx))?; @@ -2221,16 +2230,22 @@ impl NativeAgentConnection { ) })??; cx.background_spawn(async move { - if let acp_thread::RequestPermissionOutcome::Selected(outcome) = - outcome_task.await - { - response - .send(outcome) - .map_err(|_| { - anyhow!("authorization receiver was dropped") - }) - .log_err(); - } + let outcome = match outcome_task.await { + acp_thread::RequestPermissionOutcome::Selected(outcome) => outcome, + acp_thread::RequestPermissionOutcome::InterruptedByFollowUp => { + acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new( + FOLLOW_UP_PERMISSION_DENIED_OPTION_ID, + ), + acp::PermissionOptionKind::RejectOnce, + ) + } + acp_thread::RequestPermissionOutcome::Cancelled => return, + }; + response + .send(outcome) + .map_err(|_| anyhow!("authorization receiver was dropped")) + .log_err(); }) .detach(); } @@ -2639,9 +2654,148 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }) as Rc) } + fn client_user_message_ids( + &self, + _cx: &App, + ) -> Option> { + let prompt: Rc = Rc::new(self.clone()); + Some(prompt) + } + fn prompt( &self, - id: acp_thread::UserMessageId, + params: acp::PromptRequest, + cx: &mut App, + ) -> Task> { + acp_thread::AgentSessionClientUserMessageIds::prompt( + self, + acp_thread::AgentSessionClientUserMessageIds::new_id(self), + params, + cx, + ) + } + + fn retry( + &self, + session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + Some(Rc::new(NativeAgentSessionRetry { + connection: self.clone(), + session_id: session_id.clone(), + }) as _) + } + + fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { + log::info!("Cancelling on session: {}", session_id); + self.0.update(cx, |agent, cx| { + if let Some(session) = agent.sessions.get(session_id) { + session + .thread + .update(cx, |thread, cx| thread.cancel(cx)) + .detach(); + } + }); + } + + fn truncate( + &self, + session_id: &acp::SessionId, + cx: &App, + ) -> Option> { + self.0.read_with(cx, |agent, _cx| { + agent.sessions.get(session_id).map(|session| { + Rc::new(NativeAgentSessionTruncate { + thread: session.thread.clone(), + acp_thread: session.acp_thread.downgrade(), + }) as _ + }) + }) + } + + fn set_title( + &self, + session_id: &acp::SessionId, + cx: &App, + ) -> Option> { + self.0.read_with(cx, |agent, _cx| { + agent + .sessions + .get(session_id) + .filter(|s| !s.thread.read(cx).is_subagent()) + .map(|session| { + Rc::new(NativeAgentSessionSetTitle { + thread: session.thread.clone(), + }) as _ + }) + }) + } + + fn session_list(&self, cx: &mut App) -> Option> { + let thread_store = self.0.read(cx).thread_store.clone(); + Some(Rc::new(NativeAgentSessionList::new(thread_store, cx)) as _) + } + + fn telemetry(&self) -> Option> { + Some(Rc::new(self.clone()) as Rc) + } + + fn wait_for_tools_ready(&self, cx: &mut App) -> Task<()> { + let agent = self.0.read(cx); + let Some(project_state) = agent.projects.values().next() else { + return Task::ready(()); + }; + let registry = project_state.context_server_registry.read(cx); + let has_pending = registry.has_pending_tool_loads(); + + let context_server_store = project_state.project.read(cx).context_server_store(); + let store = context_server_store.read(cx); + let configured_server_count = store.configured_server_ids().len(); + let registered_count = registry.registered_server_count(); + let has_unregistered_servers = + configured_server_count > 0 && registered_count < configured_server_count; + + if !has_pending && !has_unregistered_servers { + return Task::ready(()); + } + + let expected_servers = configured_server_count; + let registry_handle = project_state.context_server_registry.clone(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |cx| { + let poll_executor = executor.clone(); + let wait = async { + loop { + let all_done = cx.update(|cx| { + let reg = registry_handle.read(cx); + !reg.has_pending_tool_loads() + && reg.registered_server_count() >= expected_servers + }); + if all_done { + return; + } + poll_executor + .timer(std::time::Duration::from_millis(250)) + .await; + } + }; + let timeout = async { + executor.timer(std::time::Duration::from_secs(30)).await; + log::warn!("Timed out waiting for MCP tools to load (30s), proceeding without them"); + }; + futures::future::select(Box::pin(wait), Box::pin(timeout)).await; + }) + } + + fn into_any(self: Rc) -> Rc { + self + } +} + +impl acp_thread::AgentSessionClientUserMessageIds for NativeAgentConnection { + fn prompt( + &self, + client_user_message_id: acp_thread::ClientUserMessageId, params: acp::PromptRequest, cx: &mut App, ) -> Task> { @@ -2663,7 +2817,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { if let Some(parsed_command) = Command::parse(¶ms.prompt) { if parsed_command.is_unqualified(COMPACT_COMMAND_NAME) { return self.0.update(cx, |agent, cx| { - agent.send_compact_command(id, session_id, cx) + agent.send_compact_command(client_user_message_id, session_id, cx) }); } @@ -2680,7 +2834,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { { let skill = skill.clone(); return self.0.update(cx, |agent, cx| { - agent.send_skill_invocation(id, session_id.clone(), skill, params.prompt, cx) + agent.send_skill_invocation( + client_user_message_id, + session_id.clone(), + skill, + params.prompt, + cx, + ) }); } @@ -2715,7 +2875,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { return self.0.update(cx, |agent, cx| { agent.send_mcp_prompt( - id, + client_user_message_id, session_id.clone(), prompt_name, server_id, @@ -2763,7 +2923,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let skill = skill.clone(); return self.0.update(cx, |agent, cx| { agent.send_skill_invocation( - id, + client_user_message_id, session_id.clone(), skill, params.prompt, @@ -2783,128 +2943,14 @@ impl acp_thread::AgentConnection for NativeAgentConnection { .map(|block| UserMessageContent::from_content_block(block, path_style)) .collect::>(); log::debug!("Converted prompt to message: {} chars", content.len()); - log::debug!("Message id: {:?}", id); + log::debug!("Client user message id: {:?}", client_user_message_id); log::debug!("Message content: {:?}", content); - thread.update(cx, |thread, cx| thread.send(id, content, cx)) - }) - } - - fn retry( - &self, - session_id: &acp::SessionId, - _cx: &App, - ) -> Option> { - Some(Rc::new(NativeAgentSessionRetry { - connection: self.clone(), - session_id: session_id.clone(), - }) as _) - } - - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { - log::info!("Cancelling on session: {}", session_id); - self.0.update(cx, |agent, cx| { - if let Some(session) = agent.sessions.get(session_id) { - session - .thread - .update(cx, |thread, cx| thread.cancel(cx)) - .detach(); - } - }); - } - - fn truncate( - &self, - session_id: &acp::SessionId, - cx: &App, - ) -> Option> { - self.0.read_with(cx, |agent, _cx| { - agent.sessions.get(session_id).map(|session| { - Rc::new(NativeAgentSessionTruncate { - thread: session.thread.clone(), - acp_thread: session.acp_thread.downgrade(), - }) as _ + thread.update(cx, |thread, cx| { + thread.send(client_user_message_id, content, cx) }) }) } - - fn set_title( - &self, - session_id: &acp::SessionId, - cx: &App, - ) -> Option> { - self.0.read_with(cx, |agent, _cx| { - agent - .sessions - .get(session_id) - .filter(|s| !s.thread.read(cx).is_subagent()) - .map(|session| { - Rc::new(NativeAgentSessionSetTitle { - thread: session.thread.clone(), - }) as _ - }) - }) - } - - fn session_list(&self, cx: &mut App) -> Option> { - let thread_store = self.0.read(cx).thread_store.clone(); - Some(Rc::new(NativeAgentSessionList::new(thread_store, cx)) as _) - } - - fn telemetry(&self) -> Option> { - Some(Rc::new(self.clone()) as Rc) - } - - fn wait_for_tools_ready(&self, cx: &mut App) -> Task<()> { - let agent = self.0.read(cx); - let Some(project_state) = agent.projects.values().next() else { - return Task::ready(()); - }; - let registry = project_state.context_server_registry.read(cx); - let has_pending = registry.has_pending_tool_loads(); - - let context_server_store = project_state.project.read(cx).context_server_store(); - let store = context_server_store.read(cx); - let configured_server_count = store.configured_server_ids().len(); - let registered_count = registry.registered_server_count(); - let has_unregistered_servers = - configured_server_count > 0 && registered_count < configured_server_count; - - if !has_pending && !has_unregistered_servers { - return Task::ready(()); - } - - let expected_servers = configured_server_count; - let registry_handle = project_state.context_server_registry.clone(); - let executor = cx.background_executor().clone(); - cx.spawn(async move |cx| { - let poll_executor = executor.clone(); - let wait = async { - loop { - let all_done = cx.update(|cx| { - let reg = registry_handle.read(cx); - !reg.has_pending_tool_loads() - && reg.registered_server_count() >= expected_servers - }); - if all_done { - return; - } - poll_executor - .timer(std::time::Duration::from_millis(250)) - .await; - } - }; - let timeout = async { - executor.timer(std::time::Duration::from_secs(30)).await; - log::warn!("Timed out waiting for MCP tools to load (30s), proceeding without them"); - }; - futures::future::select(Box::pin(wait), Box::pin(timeout)).await; - }) - } - - fn into_any(self: Rc) -> Rc { - self - } } impl acp_thread::AgentTelemetry for NativeAgentConnection { @@ -3006,9 +3052,13 @@ struct NativeAgentSessionTruncate { } impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate { - fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task> { + fn run( + &self, + client_user_message_id: acp_thread::ClientUserMessageId, + cx: &mut App, + ) -> Task> { match self.thread.update(cx, |thread, cx| { - thread.truncate(message_id.clone(), cx)?; + thread.truncate(client_user_message_id.clone(), cx)?; Ok(thread.latest_token_usage()) }) { Ok(usage) => { @@ -3848,7 +3898,7 @@ mod internal_tests { let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); let model = Arc::new(FakeLanguageModel::default()); - let old_message_id = UserMessageId::new(); + let old_message_id = ClientUserMessageId::new(); cx.update(|cx| { let path_style = project.read(cx).path_style(cx); @@ -3864,9 +3914,10 @@ mod internal_tests { }); }); - let compact_message_id = UserMessageId::new(); + let compact_message_id = ClientUserMessageId::new(); let prompt_task = cx.update(|cx| { - connection.prompt( + acp_thread::AgentSessionClientUserMessageIds::prompt( + connection.as_ref(), compact_message_id, acp::PromptRequest::new(session_id.clone(), vec!["/compact".into()]), cx, @@ -3922,7 +3973,7 @@ mod internal_tests { let path_style = project.read(cx).path_style(cx); thread.update(cx, |thread, cx| { thread.push_acp_user_block( - UserMessageId::new(), + ClientUserMessageId::new(), [acp::ContentBlock::from("hello from the user")], path_style, cx, @@ -5618,6 +5669,7 @@ mod internal_tests { ui::IconName::ZedAssistant )), is_latest: false, + disabled: None, cost: None, }] )]) @@ -6092,6 +6144,212 @@ mod internal_tests { drop(reloaded_acp_thread); } + async fn persist_thread_with_fake_corp_model( + cx: &mut TestAppContext, + ) -> ( + Entity, + Rc, + Entity, + acp::SessionId, + Arc, + ) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({ "a": {} })).await; + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + + let model = Arc::new(FakeLanguageModel::with_id_and_thinking( + "fake-corp", + "custom-model-id", + "Custom Model Display Name", + false, + )); + let provider = Arc::new( + FakeLanguageModelProvider::new( + LanguageModelProviderId::from("fake-corp".to_string()), + LanguageModelProviderName::from("Fake Corp".to_string()), + ) + .with_models(vec![model.clone()]), + ); + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + }); + }); + agent.update(cx, |agent, cx| agent.models.refresh_list(cx)); + + let acp_thread = cx + .update(|cx| { + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) + }) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let selector = connection.model_selector(&session_id).unwrap(); + cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/custom-model-id"), cx)) + .await + .unwrap(); + + let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx)); + let send = cx.foreground_executor().spawn(send); + cx.run_until_parked(); + model.send_last_completion_stream_text_chunk("Response."); + model.end_last_completion_stream(); + send.await.unwrap(); + cx.run_until_parked(); + + cx.update(|cx| connection.clone().close_session(&session_id, cx)) + .await + .unwrap(); + drop(acp_thread); + + (agent, connection, project, session_id, provider) + } + + fn unregister_fake_corp(cx: &mut TestAppContext) { + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.unregister_provider( + LanguageModelProviderId::from("fake-corp".to_string()), + cx, + ); + }); + }); + } + + #[gpui::test] + async fn test_loaded_thread_resolves_model_when_provider_loads_late(cx: &mut TestAppContext) { + init_test(cx); + let (agent, _connection, project, session_id, provider) = + persist_thread_with_fake_corp_model(cx).await; + + // Simulate a restart where the provider hasn't fetched its model list + // yet, so the saved selection can't be resolved at load time. + unregister_fake_corp(cx); + + let reloaded_acp_thread = agent + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) + .await + .unwrap(); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + thread.read_with(cx, |thread, _| { + assert!( + thread.model().is_none(), + "should not fall back to an unrelated model" + ); + }); + + // The original selection is persisted even while unresolved, so a save + // during the window can't overwrite the user's choice with a fallback. + let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await; + let saved = db_thread.model.expect("selection should be persisted"); + assert_eq!(saved.provider, "fake-corp"); + assert_eq!(saved.model, "custom-model-id"); + + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + }); + }); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!( + thread + .model() + .expect("model should resolve once provider loads") + .id() + .0 + .as_ref(), + "custom-model-id" + ); + }); + + drop(reloaded_acp_thread); + } + + #[gpui::test] + async fn test_explicit_model_selection_cancels_pending(cx: &mut TestAppContext) { + init_test(cx); + let (agent, connection, project, session_id, provider) = + persist_thread_with_fake_corp_model(cx).await; + + unregister_fake_corp(cx); + + let reloaded_acp_thread = agent + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) + .await + .unwrap(); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + thread.read_with(cx, |thread, _| { + assert!(thread.model().is_none()); + }); + + // The user explicitly picks a different, available model. + let other_model = Arc::new(FakeLanguageModel::with_id_and_thinking( + "other-corp", + "other-model-id", + "Other Model", + false, + )); + let other_provider = Arc::new( + FakeLanguageModelProvider::new( + LanguageModelProviderId::from("other-corp".to_string()), + LanguageModelProviderName::from("Other Corp".to_string()), + ) + .with_models(vec![other_model.clone()]), + ); + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.register_provider(other_provider, cx); + }); + }); + cx.run_until_parked(); + + let selector = connection.model_selector(&session_id).unwrap(); + cx.update(|cx| selector.select_model(AgentModelId::new("other-corp/other-model-id"), cx)) + .await + .unwrap(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.model().unwrap().id().0.as_ref(), "other-model-id"); + }); + + // The original provider returning must not clobber the explicit choice. + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + }); + }); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.model().unwrap().id().0.as_ref(), + "other-model-id", + "a late provider load must not override the explicit selection" + ); + }); + + drop(reloaded_acp_thread); + } + #[gpui::test] async fn test_save_load_thread(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 4a15b0920e6e0c..0dd91bfdea5345 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -1,8 +1,8 @@ use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent}; -use acp_thread::UserMessageId; -use agent_client_protocol::schema as acp; +use acp_thread::ClientUserMessageId; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentProfileId; -use anyhow::{Result, anyhow}; +use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, IndexMap}; use futures::{FutureExt, future::Shared}; @@ -62,14 +62,12 @@ pub struct DbThread { #[serde(default)] pub cumulative_token_usage: language_model::TokenUsage, #[serde(default)] - pub request_token_usage: HashMap, + pub request_token_usage: HashMap, #[serde(default)] pub model: Option, #[serde(default)] pub profile: Option, #[serde(default)] - pub imported: bool, - #[serde(default)] pub subagent_context: Option, #[serde(default)] pub speed: Option, @@ -83,6 +81,44 @@ pub struct DbThread { pub ui_scroll_position: Option, #[serde(default)] pub sandboxed_terminal_temp_dir: Option, + /// Sandbox escalations the user approved "for the rest of this thread". + /// Persisted so reopening a thread keeps its grants. See + /// [`crate::sandboxing::ThreadSandboxGrants`]. + #[serde(default)] + pub sandbox_grants: DbSandboxGrants, +} + +/// Serialized form of the sandbox permissions the user granted "for the rest of +/// this thread" (the "Allow for this thread" prompt option). Stored inside the +/// thread blob; round-trips with [`crate::sandboxing::ThreadSandboxGrants`]. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct DbSandboxGrants { + /// Paths granted write access, each paired with the canonical + /// (symlink-resolved) target established when the grant was approved; each + /// covers its whole subtree. Legacy rows stored a bare path string per + /// entry, which still deserializes (as a grant with no resolved canonical) + /// via [`settings::GrantedWritePath`]'s string-or-object format. + #[serde(default)] + pub write_paths: Vec, + /// Host patterns granted network access, in canonical string form (e.g. + /// `github.com`, `*.npmjs.org`). Parsed back into patterns on load. + #[serde(default)] + pub network_hosts: Vec, + /// Whether arbitrary-host network access was granted. + #[serde(default)] + pub network_any_host: bool, + /// Whether unrestricted filesystem writes (the broad escape hatch) were + /// granted. + #[serde(default)] + pub allow_fs_write_all: bool, + + /// Whether the model-requested fully-unsandboxed escape was granted. + #[serde(default)] + pub unsandboxed: bool, + /// Whether running commands unsandboxed was allowed because the OS sandbox + /// could not be created (the fallback prompt's "for this thread" option). + #[serde(default)] + pub sandbox_fallback: bool, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -125,7 +161,6 @@ impl SharedThread { request_token_usage: Default::default(), model: self.model, profile: None, - imported: true, subagent_context: None, speed: None, thinking_enabled: false, @@ -133,6 +168,7 @@ impl SharedThread { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: DbSandboxGrants::default(), } } @@ -207,7 +243,7 @@ impl DbThread { content.push(UserMessageContent::Text(msg.context)); } - let id = UserMessageId::new(); + let id = ClientUserMessageId::new(); last_user_message_id = Some(id.clone()); crate::Message::User(UserMessage { @@ -249,7 +285,9 @@ impl DbThread { name: tool_use.name.into(), raw_input: serde_json::to_string(&tool_use.input) .unwrap_or_default(), - input: tool_use.input, + input: language_model::LanguageModelToolUseInput::Json( + tool_use.input, + ), is_input_complete: true, thought_signature: None, }, @@ -309,7 +347,6 @@ impl DbThread { request_token_usage, model: thread.model, profile: thread.profile, - imported: false, subagent_context: None, speed: None, thinking_enabled: false, @@ -317,6 +354,7 @@ impl DbThread { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: DbSandboxGrants::default(), }) } } @@ -412,7 +450,7 @@ impl ThreadsDatabase { data BLOB NOT NULL ) "})?() - .map_err(|e| anyhow!("Failed to create threads table: {}", e))?; + .map_err(|e| e.context("Failed to create threads table"))?; if let Ok(mut s) = connection.exec(indoc! {" ALTER TABLE threads ADD COLUMN parent_id TEXT @@ -634,31 +672,48 @@ impl ThreadsDatabase { let connection = self.connection.clone(); self.executor.spawn(async move { - let sandboxed_terminal_temp_dir = { + let sandboxed_terminal_temp_dirs = { let connection = connection.lock(); + let mut select_children = + connection.select_bound::, Arc>(indoc! {" + SELECT id FROM threads WHERE parent_id = ? + "})?; + + // Collect target thread together with all of its transitive + // subagent threads + let mut ids_to_delete = vec![id.0.clone()]; + let mut frontier = vec![id.0.clone()]; + while let Some(parent) = frontier.pop() { + for child in select_children(parent)? { + ids_to_delete.push(child.clone()); + frontier.push(child); + } + } + let mut select = connection.select_bound::, (DataType, Vec)>(indoc! {" SELECT data_type, data FROM threads WHERE id = ? LIMIT 1 "})?; - let sandboxed_terminal_temp_dir = select(id.0.clone())? - .into_iter() - .next() - .and_then(|(data_type, data)| { - Self::sandboxed_terminal_temp_dir(data_type, data) - }); - let mut delete = connection.exec_bound::>(indoc! {" DELETE FROM threads WHERE id = ? "})?; - delete(id.0)?; + let mut sandboxed_terminal_temp_dirs = Vec::new(); + for thread_id in ids_to_delete { + if let Some(temp_dir) = select(thread_id.clone())?.into_iter().next().and_then( + |(data_type, data)| Self::sandboxed_terminal_temp_dir(data_type, data), + ) { + sandboxed_terminal_temp_dirs.push(temp_dir); + } + delete(thread_id)?; + } - sandboxed_terminal_temp_dir + sandboxed_terminal_temp_dirs }; - if let Some(temp_dir) = sandboxed_terminal_temp_dir { + for temp_dir in sandboxed_terminal_temp_dirs { Self::remove_sandboxed_terminal_temp_dir(temp_dir); } @@ -728,23 +783,6 @@ mod tests { assert_eq!(restored.updated_at, original.updated_at); } - #[test] - fn test_imported_flag_defaults_to_false() { - // Simulate deserializing a thread without the imported field (backwards compatibility). - let json = r#"{ - "title": "Old Thread", - "messages": [], - "updated_at": "2024-01-01T00:00:00Z" - }"#; - - let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize"); - - assert!( - !db_thread.imported, - "Legacy threads without imported field should default to false" - ); - } - fn session_id(value: &str) -> acp::SessionId { acp::SessionId::new(Arc::::from(value)) } @@ -760,7 +798,6 @@ mod tests { request_token_usage: HashMap::default(), model: None, profile: None, - imported: false, subagent_context: None, speed: None, thinking_enabled: false, @@ -768,6 +805,7 @@ mod tests { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: DbSandboxGrants::default(), } } @@ -887,6 +925,63 @@ mod tests { ); } + #[test] + fn test_sandbox_grants_default_when_absent() { + let json = r#"{ + "title": "Old Thread", + "messages": [], + "updated_at": "2024-01-01T00:00:00Z" + }"#; + + let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize"); + + assert_eq!( + db_thread.sandbox_grants, + DbSandboxGrants::default(), + "Legacy threads without sandbox_grants should default to empty grants" + ); + } + + #[gpui::test] + async fn test_sandbox_grants_roundtrip_through_save_load(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + let thread_id = session_id("sandbox-grants-thread"); + let mut thread = make_thread( + "Sandbox Grants Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + let grants = DbSandboxGrants { + write_paths: vec![ + // A legacy bare-string grant (no resolved canonical) and a grant + // carrying its resolved canonical, to exercise both forms of the + // string-or-object round-trip. + settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/build")), + settings::GrantedWritePath::resolved( + PathBuf::from("/tmp/link"), + PathBuf::from("/tmp/real"), + ), + ], + network_hosts: vec!["github.com".to_string(), "*.npmjs.org".to_string()], + network_any_host: false, + allow_fs_write_all: false, + unsandboxed: true, + sandbox_fallback: true, + }; + thread.sandbox_grants = grants.clone(); + + database + .save_thread(thread_id.clone(), thread, PathList::default()) + .await + .unwrap(); + + let loaded = database + .load_thread(thread_id) + .await + .unwrap() + .expect("thread should exist"); + assert_eq!(loaded.sandbox_grants, grants); + } + #[gpui::test] async fn test_sandboxed_terminal_temp_dir_roundtrips_through_save_load( cx: &mut TestAppContext, @@ -943,6 +1038,62 @@ mod tests { assert!(!temp_dir.exists()); } + #[gpui::test] + async fn test_delete_thread_deletes_subagent_threads(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + + let parent_id = session_id("parent-thread"); + let child_id = session_id("child-thread"); + let grandchild_id = session_id("grandchild-thread"); + let unrelated_id = session_id("unrelated-thread"); + + let parent_thread = make_thread( + "Parent Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + + let mut child_thread = make_thread( + "Child Subagent Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + child_thread.subagent_context = Some(crate::SubagentContext { + parent_thread_id: parent_id.clone(), + depth: 1, + }); + + let mut grandchild_thread = make_thread( + "Grandchild Subagent Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + grandchild_thread.subagent_context = Some(crate::SubagentContext { + parent_thread_id: child_id.clone(), + depth: 2, + }); + + let unrelated_thread = make_thread( + "Unrelated Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + + for (id, thread) in [ + (parent_id.clone(), parent_thread), + (child_id.clone(), child_thread), + (grandchild_id.clone(), grandchild_thread), + (unrelated_id.clone(), unrelated_thread), + ] { + database + .save_thread(id, thread, PathList::default()) + .await + .unwrap(); + } + + database.delete_thread(parent_id.clone()).await.unwrap(); + + let remaining = database.list_threads().await.unwrap(); + let remaining_ids: Vec<_> = remaining.iter().map(|thread| thread.id.clone()).collect(); + assert_eq!(remaining_ids, vec![unrelated_id]); + } + #[gpui::test] async fn test_subagent_context_roundtrips_through_save_load(cx: &mut TestAppContext) { let database = ThreadsDatabase::new(cx.executor()).unwrap(); diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index be32c552655fe1..da160b27502960 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -7,9 +7,15 @@ //! //! The current policy is: enabled iff the user has the `sandboxing` feature //! flag turned on, the project is local, the platform has an integration, and -//! the user has not turned sandboxing off entirely (the `disabled` sandbox -//! setting; the `allow_unsandboxed` grant only auto-approves commands that -//! explicitly request to run unsandboxed and doesn't turn the sandbox off). +//! the user has not persistently allowed unsandboxed execution (the +//! `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed` +//! persistently turns sandboxing off for the model-facing surface entirely: +//! the plain (non-sandboxed) `terminal` tool is exposed and the system prompt +//! omits the sandbox section, since every command would run without a wrap +//! anyway. The model-requested `unsandboxed: true` escape approved "once" or +//! "for this thread" does NOT change the prompt/tool set — the sandboxed tool +//! stays exposed and only the individual command runs without a wrap. See +//! `sandboxing_enabled_for_project` and `ThreadSandboxGrants`. //! //! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL) //! have real sandbox integrations; on platforms without one the per-command @@ -24,20 +30,197 @@ use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; use gpui::App; use http_proxy::HostPattern; use project::Project; -use settings::Settings; +use sandbox::{HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; +use settings::{GrantedWritePath, Settings}; use std::path::PathBuf; +/// The directory subtrees the sandbox always grants write access to for a +/// project: its worktree roots. This is the single source of truth shared by +/// the terminal tool (which hands these to the sandbox as +/// [`acp_thread::SandboxWrap::writable_paths`]) and the status UI (which lists +/// them), so the two can't drift if the set ever changes. +pub fn sandbox_worktree_writable_paths(project: &Project, cx: &App) -> Vec { + project + .worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect() +} + +/// The candidate `.git` paths the sandbox protects for a project. Locating these +/// requires Git knowledge the sandbox layer can't derive itself: a worktree's +/// `.git`, a linked worktree's common dir (which lives outside the worktree), +/// and every discovered repository's git/common dirs. +pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec { + let mut git_dirs = Vec::new(); + + for worktree in project.worktrees(cx) { + let worktree = worktree.read(cx); + let worktree_abs_path = worktree.abs_path(); + // Protect `/.git` even when it doesn't exist yet, so a command + // can't `git init` and then write to the freshly created metadata. + // + // We don't gate this on the worktree's scanned root being a directory: + // that state can be stale/pending, and for a *single-file* worktree + // (e.g. `settings.json` opened on its own) this synthesizes + // `settings.json/.git`, which can never exist. That impossible path is + // resolved authoritatively at capture time — the real filesystem + // reports `NotADirectory`, and `SandboxWrap::to_policy` skips a + // protected path that can't exist — so it never reaches enforcement. + git_dirs.push(worktree_abs_path.join(".git")); + if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() { + git_dirs.push(root_repo_common_dir.to_path_buf()); + } + } + + for repository in project.git_store().read(cx).repositories().values() { + let repository = repository.read(cx); + git_dirs.push(repository.dot_git_abs_path.to_path_buf()); + git_dirs.push(repository.repository_dir_abs_path.to_path_buf()); + git_dirs.push(repository.common_dir_abs_path.to_path_buf()); + } + + git_dirs.sort(); + git_dirs.dedup(); + git_dirs +} + +/// What sandbox a thread applies to agent terminal commands, as one value the +/// UI renders and enforcement builds from. "No sandbox" is its own variant +/// rather than a maximally-permissive [`SandboxPolicy`] so that a wide-open but +/// real sandbox (e.g. `allow_fs_write_all` + `allow_all_hosts`) stays +/// distinguishable from running with no sandbox at all. The sandbox still +/// enforces invariants such as read-only Git metadata, while unsandboxed commands +/// run with ambient permissions. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ThreadSandbox { + /// No OS sandbox is applied; commands run with ambient permissions. + Unsandboxed, + /// A sandbox is applied with this scope. + Sandboxed(SandboxPolicy), +} + +impl ThreadSandbox { + /// Combine two layers (e.g. the persistent settings and this thread's + /// grants). + /// + /// This is treated as an allowlist - i.e. merging a sandbox that allows + /// resource A with a sandbox that allows resource B creates a sandbox with + /// access to both resource A and resource B. + pub fn merge(self, other: ThreadSandbox) -> ThreadSandbox { + match (self, other) { + (ThreadSandbox::Unsandboxed, _) | (_, ThreadSandbox::Unsandboxed) => { + ThreadSandbox::Unsandboxed + } + (ThreadSandbox::Sandboxed(a), ThreadSandbox::Sandboxed(b)) => { + ThreadSandbox::Sandboxed(a.merge(b)) + } + } + } + + /// Whether no OS sandbox is applied. + pub fn is_unsandboxed(&self) -> bool { + matches!(self, ThreadSandbox::Unsandboxed) + } + + /// Attach the project's protected paths to a sandboxed layer. The settings + /// and grants don't know the project's `.git` locations, so the caller + /// computes them via [`sandbox_git_dirs`]. A no-op for `Unsandboxed`. + pub fn with_protected_paths(self, protected_paths: Vec) -> ThreadSandbox { + match self { + ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed, + ThreadSandbox::Sandboxed(policy) => { + // Capture each protected location (pinning its inode / canonical + // path). A location that can't be captured is dropped — fail-closed. + let protected_paths = protected_paths + .into_iter() + .filter_map(|path| HostFilesystemLocation::capture(path).ok()) + .collect(); + ThreadSandbox::Sandboxed(policy.with_protected_paths(protected_paths)) + } + } + } +} + +/// The sandbox the user's persistent settings establish for every thread, as a +/// [`ThreadSandbox`]. The persistent `allow_unsandboxed` setting removes the +/// sandbox entirely; otherwise the writable-path and host grants form its +/// scope. The per-thread overrides come from [`ThreadSandboxGrants::thread_sandbox`]. +pub fn settings_thread_sandbox(persistent: &SandboxPermissions) -> ThreadSandbox { + if persistent.allow_unsandboxed { + ThreadSandbox::Unsandboxed + } else { + ThreadSandbox::Sandboxed(settings_sandbox_policy(persistent)) + } +} + +/// Translate the persistent "allow always" sandbox settings into the +/// cross-platform [`SandboxPolicy`] used for display. This is the "from your +/// settings" half of the sandbox status surface; the per-thread overrides come +/// from [`ThreadSandboxGrants::to_policy`]. +pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy { + let fs = if persistent.allow_fs_write_all { + SandboxFsPolicy::Unrestricted { + protected_paths: Vec::new(), + } + } else { + SandboxFsPolicy::Restricted { + // Route each grant through the verifying reopen (or a fresh capture + // for legacy bare-string grants) so the enforced path is the one + // vetted at approval time. A grant that fails to reopen is logged + // and dropped (fail-closed) rather than silently swallowed, then the + // survivors are reduced to a minimal cover on their canonical paths. + writable_paths: sandbox::normalize_host_filesystem_locations( + persistent + .write_paths + .iter() + .filter_map(acp_thread::granted_write_path_to_location_or_log), + ), + protected_paths: Vec::new(), + } + }; + let network = if persistent.allow_all_hosts { + SandboxNetPolicy::Unrestricted + } else if persistent.network_hosts.is_empty() { + SandboxNetPolicy::Blocked + } else { + SandboxNetPolicy::Restricted { + allowed_domains: persistent.network_hosts.clone(), + } + }; + SandboxPolicy { fs, network } +} + +/// Whether the sandboxed terminal can be exposed for this project. +/// +/// The persistent `allow_unsandboxed` setting turns sandboxing off for the +/// model-facing surface: when it's set we expose the plain `terminal` tool and +/// omit the sandbox section from the system prompt, because every command would +/// run without a wrap regardless. This is deliberately keyed off the +/// *persistent* setting only. A model-requested `unsandboxed: true` escape that +/// the user approves "once" or "for this thread" keeps the sandboxed tool and +/// prompt in place, since the model is still operating in the sandbox model and +/// only escaping individual commands (tracked in `ThreadSandboxGrants`). +pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool { + sandboxing_available_for_project(project, cx) + && !AgentSettings::get_global(cx) + .sandbox_permissions + .allow_unsandboxed +} + /// Whether agent-run terminal commands should be wrapped in an OS-level /// sandbox for this process. See module docs for the policy. pub(crate) fn sandboxing_enabled(cx: &App) -> bool { cx.has_flag::() } -/// Whether the sandboxed terminal can be exposed for this project. -pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool { +/// Whether sandboxing is *applicable* for this project at all — the feature is +/// enabled, the project is local, and the platform has a sandbox integration — +/// independent of the persistent `allow_unsandboxed` setting. Used by the UI to +/// distinguish "sandboxing isn't relevant here" (don't show the indicator) from +/// "sandboxing is available but turned off in settings" (show it, struck out). +pub(crate) fn sandboxing_available_for_project(project: &Project, cx: &App) -> bool { sandboxing_enabled(cx) && project.is_local() - && !AgentSettings::get_global(cx).sandbox_permissions.disabled && cfg!(any( target_os = "macos", target_os = "linux", @@ -90,13 +273,15 @@ impl NetworkRequest { pub(crate) struct SandboxRequest { /// Outbound network access requested for this command. pub network: NetworkRequest, + /// Allow unrestricted filesystem writes (the broad escape hatch). pub allow_fs_write_all: bool, /// Run the command fully outside the sandbox. pub unsandboxed: bool, - /// Concrete paths the command needs to write to. Each grants its whole + /// Concrete paths the command needs to write to, each paired with the + /// canonical target resolved at approval time. Each grants its whole /// subtree. These are never globs — write access is always a concrete path subtree - pub write_paths: Vec, + pub write_paths: Vec, } impl SandboxRequest { @@ -132,9 +317,10 @@ pub(crate) struct ThreadSandboxGrants { /// `unsandboxed`, which records a model-requested escape; this is a /// user-acknowledged degradation because the sandbox is unavailable. sandbox_fallback: bool, - /// Canonicalized paths granted write access for the thread. Each covers its - /// whole subtree; redundant children are pruned on insert. - write_paths: Vec, + /// Paths granted write access for the thread, each paired with the canonical + /// target resolved at approval time. Each covers its whole subtree; redundant + /// children are pruned on insert (by canonical target). + write_paths: Vec, } impl ThreadSandboxGrants { @@ -152,26 +338,35 @@ impl ThreadSandboxGrants { persistent: &SandboxPermissions, ) -> bool { if request.unsandboxed { - return self.unsandboxed || persistent.allow_unsandboxed; + // The persistent `allow_unsandboxed` setting is intentionally not + // consulted here: when it's set, sandboxing is removed from the + // model-facing surface (the plain `terminal` tool is exposed + // instead of the sandboxed one), so the model can't issue an + // `unsandboxed: true` request at all. Only a "for this thread" + // grant suppresses the re-prompt while the sandboxed tool is + // active — see `sandboxing_enabled_for_project`. + return self.unsandboxed; } if !self.network_covered(&request.network, persistent) { return false; } + if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all) { return false; } - // A full-access write grant covers any concrete write request. + // A full-access write grant covers any concrete write request at the + // authorization layer; protected paths are enforced by the sandbox. if self.allow_fs_write_all || persistent.allow_fs_write_all { return true; } - request.write_paths.iter().all(|requested| { + request.write_paths.iter().all(|request| { util::paths::path_within_subtree( - requested, + request.canonical_or_requested(), self.write_paths .iter() .chain(persistent.write_paths.iter()) - .map(PathBuf::as_path), + .map(|granted| granted.canonical_or_requested()), ) }) } @@ -205,6 +400,14 @@ impl ThreadSandboxGrants { self.sandbox_fallback } + /// Whether the user approved running model-requested `unsandboxed: true` + /// commands for the rest of the thread. Once granted, every command in the + /// thread runs without a sandbox (the model can no longer scope access), + /// mirroring the `sandbox_fallback` grant. + pub fn unsandboxed_granted(&self) -> bool { + self.unsandboxed + } + /// Record that the user approved running commands unsandboxed for the rest /// of the thread when the sandbox can't be created. Only the Bubblewrap /// sandboxes (Linux directly, Windows via WSL) can fail to create a @@ -214,6 +417,102 @@ impl ThreadSandboxGrants { self.sandbox_fallback = true; } + /// The sandbox this thread's grants establish on top of the settings, as a + /// [`ThreadSandbox`]. A standing "run unsandboxed" grant (a model-requested + /// escape approved for the thread, or the sandbox-creation fallback) removes + /// the sandbox entirely; otherwise the granted writable paths and hosts form + /// its scope. This is the "overridden in this thread" half of the sandbox + /// status surface; the persistent half comes from [`settings_thread_sandbox`]. + pub fn thread_sandbox(&self) -> ThreadSandbox { + if self.unsandboxed || self.sandbox_fallback { + ThreadSandbox::Unsandboxed + } else { + ThreadSandbox::Sandboxed(self.to_policy()) + } + } + + /// Translate the per-thread overrides into the cross-platform + /// [`SandboxPolicy`] used for display. This is the "overridden in this + /// thread" half of the sandbox status surface; the persistent half comes + /// from [`settings_sandbox_policy`]. + pub fn to_policy(&self) -> SandboxPolicy { + let fs = if self.allow_fs_write_all { + SandboxFsPolicy::Unrestricted { + protected_paths: Vec::new(), + } + } else { + SandboxFsPolicy::Restricted { + // Route each grant through the verifying reopen (or a fresh + // capture for legacy bare-string grants) so the enforced path is + // the one vetted at approval time. A grant that fails to reopen + // is logged and dropped (fail-closed) rather than silently + // swallowed, then the survivors are reduced to a minimal cover + // on their canonical paths. + writable_paths: sandbox::normalize_host_filesystem_locations( + self.write_paths + .iter() + .filter_map(acp_thread::granted_write_path_to_location_or_log), + ), + protected_paths: Vec::new(), + } + }; + let network = if self.network_any_host { + SandboxNetPolicy::Unrestricted + } else if self.network_hosts.is_empty() { + SandboxNetPolicy::Blocked + } else { + SandboxNetPolicy::Restricted { + allowed_domains: self + .network_hosts + .iter() + .map(|host| host.to_string()) + .collect(), + } + }; + SandboxPolicy { fs, network } + } + + /// Serialize these grants for persistence in the thread's database row. + /// Host patterns are written in canonical string form so they round-trip + /// through [`HostPattern::parse`] on load. + pub fn to_db(&self) -> crate::db::DbSandboxGrants { + crate::db::DbSandboxGrants { + write_paths: self.write_paths.clone(), + network_hosts: self + .network_hosts + .iter() + .map(|host| host.to_string()) + .collect(), + network_any_host: self.network_any_host, + allow_fs_write_all: self.allow_fs_write_all, + unsandboxed: self.unsandboxed, + sandbox_fallback: self.sandbox_fallback, + } + } + + /// Rebuild thread grants from the persisted form. Host patterns that no + /// longer parse (e.g. after a hand-edit) are dropped with a warning rather + /// than failing the whole thread load. + pub fn from_db(db: &crate::db::DbSandboxGrants) -> Self { + let mut network_hosts = Vec::new(); + for raw in &db.network_hosts { + match HostPattern::parse(raw) { + Ok(pattern) => insert_host_pattern(&mut network_hosts, pattern), + Err(error) => { + log::warn!("ignoring invalid persisted sandbox network host '{raw}': {error}") + } + } + } + Self { + network_any_host: db.network_any_host, + network_hosts, + allow_fs_write_all: db.allow_fs_write_all, + unsandboxed: db.unsandboxed, + sandbox_fallback: db.sandbox_fallback, + write_paths: db.write_paths.clone(), + } + } + /// Record everything in `request` as granted for the rest of the thread, /// pruning entries that become redundant. pub fn record(&mut self, request: &SandboxRequest) { @@ -228,8 +527,8 @@ impl ThreadSandboxGrants { } self.allow_fs_write_all |= request.allow_fs_write_all; self.unsandboxed |= request.unsandboxed; - for path in &request.write_paths { - util::paths::insert_subtree(&mut self.write_paths, path.clone()); + for granted in &request.write_paths { + insert_granted_subtree(&mut self.write_paths, granted.clone()); } } @@ -272,8 +571,8 @@ impl ThreadSandboxGrants { }; let mut write_paths = persistent.write_paths.clone(); - for path in self.write_paths.iter().chain(request.write_paths.iter()) { - util::paths::insert_subtree(&mut write_paths, path.clone()); + for granted in self.write_paths.iter().chain(request.write_paths.iter()) { + insert_granted_subtree(&mut write_paths, granted.clone()); } SandboxRequest { network, @@ -304,6 +603,26 @@ fn parse_persistent_hosts(raw: &[String]) -> Vec { .collect() } +/// Insert `granted` into a write-grant set, keeping it minimal by comparing each +/// entry's canonical (resolved) target. Mirrors [`util::paths::insert_subtree`]: +/// skip the new grant if an existing entry's canonical is an ancestor-or-equal +/// of it, and prune existing entries whose canonical descends from the new one. +fn insert_granted_subtree(subtrees: &mut Vec, granted: GrantedWritePath) { + if subtrees.iter().any(|existing| { + granted + .canonical_or_requested() + .starts_with(existing.canonical_or_requested()) + }) { + return; + } + subtrees.retain(|existing| { + !existing + .canonical_or_requested() + .starts_with(granted.canonical_or_requested()) + }); + subtrees.push(granted); +} + /// Insert `pattern` into a host-pattern set, keeping it minimal: skip it if an /// existing entry already subsumes it, and drop existing entries it subsumes. /// The host-pattern analogue of [`util::paths::insert_subtree`]. @@ -318,6 +637,7 @@ pub(crate) fn insert_host_pattern(set: &mut Vec, pattern: HostPatte #[cfg(test)] mod tests { use super::*; + use std::path::Path; fn hosts(list: &[&str]) -> NetworkRequest { NetworkRequest::Hosts( @@ -332,10 +652,28 @@ mod tests { network, allow_fs_write_all: all, unsandboxed: false, - write_paths: paths.iter().map(PathBuf::from).collect(), + write_paths: granted(paths), } } + /// Bare-string grants (no resolved canonical) for the lexical + /// coverage/record/dedup tests, which never build a real policy. + fn granted(paths: &[&str]) -> Vec { + paths + .iter() + .map(|p| GrantedWritePath::from_requested(PathBuf::from(p))) + .collect() + } + + /// A grant carrying the resolved canonical of a real directory, for the + /// tests that build an actual policy (which reopens/captures real fds). + fn resolved_grant(dir: &Path) -> GrantedWritePath { + GrantedWritePath::resolved( + dir.to_path_buf(), + sandbox::resolve_canonical(dir).expect("resolve canonical temp dir"), + ) + } + fn unsandboxed_request() -> SandboxRequest { SandboxRequest { network: NetworkRequest::None, @@ -345,6 +683,226 @@ mod tests { } } + #[test] + fn thread_sandbox_merge_unsandboxed_wins_else_unions_scopes() { + // Writable paths are captured as real `HostFilesystemLocation`s (which + // open an fd and key on the inode), so the test uses real directories. + let dir_a = tempfile::tempdir().expect("create temp dir a"); + let dir_b = tempfile::tempdir().expect("create temp dir b"); + let path_a = dir_a.path(); + let path_b = dir_b.path(); + + let policy = |paths: &[&Path], hosts: &[&str]| SandboxPolicy { + fs: SandboxFsPolicy::Restricted { + writable_paths: paths + .iter() + .map(|p| HostFilesystemLocation::capture(p).expect("capture temp dir")) + .collect(), + protected_paths: Vec::new(), + }, + network: if hosts.is_empty() { + SandboxNetPolicy::Blocked + } else { + SandboxNetPolicy::Restricted { + allowed_domains: hosts.iter().map(|h| h.to_string()).collect(), + } + }, + }; + + // Unsandboxed on either side wins — the agent runs with ambient access. + assert!( + ThreadSandbox::Unsandboxed + .merge(ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))) + .is_unsandboxed() + ); + assert!( + ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) + .merge(ThreadSandbox::Unsandboxed) + .is_unsandboxed() + ); + + // Two sandboxed layers union their scopes. + assert_eq!( + ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) + .merge(ThreadSandbox::Sandboxed(policy(&[path_b], &["b.com"]))), + ThreadSandbox::Sandboxed(policy(&[path_a, path_b], &["a.com", "b.com"])) + ); + } + + #[test] + fn settings_thread_sandbox_reflects_allow_unsandboxed() { + let unsandboxed = SandboxPermissions { + allow_unsandboxed: true, + ..Default::default() + }; + assert!(settings_thread_sandbox(&unsandboxed).is_unsandboxed()); + assert!(matches!( + settings_thread_sandbox(&SandboxPermissions::default()), + ThreadSandbox::Sandboxed(_) + )); + } + + #[test] + fn thread_grants_sandbox_reflects_unsandboxed_grant() { + let mut grants = ThreadSandboxGrants::default(); + assert!(matches!( + grants.thread_sandbox(), + ThreadSandbox::Sandboxed(_) + )); + grants.record(&unsandboxed_request()); + assert!(grants.thread_sandbox().is_unsandboxed()); + } + + #[test] + fn grants_roundtrip_through_db_form() { + let mut grants = ThreadSandboxGrants::default(); + grants.record(&request( + hosts(&["github.com", "*.npmjs.org"]), + false, + &["/tmp/build"], + )); + grants.record(&unsandboxed_request()); + + let restored = ThreadSandboxGrants::from_db(&grants.to_db()); + + // The restored grants cover exactly what the originals did. + assert!(covers( + &restored, + &request(hosts(&["api.npmjs.org"]), false, &["/tmp/build/cache"]) + )); + assert!(covers(&restored, &unsandboxed_request())); + assert_eq!(restored.network_hosts, grants.network_hosts); + assert_eq!(restored.write_paths, grants.write_paths); + assert_eq!(restored.unsandboxed, grants.unsandboxed); + } + + #[test] + fn db_form_preserves_any_host_and_write_all() { + let mut grants = ThreadSandboxGrants::default(); + grants.record(&request(NetworkRequest::AnyHost, true, &[])); + + let restored = ThreadSandboxGrants::from_db(&grants.to_db()); + assert!(restored.network_any_host); + assert!(restored.allow_fs_write_all); + assert!(covers( + &restored, + &request(NetworkRequest::AnyHost, true, &["/anywhere"]) + )); + } + + #[test] + fn thread_grants_to_policy_maps_paths_and_domains() { + use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + + // `to_policy` reopens/captures real `HostFilesystemLocation`s, so use a + // real dir and a grant carrying its resolved canonical. + let build_dir = tempfile::tempdir().expect("create temp build dir"); + + let mut grants = ThreadSandboxGrants::default(); + grants.record(&SandboxRequest { + network: hosts(&["github.com"]), + allow_fs_write_all: false, + unsandboxed: false, + write_paths: vec![resolved_grant(build_dir.path())], + }); + let policy = grants.to_policy(); + assert_eq!( + policy.fs, + SandboxFsPolicy::Restricted { + writable_paths: vec![ + HostFilesystemLocation::capture(build_dir.path()).expect("capture temp dir") + ], + protected_paths: Vec::new(), + } + ); + assert_eq!( + policy.network, + SandboxNetPolicy::Restricted { + allowed_domains: vec!["github.com".to_string()] + } + ); + + // No grants at all: writes restricted to nothing, network blocked. + let empty = ThreadSandboxGrants::default().to_policy(); + assert_eq!( + empty.fs, + SandboxFsPolicy::Restricted { + writable_paths: Vec::new(), + protected_paths: Vec::new(), + } + ); + assert_eq!(empty.network, SandboxNetPolicy::Blocked); + + // The broad escapes map to the unrestricted variants. + let mut broad = ThreadSandboxGrants::default(); + broad.record(&request(NetworkRequest::AnyHost, true, &[])); + let policy = broad.to_policy(); + assert_eq!( + policy.fs, + SandboxFsPolicy::Unrestricted { + protected_paths: Vec::new(), + } + ); + assert_eq!(policy.network, SandboxNetPolicy::Unrestricted); + } + + #[test] + fn settings_policy_maps_persistent_permissions() { + use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + + // `settings_sandbox_policy` captures real `HostFilesystemLocation`s. + let log_dir = tempfile::tempdir().expect("create temp log dir"); + let persistent = SandboxPermissions { + write_paths: vec![resolved_grant(log_dir.path())], + network_hosts: vec!["*.npmjs.org".to_string()], + ..Default::default() + }; + let policy = settings_sandbox_policy(&persistent); + assert_eq!( + policy.fs, + SandboxFsPolicy::Restricted { + writable_paths: vec![ + HostFilesystemLocation::capture(log_dir.path()).expect("capture temp dir") + ], + protected_paths: Vec::new(), + } + ); + assert_eq!( + policy.network, + SandboxNetPolicy::Restricted { + allowed_domains: vec!["*.npmjs.org".to_string()] + } + ); + + let unrestricted = SandboxPermissions { + allow_all_hosts: true, + allow_fs_write_all: true, + ..Default::default() + }; + let policy = settings_sandbox_policy(&unrestricted); + assert_eq!( + policy.fs, + SandboxFsPolicy::Unrestricted { + protected_paths: Vec::new(), + } + ); + assert_eq!(policy.network, SandboxNetPolicy::Unrestricted); + } + + #[test] + fn db_form_drops_unparsable_persisted_hosts() { + let db = crate::db::DbSandboxGrants { + // IP literals are explicitly rejected by the host-pattern parser. + network_hosts: vec!["github.com".to_string(), "10.0.0.1".to_string()], + ..Default::default() + }; + let restored = ThreadSandboxGrants::from_db(&db); + assert_eq!( + restored.network_hosts, + vec![HostPattern::parse("github.com").unwrap()] + ); + } + fn covers(grants: &ThreadSandboxGrants, request: &SandboxRequest) -> bool { grants.covers_with_persistent(request, &SandboxPermissions::default()) } @@ -415,7 +973,7 @@ mod tests { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"])); grants.record(&request(NetworkRequest::None, false, &["/tmp/build"])); - assert_eq!(grants.write_paths, vec![PathBuf::from("/tmp/build")]); + assert_eq!(grants.write_paths, granted(&["/tmp/build"])); } #[test] @@ -423,7 +981,7 @@ mod tests { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(NetworkRequest::None, false, &["/tmp/build"])); grants.record(&request(NetworkRequest::None, false, &["/tmp/build/cache"])); - assert_eq!(grants.write_paths, vec![PathBuf::from("/tmp/build")]); + assert_eq!(grants.write_paths, granted(&["/tmp/build"])); } #[test] @@ -518,7 +1076,7 @@ mod tests { let mut grants = ThreadSandboxGrants::default(); grants.record(&request(hosts(&["github.com"]), false, &[])); let persistent = SandboxPermissions { - write_paths: vec![PathBuf::from("/tmp/build")], + write_paths: granted(&["/tmp/build"]), ..Default::default() }; @@ -572,21 +1130,34 @@ mod tests { } #[test] - fn persistent_unsandboxed_covers_unsandboxed_requests_only() { + fn thread_grant_covers_unsandboxed_requests() { + // A "for this thread" grant suppresses the re-prompt for later + // `unsandboxed: true` requests within the same thread. + let mut grants = ThreadSandboxGrants::default(); + assert!(!covers(&grants, &unsandboxed_request())); + grants.record(&unsandboxed_request()); + assert!(covers(&grants, &unsandboxed_request())); + + // A thread-wide unsandboxed grant only covers unsandboxed requests; it + // does not widen network or filesystem scope. + assert!(!covers( + &grants, + &request(NetworkRequest::AnyHost, false, &[]) + )); + assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); + } + + #[test] + fn persistent_allow_unsandboxed_does_not_cover_here() { + // The persistent setting is handled by removing the sandboxed tool (see + // `sandboxing_enabled_for_project`), not by covering requests, so on + // its own it never makes an `unsandboxed: true` request "covered". let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_unsandboxed: true, ..Default::default() }; - - assert!(grants.covers_with_persistent(&unsandboxed_request(), &persistent)); - assert!( - !grants - .covers_with_persistent(&request(NetworkRequest::AnyHost, false, &[]), &persistent) - ); - assert!( - !grants.covers_with_persistent(&request(NetworkRequest::None, true, &[]), &persistent) - ); + assert!(!grants.covers_with_persistent(&unsandboxed_request(), &persistent)); } #[test] @@ -597,7 +1168,7 @@ mod tests { grants.record(&request(NetworkRequest::None, false, &["/tmp/build"])); let effective = effective(&grants, &request(NetworkRequest::None, false, &[])); - assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/build")]); + assert_eq!(effective.write_paths, granted(&["/tmp/build"])); } #[test] @@ -612,10 +1183,7 @@ mod tests { &request(hosts(&["npmjs.org"]), false, &["/tmp/once"]), ); assert_eq!(effective.network, hosts(&["github.com", "npmjs.org"])); - assert_eq!( - effective.write_paths, - vec![PathBuf::from("/tmp/build"), PathBuf::from("/tmp/once")] - ); + assert_eq!(effective.write_paths, granted(&["/tmp/build", "/tmp/once"])); } #[test] @@ -632,14 +1200,14 @@ mod tests { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_all_hosts: true, - write_paths: vec![PathBuf::from("/tmp/always")], + write_paths: granted(&["/tmp/always"]), ..Default::default() }; let effective = grants .effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent); assert_eq!(effective.network, NetworkRequest::AnyHost); - assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]); + assert_eq!(effective.write_paths, granted(&["/tmp/always"])); } #[test] @@ -651,6 +1219,6 @@ mod tests { &grants, &request(NetworkRequest::None, false, &["/tmp/build/cache"]), ); - assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/build")]); + assert_eq!(effective.write_paths, granted(&["/tmp/build"])); } } diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index a88c5171d7a5d1..9384d07a2fac41 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -44,10 +44,11 @@ pub struct SystemPromptTemplate<'a> { /// platform equivalent), if present and non-empty. pub user_agents_md: Option, /// Whether agent-run terminal commands are wrapped in an OS-level - /// sandbox for this thread. When `true`, the rendered prompt - /// describes the sandbox's read/write/network rules and the - /// per-command flags the model can request to relax them. When - /// `false`, the prompt omits the sandbox section entirely. + /// sandbox for this thread. When `true` — and the `terminal` tool is + /// in `available_tools` — the rendered prompt describes the sandbox's + /// read/write/network rules and the per-command flags the model can + /// request to relax them. Otherwise the prompt omits the sandbox + /// section entirely. pub sandboxing: bool, /// Whether the host is Linux. The writable-temp story differs by /// platform (Linux exposes an ephemeral `tmpfs` over `/tmp`; other @@ -122,7 +123,7 @@ mod tests { root_name: "my-project".to_string(), abs_path: std::path::Path::new("/tmp/my-project").into(), rules_file: Some(RulesFileContext { - path_in_worktree: RelPath::unix("AGENTS.md").unwrap().into(), + path_in_worktree: RelPath::from_unix_str("AGENTS.md").unwrap().into(), text: "project-specific guidance".to_string(), project_entry_id: 1, }), @@ -192,7 +193,7 @@ mod tests { let project = ProjectContext::new(worktrees); let template = SystemPromptTemplate { project: &project, - available_tools: vec!["echo".into()], + available_tools: vec!["echo".into(), "terminal".into()], model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, @@ -211,7 +212,16 @@ mod tests { assert!(rendered.contains("fs_write_paths")); assert!(rendered.contains("allow_fs_write_all: true")); assert!(rendered.contains("unsandboxed: true")); + assert!(rendered.contains("`.git` directories remain protected")); + assert!(rendered.contains("Git metadata writes are never grantable inside the sandbox")); + assert!(rendered.contains("request `unsandboxed: true` with a reason")); + assert!(rendered.contains("git --no-optional-locks status")); assert!(rendered.contains("for the rest of the thread")); + // macOS tolerates granting a not-yet-existing path, so the + // existing-directory requirement must not be stated there; the + // `create_directory` flow is the preferred guidance instead. + assert!(!rendered.contains("Each path must be an existing directory")); + assert!(rendered.contains("first create it with the `create_directory` tool")); } #[test] @@ -226,7 +236,7 @@ mod tests { let project = ProjectContext::new(worktrees); let template = SystemPromptTemplate { project: &project, - available_tools: vec!["echo".into()], + available_tools: vec!["echo".into(), "terminal".into()], model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, @@ -242,6 +252,46 @@ mod tests { assert!(!rendered.contains("$TMPDIR")); assert!(rendered.contains("`/tmp` is writable")); assert!(rendered.contains("`/tmp/alpha`")); + // Linux write grants must already exist (bwrap binds existing paths). + assert!(rendered.contains("Each path must be an existing directory")); + assert!(rendered.contains("first create it with the `create_directory` tool")); + } + + #[test] + fn test_system_prompt_windows_sandbox_section_rejects_host_specific_network() { + use prompt_store::{ProjectContext, WorktreeContext}; + + let worktrees = vec![WorktreeContext { + root_name: "alpha".to_string(), + abs_path: std::path::Path::new("C:/Users/me/project").into(), + rules_file: None, + }]; + let project = ProjectContext::new(worktrees); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into(), "terminal".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: true, + is_linux: false, + is_windows: true, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(rendered.contains("commands run inside WSL under Bubblewrap")); + assert!(rendered.contains("Protected Git metadata remains read-only")); + assert!(rendered.contains("do not use this on Windows")); + assert!(rendered.contains("such requests are rejected")); + assert!(rendered.contains("allow_all_hosts: true")); + assert!(rendered.contains("git --no-optional-locks status")); + // Out-of-project `create_directory` grants aren't supported on Windows, + // so the prompt must not recommend that flow; it suggests granting the + // nearest existing parent instead. + assert!(rendered.contains("Each path must be an existing directory")); + assert!(rendered.contains("nearest existing parent directory")); + assert!(!rendered.contains("first create it with the `create_directory` tool")); } #[test] @@ -249,7 +299,7 @@ mod tests { let project = prompt_store::ProjectContext::default(); let template = SystemPromptTemplate { project: &project, - available_tools: vec!["echo".into()], + available_tools: vec!["echo".into(), "terminal".into()], model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, @@ -264,6 +314,28 @@ mod tests { assert!(rendered.contains("No project directories are currently writable")); } + #[test] + fn test_system_prompt_omits_sandbox_section_when_terminal_tool_unavailable() { + // A profile can disable the terminal tool entirely; the prompt must not + // describe a sandboxed `terminal` tool the model doesn't have. + let project = prompt_store::ProjectContext::default(); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: true, + is_linux: false, + is_windows: false, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(!rendered.contains("## Terminal sandbox")); + assert!(!rendered.contains("allow_hosts")); + } + #[test] fn test_system_prompt_omits_user_agents_md_section_when_absent() { let project = prompt_store::ProjectContext::default(); diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 30a22792e5aa6e..b0341793dd706a 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -154,17 +154,18 @@ The current project contains the following root directories: {{/each}} {{#if sandboxing}} +{{#if (contains available_tools 'terminal')}} ## Terminal sandbox The `terminal` tool runs commands inside a sandbox with these permissions: -- Reads: any path on the filesystem is readable. +- Reads: any path on the filesystem is readable, including Git metadata. {{#if is_linux}} - Writes: `/tmp` is writable but is cleared between `terminal` calls{{#if worktrees}}. These project directories are also writable and persist across calls: {{#each worktrees}} - `{{abs_path}}` {{/each}} - Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} + `.git` directories remain protected. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{else}} {{#if is_windows}} - Execution: commands run inside WSL under Bubblewrap. Native Windows project paths are routed through WSL's `/mnt//...` filesystem view. @@ -172,49 +173,43 @@ The `terminal` tool runs commands inside a sandbox with these permissions: {{#each worktrees}} - `{{abs_path}}` {{/each}} - Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}} + Protected Git metadata remains read-only. Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}} {{else}} - Writes: a per-thread temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable and persists across `terminal` calls in this thread{{#if worktrees}}, along with these project directories: {{#each worktrees}} - `{{abs_path}}` {{/each}} - Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} + `.git` directories remain protected. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{/if}} {{/if}} - Network: outbound network access is blocked. -{{#if is_linux}} -The sandbox can only allow or block outbound network access as a whole — it cannot restrict access to specific hosts. There is no HTTP/HTTPS proxy, so once network access is granted SSH, FTP, and raw sockets work too. - -You can request elevated permissions on individual `terminal` calls: - -- `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access. -- `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit. -{{else}} {{#if is_windows}} The sandbox can only allow or block outbound network access as a whole — it cannot restrict access to specific hosts. There is no HTTP/HTTPS proxy, so once network access is granted SSH, FTP, and raw sockets work too. You can request elevated permissions on individual `terminal` calls: - `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access. -- `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit. +- `allow_hosts: ["github.com", ...]` — do not use this on Windows. Host-specific network grants cannot be enforced, and such requests are rejected; use `allow_all_hosts: true` when the command genuinely needs network access. {{else}} -Network access works through an HTTP/HTTPS proxy (standard proxy environment variables are set for the command). Tools that don't honor proxy environment variables (SSH, FTP, raw sockets, etc.) can't reach the network even when access is granted, so use `https://` URLs instead of `git@`/`ssh://` when cloning or pushing. +Host-scoped network access works through an HTTP/HTTPS proxy (standard proxy environment variables are set for the command). When access is scoped to specific hosts, tools that don't honor proxy environment variables (SSH, FTP, raw sockets, etc.) can't reach them, so use `https://` URLs instead of `git@`/`ssh://` when cloning or pushing. You can request elevated permissions on individual `terminal` calls: - `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs. -- `allow_all_hosts: true` — allow outbound HTTP/HTTPS to any host. Use only when the specific hosts can't be enumerated up front. -{{/if}} +- `allow_all_hosts: true` — lift the network restriction entirely: outbound access to any host over any protocol, so SSH, FTP, and raw sockets work too (unlike `allow_hosts`, which is HTTP/HTTPS-only). Use only when the specific hosts can't be enumerated up front. {{/if}} -- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. -- `allow_fs_write_all: true` — allow unrestricted filesystem writes. Only use this when the specific paths can't be enumerated up front. -- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice. +- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write.{{#if is_windows}} Each path must be an existing directory; to write somewhere that doesn't exist yet, request write access to the nearest existing parent directory.{{else}}{{#if is_linux}} Each path must be an existing directory.{{/if}} To write into a directory that doesn't exist yet, first create it with the `create_directory` tool (which creates it and grants write access to exactly that directory) rather than requesting write access to a broad existing parent.{{/if}} Git metadata paths cannot be requested and will never be made writable while sandboxed. +- `allow_fs_write_all: true` — allow unrestricted filesystem writes except protected Git metadata. Only use this when the specific paths can't be enumerated up front. +- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice, including when a command must write Git metadata. + +Git metadata writes are never grantable inside the sandbox. If a command needs to update `.git`, linked worktree metadata, refs, the index, hooks, local Git config, or other Git metadata, request `unsandboxed: true` with a reason. For read-only Git operations, prefer flags that avoid optional metadata writes where possible, such as `git --no-optional-locks status` instead of `git status`. The user will be prompted to approve before the command runs, and can grant a sandbox request for that command, for the rest of the thread, or always. Once a host or write path is granted for the thread or always, later commands in this thread reaching that host or writing under that path won't prompt again. These sandbox settings are guaranteed to remain in effect for the entire duration of this thread. If they ever change, you will be told. +{{/if}} {{/if}} {{#if model_name}} ## Model Information diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 34e9c8dbbb7c6c..3f50789786f507 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -1,9 +1,9 @@ use super::*; use acp_thread::{ - AgentConnection, AgentModelGroupName, AgentModelList, PermissionOptions, ThreadStatus, - UserMessageId, + AgentConnection, AgentModelGroupName, AgentModelList, ClientUserMessageId, PermissionOptions, + ThreadStatus, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, RefreshLlmTokenListener, UserStore}; @@ -283,13 +283,13 @@ fn always_allow_tools(cx: &mut TestAppContext) { /// Turns terminal sandboxing off so the non-sandboxed `TerminalTool` is the /// variant exposed to the model as `terminal`. Tests that register -/// `TerminalTool` directly need this because sandboxing is enabled by default -/// for staff (and in debug builds), in which case `Thread::enabled_tools` -/// would otherwise expose `SandboxedTerminalTool` under that name instead. +/// `TerminalTool` directly need this because the sandboxing feature flag is +/// enabled for all, in which case `Thread::enabled_tools` would otherwise +/// expose `SandboxedTerminalTool` under that name instead. fn disable_sandboxing(cx: &mut TestAppContext) { cx.update(|cx| { let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.sandbox_permissions.disabled = true; + settings.sandbox_permissions.allow_unsandboxed = true; agent_settings::AgentSettings::override_global(settings, cx); }); } @@ -301,7 +301,11 @@ async fn test_echo(cx: &mut TestAppContext) { let events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Testing: Reply with 'Hello'"], cx) + thread.send( + ClientUserMessageId::new(), + ["Testing: Reply with 'Hello'"], + cx, + ) }) .unwrap(); cx.run_until_parked(); @@ -453,7 +457,7 @@ async fn test_thinking(cx: &mut TestAppContext) { let events = thread .update(cx, |thread, cx| { thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), [indoc! {" Testing: @@ -536,7 +540,7 @@ async fn test_system_prompt(cx: &mut TestAppContext) { thread.update(cx, |thread, _| thread.add_tool(EchoTool)); thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -574,7 +578,7 @@ async fn test_system_prompt_without_tools(cx: &mut TestAppContext) { thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -613,7 +617,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { // Send initial user message and verify it's cached thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Message 1"], cx) + thread.send(ClientUserMessageId::new(), ["Message 1"], cx) }) .unwrap(); cx.run_until_parked(); @@ -637,7 +641,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { // Send another user message and verify only the latest is cached thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Message 2"], cx) + thread.send(ClientUserMessageId::new(), ["Message 2"], cx) }) .unwrap(); cx.run_until_parked(); @@ -676,7 +680,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { thread.update(cx, |thread, _| thread.add_tool(EchoTool)); thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Use the echo tool"], cx) + thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx) }) .unwrap(); cx.run_until_parked(); @@ -685,7 +689,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -761,7 +765,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { .update(cx, |thread, cx| { thread.add_tool(EchoTool); thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), ["Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'."], cx, ) @@ -777,7 +781,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { thread.remove_tool(&EchoTool::NAME); thread.add_tool(DelayTool); thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), [ "Now call the delay tool with 200ms.", "When the timer goes off, then you echo the output of the tool.", @@ -820,7 +824,7 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) { let mut events = thread .update(cx, |thread, cx| { thread.add_tool(WordListTool); - thread.send(UserMessageId::new(), ["Test the word_list tool."], cx) + thread.send(ClientUserMessageId::new(), ["Test the word_list tool."], cx) }) .unwrap(); @@ -836,17 +840,25 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) { assert_eq!(last_tool_use.name.as_ref(), "word_list"); if tool_call.status == acp::ToolCallStatus::Pending { if !last_tool_use.is_input_complete - && last_tool_use.input.get("g").is_none() + && last_tool_use + .input + .as_json() + .and_then(|input| input.get("g")) + .is_none() { saw_partial_tool_use = true; } } else { last_tool_use .input + .as_json() + .expect("tool input should be JSON") .get("a") .expect("'a' has streamed because input is now complete"); last_tool_use .input + .as_json() + .expect("tool input should be JSON") .get("g") .expect("'g' has streamed because input is now complete"); } @@ -871,7 +883,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let mut events = thread .update(cx, |thread, cx| { thread.add_tool(ToolRequiringPermission); - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -880,7 +892,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -890,7 +902,17 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_2".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_interrupted".into(), + name: ToolRequiringPermission::NAME.into(), + raw_input: "{}".into(), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -898,6 +920,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { fake_model.end_last_completion_stream(); let tool_call_auth_1 = next_tool_call_authorization(&mut events).await; let tool_call_auth_2 = next_tool_call_authorization(&mut events).await; + let interrupted_tool_call_auth = next_tool_call_authorization(&mut events).await; // Approve the first - send "allow" option_id (UI transforms "once" to "allow") tool_call_auth_1 @@ -917,6 +940,13 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { acp::PermissionOptionKind::RejectOnce, )) .unwrap(); + interrupted_tool_call_auth + .response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(FOLLOW_UP_PERMISSION_DENIED_OPTION_ID), + acp::PermissionOptionKind::RejectOnce, + )) + .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -925,18 +955,25 @@ 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.tool_call_id.0.to_string().into(), + tool_use_id: "tool_id_1".into(), tool_name: ToolRequiringPermission::NAME.into(), is_error: false, content: vec!["Allowed".into()], output: Some("Allowed".into()) }), language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_2.tool_call.tool_call_id.0.to_string().into(), + tool_use_id: "tool_id_2".into(), tool_name: ToolRequiringPermission::NAME.into(), is_error: true, content: vec!["Permission to run tool denied by user".into()], output: Some("Permission to run tool denied by user".into()) + }), + language_model::MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: "tool_id_interrupted".into(), + tool_name: ToolRequiringPermission::NAME.into(), + is_error: true, + content: vec!["Permission denied: user sent a follow-up message instead of approving the tool call.".into()], + output: Some("Permission denied: user sent a follow-up message instead of approving the tool call.".into()) }) ] ); @@ -947,7 +984,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_3".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -971,7 +1008,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.tool_call_id.0.to_string().into(), + tool_use_id: "tool_id_3".into(), tool_name: ToolRequiringPermission::NAME.into(), is_error: false, content: vec!["Allowed".into()], @@ -986,7 +1023,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_4".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1016,7 +1053,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { let mut events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -1025,7 +1062,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: "nonexistent_tool".into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1039,6 +1076,180 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed)); } +/// Regression test: some providers (confirmed on Bedrock Mantle/GPT-5.x) +/// reset their raw `tool_use` id counter every request/response cycle, so +/// the same id (e.g. `call_1`) can recur within one turn. Used verbatim as +/// the ACP id, this let a later tool call overwrite an earlier, unrelated +/// one in `AcpThread::upsert_tool_call`. +#[gpui::test] +async fn test_tool_call_id_scoped_per_completion_request(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread.update(cx, |thread, _cx| { + thread.add_tool(EchoTool); + }); + + let mut events = thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Use the echo tool twice"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "call_1".into(), + name: EchoTool::NAME.into(), + raw_input: json!({"text": "first"}).to_string(), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "first"})), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + let first_tool_call = next_tool_call(&mut events).await; + cx.run_until_parked(); + + // Same turn, second cycle: the id counter has reset, so "call_1" recurs + // for a different tool call. + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "call_1".into(), + name: EchoTool::NAME.into(), + raw_input: json!({"text": "second"}).to_string(), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "second"})), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + let second_tool_call = next_tool_call(&mut events).await; + cx.run_until_parked(); + + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + events.collect::>().await; + + assert_ne!( + first_tool_call.tool_call_id, second_tool_call.tool_call_id, + "raw tool_use ids that recur across separate completion requests within the same turn \ + must map to distinct ACP tool call ids, otherwise the second tool call would overwrite \ + the first instead of appearing as a new entry" + ); +} + +/// Same bug as `test_tool_call_id_scoped_per_completion_request`, but +/// exercised through persistence and `Thread::replay()` instead of live +/// streaming. +#[gpui::test] +async fn test_replayed_tool_call_ids_scoped_across_messages(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + project_context, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread.update(cx, |thread, _cx| { + thread.add_tool(EchoTool); + }); + + thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "call_1".into(), + name: EchoTool::NAME.into(), + raw_input: json!({"text": "first"}).to_string(), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "first"})), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Done with first"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Different turn: the id counter has reset, so "call_1" recurs in a + // different `AgentMessage`. + thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Use the echo tool again"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "call_1".into(), + name: EchoTool::NAME.into(), + raw_input: json!({"text": "second"}).to_string(), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "second"})), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Done with second"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await; + + cx.update(|cx| { + LanguageModelRegistry::test(cx); + }); + let restored = cx.update(|cx| { + let thread = thread.read(cx); + let project = thread.project.clone(); + let context_server_registry = thread.context_server_registry.clone(); + let templates = thread.templates.clone(); + cx.new(|cx| { + Thread::from_db( + acp::SessionId::new("restored"), + db_thread, + project, + project_context.clone(), + context_server_registry, + templates, + cx, + ) + }) + }); + restored.update(cx, |thread, _cx| { + thread.add_tool(EchoTool); + }); + + let mut replay_events = restored.update(cx, |thread, cx| thread.replay(cx)); + let mut tool_call_ids = Vec::new(); + while let Some(event) = replay_events.next().await { + if let ThreadEvent::ToolCall(tool_call) = event.unwrap() { + tool_call_ids.push(tool_call.tool_call_id); + } + } + + assert_eq!( + tool_call_ids.len(), + 2, + "expected one replayed tool call per message" + ); + assert_ne!( + tool_call_ids[0], tool_call_ids[1], + "raw tool_use ids that recur across separate AgentMessages must map to distinct ACP \ + tool call ids when replayed from a persisted thread, otherwise the second tool call \ + would overwrite the first instead of appearing as a new entry" + ); +} + async fn expect_tool_call(events: &mut UnboundedReceiver>) -> acp::ToolCall { let event = events .next() @@ -1053,6 +1264,21 @@ async fn expect_tool_call(events: &mut UnboundedReceiver>) - } } +/// Like [`expect_tool_call`], but skips other events until a `ToolCall` +/// appears -- useful across multiple request/response cycles in one turn. +async fn next_tool_call(events: &mut UnboundedReceiver>) -> acp::ToolCall { + loop { + let event = events + .next() + .await + .expect("no tool call event received") + .unwrap(); + if let ThreadEvent::ToolCall(tool_call) = event { + return tool_call; + } + } +} + async fn expect_tool_call_update_fields( events: &mut UnboundedReceiver>, ) -> acp::ToolCallUpdate { @@ -1345,7 +1571,7 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) { .update(cx, |thread, cx| { thread.add_tool(DelayTool); thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), [ "Call the delay tool twice in the same message.", "Once with 100ms. Once with 300ms.", @@ -1425,7 +1651,7 @@ async fn test_profiles(cx: &mut TestAppContext) { thread .update(cx, |thread, cx| { thread.set_profile(AgentProfileId("test-1".into()), cx); - thread.send(UserMessageId::new(), ["test"], cx) + thread.send(ClientUserMessageId::new(), ["test"], cx) }) .unwrap(); cx.run_until_parked(); @@ -1445,7 +1671,7 @@ async fn test_profiles(cx: &mut TestAppContext) { thread .update(cx, |thread, cx| { thread.set_profile(AgentProfileId("test-2".into()), cx); - thread.send(UserMessageId::new(), ["test2"], cx) + thread.send(ClientUserMessageId::new(), ["test2"], cx) }) .unwrap(); cx.run_until_parked(); @@ -1515,7 +1741,9 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { ); let events = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hey"], cx).unwrap() + thread + .send(ClientUserMessageId::new(), ["Hey"], cx) + .unwrap() }); cx.run_until_parked(); @@ -1527,7 +1755,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }, @@ -1558,7 +1786,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { // Send again after adding the echo tool, ensuring the name collision is resolved. let events = thread.update(cx, |thread, cx| { thread.add_tool(EchoTool); - thread.send(UserMessageId::new(), ["Go"], cx).unwrap() + thread.send(ClientUserMessageId::new(), ["Go"], cx).unwrap() }); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -1571,7 +1799,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_2".into(), name: "test_server_echo".into(), raw_input: json!({"text": "mcp"}).to_string(), - input: json!({"text": "mcp"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "mcp"})), is_input_complete: true, thought_signature: None, }, @@ -1581,7 +1809,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_3".into(), name: "echo".into(), raw_input: json!({"text": "native"}).to_string(), - input: json!({"text": "native"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "native"})), is_input_complete: true, thought_signature: None, }, @@ -1627,6 +1855,97 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { events.collect::>().await; } +#[gpui::test] +async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + context_server_store, + fs, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "tool_permissions": { "default": "allow" }, + "profiles": { + "test": { + "name": "Test Profile", + "enable_all_context_servers": true, + }, + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + thread.update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test".into()), cx) + }); + + let mut mcp_tool_calls = setup_context_server( + "Superluminal", + vec![context_server::types::Tool { + name: "snake_case.PascalCase".into(), + title: None, + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }], + &context_server_store, + cx, + ); + + let events = thread.update(cx, |thread, cx| { + thread + .send(ClientUserMessageId::new(), ["Use the MCP tool"], cx) + .unwrap() + }); + cx.run_until_parked(); + + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + tool_names_for_completion(&completion), + vec!["snake_case_PascalCase"] + ); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_1".into(), + name: "snake_case_PascalCase".into(), + raw_input: json!({}).to_string(), + input: language_model::LanguageModelToolUseInput::Json(json!({})), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); + assert_eq!(tool_call_params.name, "snake_case.PascalCase"); + tool_call_response + .send(context_server::types::CallToolResponse { + content: vec![context_server::types::ToolResponseContent::Text { + text: "done".into(), + }], + is_error: None, + meta: None, + structured_content: None, + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Done!"); + fake_model.end_last_completion_stream(); + events.collect::>().await; +} + #[gpui::test] async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { let ThreadTest { @@ -1678,7 +1997,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { let events = thread.update(cx, |thread, cx| { thread - .send(UserMessageId::new(), ["Take a screenshot"], cx) + .send(ClientUserMessageId::new(), ["Take a screenshot"], cx) .unwrap() }); cx.run_until_parked(); @@ -1689,7 +2008,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { id: "tool_1".into(), name: "screenshot".into(), raw_input: json!({}).to_string(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1819,7 +2138,7 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp // Send a message and have the model call the MCP tool let events = thread.update(cx, |thread, cx| { thread - .send(UserMessageId::new(), ["Read issue #47404"], cx) + .send(ClientUserMessageId::new(), ["Read issue #47404"], cx) .unwrap() }); cx.run_until_parked(); @@ -1839,7 +2158,9 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp name: "issue_read".into(), raw_input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}) .to_string(), - input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}), + ), is_input_complete: true, thought_signature: None, }, @@ -1900,15 +2221,19 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp let mut found_tool_call = None; let mut found_tool_call_update_with_output = None; + // The ACP id is scoped by message index (see `scoped_tool_call_id`), so + // capture it instead of assuming it matches the raw provider id. + let mut tool_call_id = None; while let Some(event) = replay_events.next().await { let event = event.unwrap(); match &event { - ThreadEvent::ToolCall(tc) if tc.tool_call_id.to_string() == "tool_1" => { + ThreadEvent::ToolCall(tc) => { + tool_call_id = Some(tc.tool_call_id.clone()); found_tool_call = Some(tc.clone()); } ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) - if update.tool_call_id.to_string() == "tool_1" => + if tool_call_id.as_ref() == Some(&update.tool_call_id) => { if update.fields.raw_output.is_some() { found_tool_call_update_with_output = Some(update.clone()); @@ -2112,7 +2437,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Go"], cx) + thread.send(ClientUserMessageId::new(), ["Go"], cx) }) .unwrap(); cx.run_until_parked(); @@ -2148,7 +2473,7 @@ async fn test_cancellation(cx: &mut TestAppContext) { thread.add_tool(InfiniteTool); thread.add_tool(EchoTool); thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), ["Call the echo tool, then call the infinite tool, then explain their output"], cx, ) @@ -2205,7 +2530,7 @@ async fn test_cancellation(cx: &mut TestAppContext) { let events = thread .update(cx, |thread, cx| { thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), ["Testing: reply with 'Hello' then stop."], cx, ) @@ -2242,7 +2567,7 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext thread.project().clone(), environment, )); - thread.send(UserMessageId::new(), ["run a command"], cx) + thread.send(ClientUserMessageId::new(), ["run a command"], cx) }) .unwrap(); @@ -2254,7 +2579,9 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2334,7 +2661,7 @@ async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppC .update(cx, |thread, cx| { thread.add_tool(tool); thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), ["call the cancellation aware tool"], cx, ) @@ -2349,7 +2676,7 @@ async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppC id: "cancellation_aware_1".into(), name: "cancellation_aware".into(), raw_input: r#"{}"#.into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -2423,7 +2750,7 @@ async fn verify_thread_recovery( let events = thread .update(cx, |thread, cx| { thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), ["Testing: reply with 'Hello' then stop."], cx, ) @@ -2516,7 +2843,7 @@ async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) { })); let handle = environment.terminal_handle.clone().unwrap(); - let message_id = UserMessageId::new(); + let message_id = ClientUserMessageId::new(); let mut events = thread .update(cx, |thread, cx| { thread.add_tool(crate::TerminalTool::new( @@ -2535,7 +2862,9 @@ async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) { id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2588,7 +2917,7 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext) thread.project().clone(), environment.clone(), )); - thread.send(UserMessageId::new(), ["run multiple commands"], cx) + thread.send(ClientUserMessageId::new(), ["run multiple commands"], cx) }) .unwrap(); @@ -2600,7 +2929,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext) id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2610,7 +2941,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext) id: "terminal_tool_2".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 2000", "cd": "."}"#.into(), - input: json!({"command": "sleep 2000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 2000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2702,7 +3035,7 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon thread.project().clone(), environment, )); - thread.send(UserMessageId::new(), ["run a command"], cx) + thread.send(ClientUserMessageId::new(), ["run a command"], cx) }) .unwrap(); @@ -2714,7 +3047,9 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2794,7 +3129,11 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) { thread.project().clone(), environment, )); - thread.send(UserMessageId::new(), ["run a command with timeout"], cx) + thread.send( + ClientUserMessageId::new(), + ["run a command with timeout"], + cx, + ) }) .unwrap(); @@ -2806,7 +3145,9 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) { id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": ".", "timeout_ms": 100}"#.into(), - input: json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}), + ), is_input_complete: true, thought_signature: None, }, @@ -2879,7 +3220,7 @@ async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { let events_1 = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello 1"], cx) + thread.send(ClientUserMessageId::new(), ["Hello 1"], cx) }) .unwrap(); cx.run_until_parked(); @@ -2888,7 +3229,7 @@ async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { let events_2 = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello 2"], cx) + thread.send(ClientUserMessageId::new(), ["Hello 2"], cx) }) .unwrap(); cx.run_until_parked(); @@ -2915,7 +3256,7 @@ async fn test_retry_cancelled_promptly_on_new_send(cx: &mut TestAppContext) { // Start a turn with model_a. let events_1 = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello"], cx) + thread.send(ClientUserMessageId::new(), ["Hello"], cx) }) .unwrap(); cx.run_until_parked(); @@ -2945,7 +3286,7 @@ async fn test_retry_cancelled_promptly_on_new_send(cx: &mut TestAppContext) { }); let events_2 = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Continue"], cx) + thread.send(ClientUserMessageId::new(), ["Continue"], cx) }) .unwrap(); cx.run_until_parked(); @@ -2988,7 +3329,7 @@ async fn test_subsequent_successful_sends_dont_cancel(cx: &mut TestAppContext) { let events_1 = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello 1"], cx) + thread.send(ClientUserMessageId::new(), ["Hello 1"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3000,7 +3341,7 @@ async fn test_subsequent_successful_sends_dont_cancel(cx: &mut TestAppContext) { let events_2 = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello 2"], cx) + thread.send(ClientUserMessageId::new(), ["Hello 2"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3021,7 +3362,7 @@ async fn test_refusal(cx: &mut TestAppContext) { let events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello"], cx) + thread.send(ClientUserMessageId::new(), ["Hello"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3068,7 +3409,7 @@ async fn test_truncate_first_message(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let message_id = UserMessageId::new(); + let message_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(message_id.clone(), ["Hello"], cx) @@ -3134,7 +3475,7 @@ async fn test_truncate_first_message(cx: &mut TestAppContext) { // Ensure we can still send a new message after truncation. thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hi"], cx) + thread.send(ClientUserMessageId::new(), ["Hi"], cx) }) .unwrap(); thread.update(cx, |thread, _cx| { @@ -3190,7 +3531,7 @@ async fn test_latest_token_usage_counts_cached_input_tokens(cx: &mut TestAppCont let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let message_1_id = UserMessageId::new(); + let message_1_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(message_1_id, ["Message 1"], cx) @@ -3223,7 +3564,7 @@ async fn test_latest_token_usage_counts_cached_input_tokens(cx: &mut TestAppCont ); }); - let message_2_id = UserMessageId::new(); + let message_2_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(message_2_id.clone(), ["Message 2"], cx) @@ -3237,25 +3578,101 @@ async fn test_latest_token_usage_counts_cached_input_tokens(cx: &mut TestAppCont } #[gpui::test] -async fn test_cumulative_token_usage(cx: &mut TestAppContext) { - let ThreadTest { - model, - thread, - project_context, - .. - } = setup(cx, TestModel::Fake).await; +async fn test_prompt_too_large_marks_token_usage_exceeded(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); thread .update(cx, |thread, cx| { - thread.add_tool(EchoTool); - thread.send(UserMessageId::new(), ["Use the echo tool"], cx) + thread.send(ClientUserMessageId::new(), ["Message 1"], cx) }) .unwrap(); cx.run_until_parked(); - // The first request emits two cumulative snapshots; only the final values - // must be counted, exactly once. + 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, + ..Default::default() + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.latest_token_usage().unwrap().ratio(), + acp_thread::TokenUsageRatio::Normal + ); + }); + + thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Message 2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::PromptTooLarge { + tokens: None, + }); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + let usage = thread.latest_token_usage().unwrap(); + assert_eq!(usage.used_tokens, 1_000_000); + assert_eq!(usage.max_tokens, 1_000_000); + assert_eq!(usage.ratio(), acp_thread::TokenUsageRatio::Exceeded); + }); +} + +#[gpui::test] +async fn test_prompt_too_large_uses_reported_token_count(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Message 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::PromptTooLarge { + tokens: Some(1_500_000), + }); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + let usage = thread.latest_token_usage().unwrap(); + assert_eq!(usage.used_tokens, 1_500_000); + assert_eq!(usage.ratio(), acp_thread::TokenUsageRatio::Exceeded); + }); +} + +#[gpui::test] +async fn test_cumulative_token_usage(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + project_context, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // The first request emits two cumulative snapshots; only the final values + // must be counted, exactly once. fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( TokenUsage { input_tokens: 100, @@ -3275,7 +3692,7 @@ async fn test_cumulative_token_usage(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "hello"}).to_string(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, @@ -3339,7 +3756,7 @@ async fn test_cumulative_token_usage_keeps_accounted_usage_monotonic(cx: &mut Te thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["hello"], cx) + thread.send(ClientUserMessageId::new(), ["hello"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3383,7 +3800,7 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) { thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Message 1"], cx) + thread.send(ClientUserMessageId::new(), ["Message 1"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3429,7 +3846,7 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) { assert_first_message_state(cx); - let second_message_id = UserMessageId::new(); + let second_message_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(second_message_id.clone(), ["Message 2"], cx) @@ -3503,7 +3920,7 @@ async fn test_title_generation(cx: &mut TestAppContext) { let send = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello"], cx) + thread.send(ClientUserMessageId::new(), ["Hello"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3527,7 +3944,7 @@ async fn test_title_generation(cx: &mut TestAppContext) { // Send another message, ensuring no title is generated this time. let send = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello again"], cx) + thread.send(ClientUserMessageId::new(), ["Hello again"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3591,7 +4008,7 @@ async fn test_db_thread_markdown_matches_live_thread(cx: &mut TestAppContext) { let send = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello"], cx) + thread.send(ClientUserMessageId::new(), ["Hello"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3620,7 +4037,7 @@ async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { let send = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello"], cx) + thread.send(ClientUserMessageId::new(), ["Hello"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3643,6 +4060,11 @@ async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { thread.read_with(cx, |thread, _| { assert_eq!(thread.title(), None); assert!(thread.has_failed_title_generation()); + assert!( + thread + .title_generation_error() + .is_some_and(|error| error.contains("Internal server error")) + ); assert!(!thread.is_generating_title()); }); @@ -3653,6 +4075,7 @@ async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { thread.read_with(cx, |thread, _| { assert!(!thread.has_failed_title_generation()); + assert_eq!(thread.title_generation_error(), None); assert!(thread.is_generating_title()); }); @@ -3663,6 +4086,7 @@ async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { thread.read_with(cx, |thread, _| { assert_eq!(thread.title(), Some("Retried title".into())); assert!(!thread.has_failed_title_generation()); + assert_eq!(thread.title_generation_error(), None); assert!(!thread.is_generating_title()); }); } @@ -3676,7 +4100,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { .update(cx, |thread, cx| { thread.add_tool(ToolRequiringPermission); thread.add_tool(EchoTool); - thread.send(UserMessageId::new(), ["Hey!"], cx) + thread.send(ClientUserMessageId::new(), ["Hey!"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3685,7 +4109,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }; @@ -3693,7 +4117,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { id: "tool_id_2".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -3855,8 +4279,9 @@ async fn test_agent_connection(cx: &mut TestAppContext) { drop(acp_thread); let result = cx .update(|cx| { - connection.prompt( - acp_thread::UserMessageId::new(), + acp_thread::AgentSessionClientUserMessageIds::prompt( + &connection, + acp_thread::ClientUserMessageId::new(), acp::PromptRequest::new(session_id.clone(), vec!["ghi".into()]), cx, ) @@ -3878,7 +4303,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let mut events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Echo something"], cx) + thread.send(ClientUserMessageId::new(), ["Echo something"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3890,7 +4315,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { id: "1".into(), name: EchoTool::NAME.into(), raw_input: input.to_string(), - input, + input: language_model::LanguageModelToolUseInput::Json(input), is_input_complete: false, thought_signature: None, }, @@ -3903,7 +4328,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { id: "1".into(), name: "echo".into(), raw_input: input.to_string(), - input, + input: language_model::LanguageModelToolUseInput::Json(input), is_input_complete: true, thought_signature: None, }, @@ -3911,10 +4336,14 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { fake_model.end_last_completion_stream(); cx.run_until_parked(); + // User message is index 0, so the tool call is scoped to index 1 (see + // `scoped_tool_call_id`). + let tool_call_id = scoped_tool_call_id(1, &"1".into()); + let tool_call = expect_tool_call(&mut events).await; assert_eq!( tool_call, - acp::ToolCall::new("1", "Echo") + acp::ToolCall::new(tool_call_id.clone(), "Echo") .raw_input(json!({})) .meta(acp::Meta::from_iter([("tool_name".into(), "echo".into())])) ); @@ -3922,7 +4351,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { assert_eq!( update, acp::ToolCallUpdate::new( - "1", + tool_call_id.clone(), acp::ToolCallUpdateFields::new() .title("Echo") .kind(acp::ToolKind::Other) @@ -3933,7 +4362,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { assert_eq!( update, acp::ToolCallUpdate::new( - "1", + tool_call_id.clone(), acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) ) ); @@ -3941,7 +4370,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { assert_eq!( update, acp::ToolCallUpdate::new( - "1", + tool_call_id, acp::ToolCallUpdateFields::new() .status(acp::ToolCallStatus::Completed) .raw_output("Hello!") @@ -3956,7 +4385,7 @@ async fn test_send_no_retry_on_success(cx: &mut TestAppContext) { let mut events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello!"], cx) + thread.send(ClientUserMessageId::new(), ["Hello!"], cx) }) .unwrap(); cx.run_until_parked(); @@ -3999,7 +4428,7 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { let mut events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello!"], cx) + thread.send(ClientUserMessageId::new(), ["Hello!"], cx) }) .unwrap(); cx.run_until_parked(); @@ -4064,7 +4493,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { let events = thread .update(cx, |thread, cx| { thread.add_tool(EchoTool); - thread.send(UserMessageId::new(), ["Call the echo tool!"], cx) + thread.send(ClientUserMessageId::new(), ["Call the echo tool!"], cx) }) .unwrap(); cx.run_until_parked(); @@ -4073,7 +4502,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -4143,7 +4572,7 @@ async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) { let mut events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello!"], cx) + thread.send(ClientUserMessageId::new(), ["Hello!"], cx) }) .unwrap(); cx.run_until_parked(); @@ -4206,7 +4635,11 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input( let _events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Use the streaming_echo tool"], cx) + thread.send( + ClientUserMessageId::new(), + ["Use the streaming_echo tool"], + cx, + ) }) .unwrap(); cx.run_until_parked(); @@ -4217,7 +4650,7 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input( id: "tool_1".into(), name: "streaming_echo".into(), raw_input: r#"{"text": "partial"}"#.into(), - input: json!({"text": "partial"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})), is_input_complete: false, thought_signature: None, }; @@ -4310,7 +4743,7 @@ async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool( let _events = thread .update(cx, |thread, cx| { thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), ["Use the streaming_json_error_context tool"], cx, ) @@ -4322,7 +4755,7 @@ async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool( id: "tool_1".into(), name: StreamingJsonErrorContextTool::NAME.into(), raw_input: r#"{"text": "partial"#.into(), - input: json!({"text": "partial"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})), is_input_complete: false, thought_signature: None, }; @@ -4670,7 +5103,7 @@ async fn test_tokens_before_message(cx: &mut TestAppContext) { let fake_model = model.as_fake(); // First message - let message_1_id = UserMessageId::new(); + let message_1_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(message_1_id.clone(), ["First message"], cx) @@ -4710,7 +5143,7 @@ async fn test_tokens_before_message(cx: &mut TestAppContext) { }); // Second message - let message_2_id = UserMessageId::new(); + let message_2_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(message_2_id.clone(), ["Second message"], cx) @@ -4741,7 +5174,7 @@ async fn test_tokens_before_message(cx: &mut TestAppContext) { cx.run_until_parked(); // Third message - let message_3_id = UserMessageId::new(); + let message_3_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(message_3_id.clone(), ["Third message"], cx) @@ -4777,7 +5210,7 @@ async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) { let fake_model = model.as_fake(); // Set up three messages with responses - let message_1_id = UserMessageId::new(); + let message_1_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(message_1_id.clone(), ["Message 1"], cx) @@ -4796,7 +5229,7 @@ async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) { fake_model.end_last_completion_stream(); cx.run_until_parked(); - let message_2_id = UserMessageId::new(); + let message_2_id = ClientUserMessageId::new(); thread .update(cx, |thread, cx| { thread.send(message_2_id.clone(), ["Message 2"], cx) @@ -5122,7 +5555,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5256,7 +5691,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5403,7 +5840,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5532,7 +5971,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5593,7 +6034,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { id: "subagent_2".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&resume_tool_input).unwrap(), - input: serde_json::to_value(&resume_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&resume_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5893,7 +6336,7 @@ async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) { // `enabled_for_staff`, so it is already visible in debug builds. thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["hello"], cx) + thread.send(ClientUserMessageId::new(), ["hello"], cx) }) .unwrap(); cx.run_until_parked(); @@ -5928,7 +6371,7 @@ async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) { thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["hello again"], cx) + thread.send(ClientUserMessageId::new(), ["hello again"], cx) }) .unwrap(); cx.run_until_parked(); @@ -6022,7 +6465,7 @@ async fn test_sibling_thread_tools_gated_by_feature_flag(cx: &mut TestAppContext set_flag_override("off", cx); thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["hello"], cx) + thread.send(ClientUserMessageId::new(), ["hello"], cx) }) .unwrap(); cx.run_until_parked(); @@ -6048,7 +6491,7 @@ async fn test_sibling_thread_tools_gated_by_feature_flag(cx: &mut TestAppContext set_flag_override("on", cx); thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["hello again"], cx) + thread.send(ClientUserMessageId::new(), ["hello again"], cx) }) .unwrap(); cx.run_until_parked(); @@ -6100,7 +6543,7 @@ async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) { subagent .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Do work".to_string()], cx) + thread.send(ClientUserMessageId::new(), ["Do work".to_string()], cx) }) .unwrap(); cx.run_until_parked(); @@ -6179,7 +6622,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6304,7 +6749,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6370,7 +6817,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu id: "subagent_2".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&resume_tool_input).unwrap(), - input: serde_json::to_value(&resume_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&resume_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6477,7 +6926,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -7041,6 +7492,12 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) invalid_patterns: vec![], }, ); + // The fetch tool also gates on the shared per-host network grant, so + // grant docs.rs to keep this URL fully silent. + settings + .sandbox_permissions + .network_hosts + .push("docs.rs".into()); agent_settings::AgentSettings::override_global(settings, cx); }); @@ -7060,7 +7517,369 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) let event = rx.try_recv(); assert!( !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), - "expected no authorization request for allowed docs.rs URL" + "expected no authorization request for allowed and granted docs.rs URL" + ); +} + +/// A fetch to a host that hasn't been granted network access prompts for the +/// shared per-host sandbox grant, even when the tool itself is allowed. +#[gpui::test] +async fn test_fetch_tool_prompts_for_ungranted_host(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::with_200_response(); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let authorization = rx.expect_authorization().await; + let details = + acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) + .expect("an ungranted host should request a sandbox network grant"); + assert_eq!(details.network_hosts, vec!["example.com".to_string()]); + assert!(!details.network_all_hosts); +} + +/// A host already present in the shared sandbox grants lets a fetch proceed +/// without any prompt — the same grant the terminal tool records and consults. +#[gpui::test] +async fn test_fetch_tool_granted_host_skips_prompt(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + // Allow the tool itself so only the shared per-host grant is under test. + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::with_200_response(); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let event = rx.try_recv(); + assert!( + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), + "expected no authorization request for an already-granted host" + ); +} + +/// Loopback / IP-literal hosts can't be granted individually, so without +/// unsandboxed access a fetch to them is refused with guidance to grant it. +#[gpui::test] +async fn test_fetch_tool_refuses_loopback_without_unsandboxed(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + // Allow the tool itself so the request reaches the per-host gate. + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::with_200_response(); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, _rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap(); + + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + let result = task.await; + assert!(result.is_err(), "expected a loopback fetch to be refused"); + assert!( + result.unwrap_err().contains("unsandboxed"), + "error should point at unsandboxed access as the way to reach loopback hosts" + ); +} + +/// Granting unsandboxed access lifts every fetch restriction, matching the +/// terminal: even loopback hosts become reachable and no per-host prompt is +/// requested. +#[gpui::test] +async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.sandbox_permissions.allow_unsandboxed = true; + // Allow the tool itself so only the per-host gate is under test. + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::with_200_response(); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + // A loopback host that could never be granted individually is reachable, + // and no per-host authorization is requested. + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let event = rx.try_recv(); + assert!( + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), + "expected no authorization request when unsandboxed access is granted" + ); +} + +/// A granted host that redirects to a loopback target must not have that +/// redirect followed: loopback hosts can't be granted individually, so the hop +/// is refused just like a direct loopback fetch. This is the redirect variant of +/// the SSRF protection — the approved domain can't be used to bounce the request +/// onto the local machine. +#[gpui::test] +async fn test_fetch_tool_refuses_redirect_to_loopback(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + assert!( + uri.contains("example.com"), + "the loopback redirect target must never be requested, but saw {uri}" + ); + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "http://localhost:3000/internal") + .body("".into()) + .unwrap()) + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, _rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + let result = task.await; + assert!( + result.is_err(), + "expected a redirect to a loopback host to be refused" + ); + assert!( + result.unwrap_err().contains("unsandboxed"), + "error should point at unsandboxed access as the way to reach loopback hosts" + ); +} + +/// A granted host that redirects to a *different*, ungranted host triggers a +/// fresh per-host authorization prompt for the redirect target — the redirect is +/// not silently followed to a host the user never approved. +#[gpui::test] +async fn test_fetch_tool_reauthorizes_redirect_to_new_host(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + assert!( + uri.contains("example.com"), + "the ungranted redirect target must not be requested before authorization, \ + but saw {uri}" + ); + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "https://redirect-target.example/landing") + .body("".into()) + .unwrap()) + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let authorization = rx.expect_authorization().await; + let details = + acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) + .expect("a redirect to an ungranted host should request a sandbox network grant"); + assert_eq!( + details.network_hosts, + vec!["redirect-target.example".to_string()] + ); + assert!(!details.network_all_hosts); +} + +/// Redirects between paths on an already-granted host are followed without any +/// additional prompt, so ordinary redirects (http→https upgrades, trailing-slash +/// canonicalization, etc.) keep working after the per-hop authorization change. +#[gpui::test] +async fn test_fetch_tool_follows_same_host_redirect(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + if uri.ends_with("/start") { + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "https://example.com/final") + .body("".into()) + .unwrap()) + } else if uri.ends_with("/final") { + Ok(gpui::http_client::Response::builder() + .status(200) + .header("content-type", "text/plain") + .body("final content".into()) + .unwrap()) + } else { + panic!("unexpected request to {uri}"); + } + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + let result = task.await; + assert_eq!( + result.expect("same-host redirect should succeed"), + "final content" + ); + + let event = rx.try_recv(); + assert!( + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), + "expected no authorization prompt for a redirect to an already-granted host" ); } @@ -7074,7 +7893,7 @@ async fn test_always_allow_resolves_pending_authorizations(cx: &mut TestAppConte let mut events = thread .update(cx, |thread, cx| { thread.add_tool(ToolRequiringPermission); - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -7086,7 +7905,7 @@ async fn test_always_allow_resolves_pending_authorizations(cx: &mut TestAppConte id: id.into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7155,7 +7974,7 @@ async fn test_external_settings_edit_resolves_pending_authorization(cx: &mut Tes let mut events = thread .update(cx, |thread, cx| { thread.add_tool(ToolRequiringPermission); - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -7165,7 +7984,7 @@ async fn test_external_settings_edit_resolves_pending_authorization(cx: &mut Tes id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7226,7 +8045,7 @@ async fn test_external_deny_rule_resolves_pending_authorization(cx: &mut TestApp let mut events = thread .update(cx, |thread, cx| { thread.add_tool(ToolRequiringPermission); - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -7236,7 +8055,7 @@ async fn test_external_deny_rule_resolves_pending_authorization(cx: &mut TestApp id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7302,7 +8121,7 @@ async fn test_unrelated_settings_change_does_not_resolve_pending_authorization( let mut events = thread .update(cx, |thread, cx| { thread.add_tool(ToolRequiringPermission); - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -7312,7 +8131,7 @@ async fn test_unrelated_settings_change_does_not_resolve_pending_authorization( id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7366,7 +8185,7 @@ async fn test_always_allow_does_not_resolve_unrelated_tool_authorization(cx: &mu .update(cx, |thread, cx| { thread.add_tool(ToolRequiringPermission); thread.add_tool(ToolRequiringPermission2); - thread.send(UserMessageId::new(), ["abc"], cx) + thread.send(ClientUserMessageId::new(), ["abc"], cx) }) .unwrap(); cx.run_until_parked(); @@ -7382,7 +8201,7 @@ async fn test_always_allow_does_not_resolve_unrelated_tool_authorization(cx: &mu id: id.into(), name: name.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7468,7 +8287,7 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { // Start a turn by sending a message let mut events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Use the echo tool"], cx) + thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx) }) .unwrap(); cx.run_until_parked(); @@ -7479,7 +8298,7 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: r#"{"text": "hello"}"#.into(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, @@ -7487,9 +8306,9 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { fake_model .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)); - // Signal that a message is queued before ending the stream + // Request that the turn end at the next boundary (a "steering" queued message) thread.update(cx, |thread, _cx| { - thread.set_has_queued_message(true); + thread.set_end_turn_at_next_boundary(true); }); // Now end the stream - tool will run, and the boundary check should see the queue @@ -7506,9 +8325,11 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { _ => None, }) .collect(); + // User message is index 0, so the tool call is scoped to index 1 (see + // `scoped_tool_call_id`). assert_eq!( tool_call_ids, - vec!["tool_1"], + vec![scoped_tool_call_id(1, &"tool_1".into()).to_string()], "Should have received a tool call event for our echo tool" ); @@ -7520,11 +8341,11 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { "Turn should have ended after tool completion due to queued message" ); - // Verify the queued message flag is still set + // Verify the boundary flag is still set thread.update(cx, |thread, _cx| { assert!( - thread.has_queued_message(), - "Should still have queued message flag set" + thread.end_turn_at_next_boundary(), + "Should still have the end-turn-at-boundary flag set" ); }); @@ -7537,6 +8358,68 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_queued_message_does_not_end_turn_without_boundary_flag(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread.update(cx, |thread, _cx| { + thread.add_tool(EchoTool); + }); + + let mut events = thread + .update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_1".into(), + name: "echo".into(), + raw_input: r#"{"text": "hello"}"#.into(), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)); + + // Default behavior: even though a message is conceptually queued, we do NOT + // set the boundary flag, so the agent must keep going past the tool boundary + // (running to completion) rather than ending the turn early. + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // The agent should have issued a fresh completion request with the tool + // results instead of stopping — proof it continued past the boundary. + let continuation = fake_model.pending_completions(); + assert_eq!( + continuation.len(), + 1, + "Without the boundary flag, the turn should continue with another completion request" + ); + + // Let the continuation finish the turn naturally. + fake_model.send_last_completion_stream_text_chunk("All done"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + + let all_events = collect_events_until_stop(&mut events, cx).await; + let stop_reasons = stop_events(all_events); + assert_eq!( + stop_reasons, + vec![acp::StopReason::EndTurn], + "Turn should end only after the agent finishes, not at the tool boundary" + ); +} + #[gpui::test] async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestAppContext) { init_test(cx); @@ -7554,7 +8437,7 @@ async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestA let _events = thread .update(cx, |thread, cx| { thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), ["Use the streaming_failing_echo tool"], cx, ) @@ -7566,7 +8449,7 @@ async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestA id: "call_1".into(), name: StreamingFailingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: false, thought_signature: None, }; @@ -7635,7 +8518,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te let _events = thread .update(cx, |thread, cx| { thread.send( - UserMessageId::new(), + ClientUserMessageId::new(), ["Use the streaming_echo tool and the streaming_failing_echo tool"], cx, ) @@ -7648,7 +8531,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te id: "call_1".into(), name: StreamingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({ "text": "hello" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })), is_input_complete: false, thought_signature: None, }, @@ -7657,7 +8540,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te id: "call_1".into(), name: StreamingEchoTool::NAME.into(), raw_input: "hello world".into(), - input: json!({ "text": "hello world" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello world" })), is_input_complete: true, thought_signature: None, }; @@ -7667,7 +8550,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te let second_tool_use = LanguageModelToolUse { name: StreamingFailingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({ "text": "hello" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })), is_input_complete: false, thought_signature: None, id: "call_2".into(), @@ -7778,7 +8661,7 @@ async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) { // Send a message — first iteration starts with model A, profile-a, thinking off. thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["test mid-turn refresh"], cx) + thread.send(ClientUserMessageId::new(), ["test mid-turn refresh"], cx) }) .unwrap(); cx.run_until_parked(); @@ -7796,7 +8679,7 @@ async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: r#"{"text":"hello"}"#.into(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 0bae32da679460..9b7af8a3d21aa6 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -7,15 +7,19 @@ use crate::{ TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; -use acp_thread::{MentionUri, UserMessageId}; +use acp_thread::{ClientUserMessageId, MentionUri}; use action_log::ActionLog; use agent_settings::UserAgentsMd; -use crate::sandboxing::{SandboxRequest, ThreadSandboxGrants, sandboxing_enabled_for_project}; -use agent_client_protocol::schema as acp; +use crate::sandboxing::{ + SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandbox_git_dirs, + sandbox_worktree_writable_paths, sandboxing_available_for_project, + sandboxing_enabled_for_project, +}; +use agent_client_protocol::schema::v1 as acp; use agent_settings::{ - AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, - SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, + AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, + SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, builtin_profiles, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Local, Utc}; @@ -31,7 +35,8 @@ use futures::{ }; use futures::{StreamExt, stream}; use gpui::{ - App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, + App, AppContext, AsyncApp, Context, Entity, EventEmitter, ReadGlobal as _, SharedString, Task, + WeakEntity, }; use heck::ToSnakeCase as _; use language_model::{ @@ -42,7 +47,7 @@ use language_model::{ LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, SelectedModel, Speed, StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID, }; -use project::Project; +use project::{Project, trusted_worktrees::TrustedWorktrees}; use prompt_store::ProjectContext; use schemars::{JsonSchema, Schema}; use serde::de::DeserializeOwned; @@ -65,9 +70,53 @@ use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock, paths::PathStyle use uuid::Uuid; const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; +const TOOL_CALL_INTERRUPTED_BY_FOLLOW_UP_MESSAGE: &str = + "Permission denied: user sent a follow-up message instead of approving the tool call."; +pub(crate) const FOLLOW_UP_PERMISSION_DENIED_OPTION_ID: &str = "follow_up_permission_denied"; pub const MAX_TOOL_NAME_LENGTH: usize = 64; pub const MAX_SUBAGENT_DEPTH: u8 = 1; +pub(crate) fn provider_compatible_tool_name(tool_name: &str) -> String { + let mut sanitized = String::new(); + for character in tool_name.chars() { + if sanitized.len() >= MAX_TOOL_NAME_LENGTH { + break; + } + + if character.is_ascii_alphanumeric() || character == '_' || character == '-' { + sanitized.push(character); + } else { + sanitized.push('_'); + } + } + + if sanitized.is_empty() { + sanitized.push_str("tool"); + } + + sanitized +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SandboxStatusKey { + pub settings_sandbox: ThreadSandbox, + pub thread_sandbox: ThreadSandbox, + pub baseline_writable_paths: Vec, + pub git_paths: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerifiedSandboxStatus { + pub settings_sandbox: ThreadSandbox, + pub thread_sandbox: ThreadSandbox, + pub baseline_writable_paths: Vec, +} + +pub enum SandboxStatusRefresh { + Ready(VerifiedSandboxStatus), + Pending(Task), +} + /// Auto-compaction is only available for models whose context window is at least /// this large. For smaller models there isn't enough headroom for a compaction /// pass to be worthwhile, so we leave the thread uncompacted and let the UI warn @@ -214,7 +263,7 @@ impl Message { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct UserMessage { - pub id: UserMessageId, + pub id: ClientUserMessageId, pub content: Arc<[UserMessageContent]>, } @@ -561,7 +610,7 @@ impl AgentMessage { "{}\n", MarkdownCodeBlock { tag: "json", - text: &format!("{:#}", tool_use.input) + text: &format!("{:#}", tool_use.input.to_display_json()) } )); } @@ -1107,6 +1156,16 @@ pub struct ToolCallAuthorization { pub kind: acp_thread::AuthorizationKind, } +fn ensure_tool_call_authorization_not_interrupted( + outcome: &acp_thread::SelectedPermissionOutcome, +) -> Result<()> { + if outcome.option_id.0.as_ref() == FOLLOW_UP_PERMISSION_DENIED_OPTION_ID { + Err(anyhow!(TOOL_CALL_INTERRUPTED_BY_FOLLOW_UP_MESSAGE)) + } else { + Ok(()) + } +} + fn auto_resolve_permission_outcome( options: &acp_thread::PermissionOptions, is_allow: bool, @@ -1136,13 +1195,44 @@ enum CompletionError { Other(#[from] anyhow::Error), } +pub enum ThreadModel { + Ready(Arc), + Unresolved(SelectedModel), + Unset, +} + +impl ThreadModel { + fn as_model(&self) -> Option<&Arc> { + match self { + Self::Ready(model) => Some(model), + Self::Unresolved(_) | Self::Unset => None, + } + } +} + +impl From<&ThreadModel> for Option { + fn from(model: &ThreadModel) -> Self { + match model { + ThreadModel::Ready(model) => Some(DbLanguageModel { + provider: model.provider_id().to_string(), + model: model.id().0.to_string(), + }), + ThreadModel::Unresolved(selection) => Some(DbLanguageModel { + provider: selection.provider.0.to_string(), + model: selection.model.0.to_string(), + }), + ThreadModel::Unset => None, + } + } +} + pub struct Thread { id: acp::SessionId, prompt_id: PromptId, updated_at: DateTime, title: Option, pending_title_generation: Option>, - title_generation_failed: bool, + title_generation_error: Option, pending_summary_generation: Option>>>, summary: Option, messages: Vec>, @@ -1151,12 +1241,13 @@ pub struct Thread { /// Survives across multiple requests as the model performs tool calls and /// we run tools, report their results. running_turn: Option, - /// Flag indicating the UI has a queued message waiting to be sent. - /// Used to signal that the turn should end at the next message boundary. - has_queued_message: bool, + /// When set, the current turn ends at the next message boundary instead of + /// running to completion. The UI sets this to deliver a "steering" queued + /// message mid-task; by default queued messages wait for the turn to finish. + end_turn_at_next_boundary: bool, pending_message: Option, pub(crate) tools: BTreeMap>, - request_token_usage: HashMap, + request_token_usage: HashMap, cumulative_token_usage: TokenUsage, /// The per-field maximum usage snapshot already added to /// `cumulative_token_usage` for the in-flight completion request. Reset at @@ -1167,9 +1258,12 @@ pub struct Thread { initial_project_snapshot: Shared>>>, pub(crate) context_server_registry: Entity, profile_id: AgentProfileId, + /// Whether `profile_id` was downgraded to `minimal` at thread start because + /// the workspace is restricted. Used purely to surface a warning in the UI. + profile_downgraded_for_restricted_workspace: bool, project_context: Entity, pub(crate) templates: Arc, - model: Option>, + model: ThreadModel, summarization_model: Option>, thinking_enabled: bool, thinking_effort: Option, @@ -1178,8 +1272,6 @@ pub struct Thread { pub(crate) prompt_capabilities_rx: watch::Receiver, pub(crate) project: Entity, pub(crate) action_log: Entity, - /// True if this thread was imported from a shared thread and can be synced. - imported: bool, /// If this is a subagent thread, contains context about the parent subagent_context: Option, /// The user's unsent prompt text, persisted so it can be restored when reloading the thread. @@ -1263,7 +1355,8 @@ impl Thread { cx: &mut Context, ) -> Self { let settings = AgentSettings::get_global(cx); - let profile_id = settings.default_profile.clone(); + let (profile_id, profile_downgraded_for_restricted_workspace) = + Self::profile_for_restricted_workspace(settings.default_profile.clone(), &project, cx); let enable_thinking = settings .default_model .as_ref() @@ -1278,19 +1371,24 @@ impl Thread { .and_then(|model| model.speed); let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel(Self::prompt_capabilities(model.as_deref())); + let model = match model { + Some(model) => ThreadModel::Ready(model), + None => Self::user_configured_model_selection(cx) + .map_or(ThreadModel::Unset, ThreadModel::Unresolved), + }; Self { id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()), prompt_id: PromptId::new(), updated_at: Utc::now(), title: None, pending_title_generation: None, - title_generation_failed: false, + title_generation_error: None, pending_summary_generation: None, summary: None, messages: Vec::new(), user_store: project.read(cx).user_store(), running_turn: None, - has_queued_message: false, + end_turn_at_next_boundary: false, pending_message: None, tools: BTreeMap::default(), request_token_usage: HashMap::default(), @@ -1305,6 +1403,7 @@ impl Thread { }, context_server_registry, profile_id, + profile_downgraded_for_restricted_workspace, project_context, templates, model, @@ -1316,7 +1415,6 @@ impl Thread { prompt_capabilities_rx, project, action_log, - imported: false, subagent_context: None, draft_prompt: None, ui_scroll_position: None, @@ -1338,6 +1436,8 @@ impl Thread { self.thinking_effort = parent.thinking_effort.clone(); self.summarization_model = parent.summarization_model.clone(); self.profile_id = parent.profile_id.clone(); + self.profile_downgraded_for_restricted_workspace = + parent.profile_downgraded_for_restricted_workspace; } fn apply_model_selection( @@ -1354,13 +1454,13 @@ impl Thread { return; }; - self.model = Some(model.clone()); self.thinking_enabled = selection.enable_thinking && model.supports_thinking(); self.thinking_effort = selection.effort.clone(); self.speed = selection.speed.filter(|_| model.supports_fast_mode()); self.prompt_capabilities_tx - .send(Self::prompt_capabilities(self.model.as_deref())) + .send(Self::prompt_capabilities(Some(model.as_ref()))) .log_err(); + self.model = ThreadModel::Ready(model); } pub fn id(&self) -> &acp::SessionId { @@ -1395,11 +1495,6 @@ impl Thread { Ok(temp_dir) } - /// Returns true if this thread was imported from a shared thread. - pub fn is_imported(&self) -> bool { - self.imported - } - pub fn replay( &mut self, cx: &mut Context, @@ -1421,6 +1516,7 @@ impl Thread { self.replay_tool_call( tool_use, assistant_message.tool_results.get(&tool_use.id), + message_ix, &stream, cx, ); @@ -1458,6 +1554,7 @@ impl Thread { &self, tool_use: &LanguageModelToolUse, tool_result: Option<&LanguageModelToolResult>, + owning_message_ix: usize, stream: &ThreadEventStream, cx: &mut Context, ) { @@ -1468,6 +1565,7 @@ impl Thread { return; } + let tool_call_id = scoped_tool_call_id(owning_message_ix, &tool_use.id); let output = tool_result .as_ref() .and_then(|result| result.output.clone()); @@ -1509,9 +1607,9 @@ impl Thread { stream .0 .unbounded_send(Ok(ThreadEvent::ToolCall( - acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) + acp::ToolCall::new(tool_call_id.clone(), tool_use.name.to_string()) .status(status) - .raw_input(tool_use.input.clone()), + .raw_input(tool_use.input.to_display_json()), ))) .ok(); let mut fields = acp::ToolCallUpdateFields::new() @@ -1520,23 +1618,20 @@ impl Thread { if let Some(content) = replay_content { fields = fields.content(content); } - stream.update_tool_call_fields(&tool_use.id, fields, None); + stream.update_tool_call_fields(&tool_call_id, fields, None); return; }; - let title = tool.initial_title(tool_use.input.clone(), cx); + let Ok(input) = tool_use.input.clone().into_json() else { + return; + }; + let title = tool.initial_title(input.clone(), cx); let kind = tool.kind(); - stream.send_tool_call( - &tool_use.id, - &tool_use.name, - title, - kind, - tool_use.input.clone(), - ); + stream.send_tool_call(&tool_call_id, &tool_use.name, title, kind, input.clone()); if let Some(content) = replay_content { stream.update_tool_call_fields( - &tool_use.id, + &tool_call_id, acp::ToolCallUpdateFields::new().content(content), None, ); @@ -1547,17 +1642,18 @@ impl Thread { let (_cancellation_tx, cancellation_rx) = watch::channel(false); let tool_event_stream = ToolCallEventStream::new( tool_use.id.clone(), + tool_call_id.clone(), stream.clone(), Some(self.project.read(cx).fs().clone()), cancellation_rx, self.sandbox_grants.clone(), + Some(cx.weak_entity()), ); - tool.replay(tool_use.input.clone(), output, tool_event_stream, cx) - .log_err(); + tool.replay(input, output, tool_event_stream, cx).log_err(); } stream.update_tool_call_fields( - &tool_use.id, + &tool_call_id, acp::ToolCallUpdateFields::new() .status(status) .raw_output(output), @@ -1630,31 +1726,33 @@ impl Thread { .profile .unwrap_or_else(|| settings.default_profile.clone()); - let mut model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - db_thread - .model - .and_then(|model| { - let model = SelectedModel { - provider: model.provider.clone().into(), - model: model.model.into(), - }; - registry.select_model(&model, cx) - }) - .or_else(|| registry.default_model()) - .map(|model| model.model) + let saved_selection = db_thread.model.map(|model| SelectedModel { + provider: model.provider.into(), + model: model.model.into(), }); - if model.is_none() { - model = Self::resolve_profile_model(&profile_id, cx); - } - if model.is_none() { - model = LanguageModelRegistry::global(cx).update(cx, |registry, _cx| { - registry.default_model().map(|model| model.model) - }); - } + let resolved_saved_model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + saved_selection + .as_ref() + .and_then(|selection| registry.select_model(selection, cx)) + .map(|configured| configured.model) + }); - let (prompt_capabilities_tx, prompt_capabilities_rx) = - watch::channel(Self::prompt_capabilities(model.as_deref())); + let model = match (resolved_saved_model, saved_selection) { + (Some(model), _) => ThreadModel::Ready(model), + (None, Some(selection)) => ThreadModel::Unresolved(selection), + (None, None) => Self::resolve_profile_model(&profile_id, cx) + .or_else(|| { + LanguageModelRegistry::global(cx).update(cx, |registry, _cx| { + registry.default_model().map(|model| model.model) + }) + }) + .map_or(ThreadModel::Unset, ThreadModel::Ready), + }; + + let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel( + Self::prompt_capabilities(model.as_model().map(|model| model.as_ref())), + ); let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -1667,13 +1765,13 @@ impl Thread { Some(db_thread.title.clone()) }, pending_title_generation: None, - title_generation_failed: false, + title_generation_error: None, pending_summary_generation: None, summary: db_thread.detailed_summary, messages: db_thread.messages, user_store: project.read(cx).user_store(), running_turn: None, - has_queued_message: false, + end_turn_at_next_boundary: false, pending_message: None, tools: BTreeMap::default(), request_token_usage: db_thread.request_token_usage.clone(), @@ -1683,6 +1781,7 @@ impl Thread { initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), context_server_registry, profile_id, + profile_downgraded_for_restricted_workspace: false, project_context, templates, model, @@ -1695,7 +1794,6 @@ impl Thread { updated_at: db_thread.updated_at, prompt_capabilities_tx, prompt_capabilities_rx, - imported: db_thread.imported, subagent_context: db_thread.subagent_context, draft_prompt: db_thread.draft_prompt, ui_scroll_position: db_thread.ui_scroll_position.map(|sp| gpui::ListOffset { @@ -1705,8 +1803,79 @@ impl Thread { running_subagents: Vec::new(), inherits_parent_model_settings: true, sandboxed_terminal_temp_dir: db_thread.sandboxed_terminal_temp_dir, - sandbox_grants: Rc::new(RefCell::new(ThreadSandboxGrants::default())), + sandbox_grants: Rc::new(RefCell::new(ThreadSandboxGrants::from_db( + &db_thread.sandbox_grants, + ))), + } + } + + pub fn sandbox_status(&self, cx: &App) -> Option<(ThreadSandbox, ThreadSandbox)> { + if !self.sandboxing_available(cx) { + return None; + } + let persistent = AgentSettings::get_global(cx).sandbox_permissions.clone(); + let git_dirs = sandbox_git_dirs(self.project.read(cx), cx); + let grants = self.sandbox_grants.borrow(); + let settings = crate::sandboxing::settings_thread_sandbox(&persistent) + .with_protected_paths(git_dirs.clone()); + let thread = grants.thread_sandbox().with_protected_paths(git_dirs); + Some((settings, thread)) + } + + pub fn refresh_verified_sandbox_status( + &self, + cx: &mut Context, + ) -> Option<(SandboxStatusKey, SandboxStatusRefresh)> { + if !self.sandboxing_available(cx) { + return None; } + + let persistent = AgentSettings::get_global(cx).sandbox_permissions.clone(); + let settings_sandbox = crate::sandboxing::settings_thread_sandbox(&persistent); + let grants = self.sandbox_grants.borrow(); + let thread_sandbox = grants.thread_sandbox(); + drop(grants); + + let project = self.project.read(cx); + let baseline_writable_paths = sandbox_worktree_writable_paths(project, cx); + let git_paths = sandbox_git_dirs(project, cx); + + let key = SandboxStatusKey { + settings_sandbox: settings_sandbox.clone(), + thread_sandbox: thread_sandbox.clone(), + baseline_writable_paths: baseline_writable_paths.clone(), + git_paths: git_paths.clone(), + }; + + Some(( + key, + SandboxStatusRefresh::Ready(VerifiedSandboxStatus { + settings_sandbox: settings_sandbox.with_protected_paths(git_paths.clone()), + thread_sandbox: thread_sandbox.with_protected_paths(git_paths), + baseline_writable_paths, + }), + )) + } + + /// Whether agent terminal commands are sandboxed for this thread's project, + /// so the UI can decide whether to surface the sandbox status at all. + pub fn sandboxing_enabled(&self, cx: &App) -> bool { + sandboxing_enabled_for_project(self.project.read(cx), cx) + } + + /// Whether sandboxing is *applicable* for this thread's project (feature on, + /// local project, supported platform), regardless of whether it's been + /// turned off in settings. The UI shows the sandbox indicator whenever this + /// is true, drawing it struck-out when sandboxing is disabled. + pub fn sandboxing_available(&self, cx: &App) -> bool { + sandboxing_available_for_project(self.project.read(cx), cx) + } + + /// The directory subtrees the sandbox always grants write access to for this + /// thread's project (its worktree roots), derived from the same source the + /// terminal tool uses when it actually builds the sandbox. + pub fn sandbox_baseline_writable_paths(&self, cx: &App) -> Vec { + crate::sandboxing::sandbox_worktree_writable_paths(self.project.read(cx), cx) } pub fn to_db(&self, cx: &App) -> Task { @@ -1719,12 +1888,8 @@ impl Thread { initial_project_snapshot: None, cumulative_token_usage: self.cumulative_token_usage, request_token_usage: self.request_token_usage.clone(), - model: self.model.as_ref().map(|model| DbLanguageModel { - provider: model.provider_id().to_string(), - model: model.id().0.to_string(), - }), + model: (&self.model).into(), profile: Some(self.profile_id.clone()), - imported: self.imported, subagent_context: self.subagent_context.clone(), speed: self.speed, thinking_enabled: self.thinking_enabled, @@ -1737,6 +1902,7 @@ impl Thread { } }), sandboxed_terminal_temp_dir: self.sandboxed_terminal_temp_dir.clone(), + sandbox_grants: self.sandbox_grants.borrow().to_db(), }; cx.background_spawn(async move { @@ -1795,18 +1961,45 @@ impl Thread { } pub fn model(&self) -> Option<&Arc> { - self.model.as_ref() + self.model.as_model() + } + + pub fn thread_model(&self) -> &ThreadModel { + &self.model + } + + pub(crate) fn ensure_model( + &mut self, + default_model: Option<&Arc>, + cx: &mut Context, + ) { + let resolved = match &self.model { + ThreadModel::Ready(_) => return, + ThreadModel::Unresolved(selection) => { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .select_model(selection, cx) + .map(|configured| configured.model) + }) + } + ThreadModel::Unset => default_model.cloned(), + }; + + if let Some(model) = resolved { + self.set_model(model, cx); + } } pub fn set_model(&mut self, model: Arc, cx: &mut Context) { let old_usage = self.latest_token_usage(); - self.model = Some(model.clone()); - let new_caps = Self::prompt_capabilities(self.model.as_deref()); + self.model = ThreadModel::Ready(model.clone()); + let new_caps = Self::prompt_capabilities(self.model.as_model().map(|model| model.as_ref())); let new_usage = self.latest_token_usage(); if old_usage != new_usage { cx.emit(TokenUsageUpdated(new_usage)); } self.prompt_capabilities_tx.send(new_caps).log_err(); + cx.emit(ModelChanged); for subagent in &self.running_subagents { subagent @@ -2004,7 +2197,44 @@ impl Thread { &self.profile_id } + /// Whether this thread's profile was downgraded to `minimal` at thread start + /// because the workspace is restricted. + pub fn profile_was_downgraded(&self) -> bool { + self.profile_downgraded_for_restricted_workspace + } + + /// Computes the profile a thread should start with, given the user's chosen + /// profile. In a restricted workspace, the built-in `write`/`ask` profiles + /// are downgraded to `minimal` — but only when both the chosen profile and + /// `minimal` are unmodified, shipped defaults, so we never override a user's + /// custom or customized profiles. + /// + /// Returns the (possibly downgraded) profile and whether a downgrade + /// happened. + fn profile_for_restricted_workspace( + profile_id: AgentProfileId, + project: &Entity, + cx: &App, + ) -> (AgentProfileId, bool) { + let is_write_or_ask = profile_id.as_str() == builtin_profiles::WRITE + || profile_id.as_str() == builtin_profiles::ASK; + let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); + if is_write_or_ask + && TrustedWorktrees::has_restricted_worktrees(&project.read(cx).worktree_store(), cx) + && AgentProfileSettings::is_unmodified_default(&profile_id, cx) + && AgentProfileSettings::is_unmodified_default(&minimal, cx) + { + (minimal, true) + } else { + (profile_id, false) + } + } + pub fn set_profile(&mut self, profile_id: AgentProfileId, cx: &mut Context) { + // An explicit selection means any earlier automatic downgrade no longer + // applies, even if the user re-selects the same profile. + self.profile_downgraded_for_restricted_workspace = false; + if self.profile_id == profile_id { return; } @@ -2046,12 +2276,12 @@ impl Thread { }) } - pub fn set_has_queued_message(&mut self, has_queued: bool) { - self.has_queued_message = has_queued; + pub fn set_end_turn_at_next_boundary(&mut self, end_at_boundary: bool) { + self.end_turn_at_next_boundary = end_at_boundary; } - pub fn has_queued_message(&self) -> bool { - self.has_queued_message + pub fn end_turn_at_next_boundary(&self) -> bool { + self.end_turn_at_next_boundary } fn accumulate_token_usage(&mut self, update: language_model::TokenUsage) { @@ -2101,14 +2331,43 @@ impl Thread { cx.notify(); } - pub fn truncate(&mut self, message_id: UserMessageId, cx: &mut Context) -> Result<()> { + /// Records that the last request overflowed the model's context window so + /// the token usage indicator reports `Exceeded` instead of the stale usage + /// from the last successful request. Providers don't report usage for + /// failed requests, so we synthesize one from the reported overflow (when + /// available) or the model's context size. + fn mark_token_limit_exceeded(&mut self, tokens: Option, cx: &mut Context) { + let Some(model) = self.model() else { + return; + }; + let input_tokens = tokens.unwrap_or(0).max(model.max_token_count()); + let Some(last_user_message) = self.last_user_message() else { + return; + }; + + self.request_token_usage.insert( + last_user_message.id.clone(), + language_model::TokenUsage { + input_tokens, + ..Default::default() + }, + ); + cx.emit(TokenUsageUpdated(self.latest_token_usage())); + cx.notify(); + } + + pub fn truncate( + &mut self, + client_user_message_id: ClientUserMessageId, + cx: &mut Context, + ) -> Result<()> { self.cancel(cx).detach(); // Clear pending message since cancel will try to flush it asynchronously, // and we don't want that content to be added after we truncate self.pending_message.take(); - let Some(position) = self.messages.iter().position( - |msg| matches!(&**msg, Message::User(UserMessage { id, .. }) if id == &message_id), - ) else { + let Some(position) = self.messages.iter().position(|msg| { + matches!(&**msg, Message::User(UserMessage { id, .. }) if id == &client_user_message_id) + }) else { return Err(anyhow!("Message not found")); }; @@ -2137,7 +2396,7 @@ impl Thread { pub fn latest_token_usage(&self) -> Option { let usage = self.latest_request_token_usage()?; - let model = self.model.clone()?; + let model = self.model()?; let input_tokens = total_input_tokens(usage); Some(acp_thread::TokenUsage { @@ -2155,8 +2414,8 @@ impl Thread { /// - `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; + pub fn tokens_before_message(&self, target_id: &ClientUserMessageId) -> Option { + let mut previous_user_message_id: Option<&ClientUserMessageId> = None; for message in &self.messages { if let Message::User(user_msg) = &**message { @@ -2184,6 +2443,20 @@ impl Thread { Self::resolve_model_from_selection(&selection, cx) } + fn user_configured_model_selection(cx: &App) -> Option { + let selection = SettingsStore::global(cx) + .raw_user_settings()? + .content + .agent + .as_ref()? + .default_model + .as_ref()?; + Some(SelectedModel { + provider: LanguageModelProviderId::from(selection.provider.0.clone()), + model: LanguageModelId::from(selection.model.clone()), + }) + } + /// Translate a stored model selection into the configured model from the registry. fn resolve_model_from_selection( selection: &LanguageModelSelection, @@ -2216,7 +2489,7 @@ impl Thread { /// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn. pub fn send( &mut self, - id: UserMessageId, + id: ClientUserMessageId, content: impl IntoIterator, cx: &mut Context, ) -> Result>> @@ -2252,12 +2525,11 @@ impl Thread { /// regardless of the current token usage or context window size. pub fn compact( &mut self, - id: UserMessageId, + id: ClientUserMessageId, cx: &mut Context, ) -> Result>> { let model = self - .model - .clone() + .compaction_model(cx) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; // Flush any pending message and cancel an in-flight turn before we @@ -2270,11 +2542,12 @@ impl Thread { self.advance_prompt_id(); let request = self.build_compaction_request(request_end_ix, &model, cx); self.current_request_token_usage = TokenUsage::default(); - (model, request) + (model.clone(), request) }); if compaction.is_some() { - self.pending_compaction_telemetry = self.build_compaction_telemetry("manual", cx); + self.pending_compaction_telemetry = + self.build_compaction_telemetry("manual", &model, cx); } self.clear_summary(); @@ -2344,7 +2617,7 @@ impl Thread { pub fn push_acp_user_block( &mut self, - id: UserMessageId, + id: ClientUserMessageId, blocks: impl IntoIterator, path_style: PathStyle, cx: &mut Context, @@ -2536,7 +2809,7 @@ impl Thread { let (model, request) = this.update(cx, |this, cx| { let model = refusal_fallback_model .clone() - .or_else(|| this.model.clone()) + .or_else(|| this.model().cloned()) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; this.refresh_turn_tools(cx); let request = this.build_completion_request(intent, cx)?; @@ -2562,9 +2835,9 @@ impl Thread { Ok(events) => (events.fuse(), None), Err(err) => (stream::empty().boxed().fuse(), Some(err)), }; - let mut tool_results: FuturesUnordered> = + let mut tool_results: FuturesUnordered> = FuturesUnordered::new(); - let mut early_tool_results: Vec = Vec::new(); + let mut early_tool_results: Vec<(usize, LanguageModelToolResult)> = Vec::new(); let mut cancelled = false; let mut had_refusal = false; loop { @@ -2572,6 +2845,7 @@ impl Thread { let first_event = futures::select! { event = events.next().fuse() => event, tool_result = futures::StreamExt::select_next_some(&mut tool_results) => { + let (owning_message_ix, tool_result) = tool_result; let is_error = tool_result.is_error; let is_still_streaming = this .read_with(cx, |this, _cx| { @@ -2582,7 +2856,7 @@ impl Thread { }) .unwrap_or(false); - early_tool_results.push(tool_result); + early_tool_results.push((owning_message_ix, tool_result)); // Only break if the tool errored and we are still // streaming the input of the tool. If the tool errored @@ -2683,7 +2957,7 @@ impl Thread { if had_refusal { let maybe_fallback = this.update(cx, |this, cx| -> Option> { - let current_model = refusal_fallback_model.as_ref().or(this.model.as_ref())?; + let current_model = refusal_fallback_model.as_ref().or(this.model())?; let fallback_id = match current_model.refusal_fallback_model_id() { Some(id) => id, None => { @@ -2736,11 +3010,11 @@ impl Thread { let end_turn = tool_results.is_empty() && early_tool_results.is_empty(); - for tool_result in early_tool_results { - Self::process_tool_result(this, event_stream, cx, tool_result)?; + for (owning_message_ix, tool_result) in early_tool_results { + Self::process_tool_result(this, event_stream, cx, owning_message_ix, tool_result)?; } - while let Some(tool_result) = tool_results.next().await { - Self::process_tool_result(this, event_stream, cx, tool_result)?; + while let Some((owning_message_ix, tool_result)) = tool_results.next().await { + Self::process_tool_result(this, event_stream, cx, owning_message_ix, tool_result)?; } this.update(cx, |this, cx| { @@ -2781,9 +3055,10 @@ impl Thread { } else if end_turn { return Ok(()); } else { - let has_queued = this.update(cx, |this, _| this.has_queued_message())?; - if has_queued { - log::debug!("Queued message found, ending turn at message boundary"); + let end_at_boundary = + this.update(cx, |this, _| this.end_turn_at_next_boundary())?; + if end_at_boundary { + log::debug!("Steering message queued, ending turn at message boundary"); return Ok(()); } intent = CompletionIntent::ToolResults; @@ -2806,7 +3081,7 @@ impl Thread { ) -> Result> { let retry = this.update(cx, |this, cx| { let user_store = this.user_store.read(cx); - this.handle_completion_error(error, attempt, user_store.plan()) + this.handle_completion_error(error, attempt, user_store.plan(), cx) })??; let timer = cx.background_executor().timer(retry.duration); event_stream.send_retry(retry); @@ -2830,13 +3105,14 @@ impl Thread { ) -> Result> { let Some((model, request, insertion_ix)) = this.update(cx, |this, cx| { let insertion_ix = this.compaction_message_target_ix(cx)?; - let model = this.model.clone()?; + let model = this.compaction_model(cx)?; let request = this.build_compaction_request(insertion_ix, &model, cx); this.current_request_token_usage = TokenUsage::default(); // Preserve telemetry across retries so the retry count keeps // accumulating rather than resetting on each attempt. if this.pending_compaction_telemetry.is_none() { - this.pending_compaction_telemetry = this.build_compaction_telemetry("auto", cx); + this.pending_compaction_telemetry = + this.build_compaction_telemetry("auto", &model, cx); } Some((model, request, insertion_ix)) })? @@ -2968,12 +3244,13 @@ impl Thread { this: &WeakEntity, event_stream: &ThreadEventStream, cx: &mut AsyncApp, + owning_message_ix: usize, tool_result: LanguageModelToolResult, ) -> Result<(), anyhow::Error> { log::debug!("Tool finished {:?}", tool_result); event_stream.update_tool_call_fields( - &tool_result.tool_use_id, + &scoped_tool_call_id(owning_message_ix, &tool_result.tool_use_id), acp::ToolCallUpdateFields::new() .status(if tool_result.is_error { acp::ToolCallStatus::Failed @@ -2996,8 +3273,13 @@ impl Thread { error: LanguageModelCompletionError, attempt: u8, plan: Option, + cx: &mut Context, ) -> Result { - let Some(model) = self.model.as_ref() else { + if let LanguageModelCompletionError::PromptTooLarge { tokens } = &error { + self.mark_token_limit_exceeded(*tokens, cx); + } + + let Some(model) = self.model() else { return Err(anyhow!(error)); }; @@ -3052,7 +3334,7 @@ impl Thread { event_stream: &ThreadEventStream, cancellation_rx: watch::Receiver, cx: &mut Context, - ) -> Result>> { + ) -> Result>> { log::trace!("Handling streamed completion event: {:?}", event); use LanguageModelCompletionEvent::*; @@ -3103,8 +3385,8 @@ impl Thread { thread_id = self.id.to_string(), parent_thread_id = self.parent_thread_id().map(|id| id.to_string()), prompt_id = self.prompt_id.to_string(), - model = self.model.as_ref().map(|m| m.telemetry_id()), - model_provider = self.model.as_ref().map(|m| m.provider_id().to_string()), + model = self.model().map(|m| m.telemetry_id()), + model_provider = self.model().map(|m| m.provider_id().to_string()), input_tokens = usage.input_tokens, output_tokens = usage.output_tokens, cache_creation_input_tokens = usage.cache_creation_input_tokens, @@ -3175,40 +3457,66 @@ impl Thread { event_stream: &ThreadEventStream, cancellation_rx: watch::Receiver, cx: &mut Context, - ) -> Option> { + ) -> Option> { cx.notify(); + let owning_message_ix = self.messages.len(); let tool = self.tool(tool_use.name.as_ref()); let mut title = SharedString::from(&tool_use.name); let mut kind = acp::ToolKind::Other; if let Some(tool) = tool.as_ref() { - title = tool.initial_title(tool_use.input.clone(), cx); + if let Ok(input) = tool_use.input.clone().into_json() { + title = tool.initial_title(input, cx); + } kind = tool.kind(); } - self.send_or_update_tool_use(&tool_use, title, kind, event_stream); + self.send_or_update_tool_use(&tool_use, title, kind, owning_message_ix, event_stream); let Some(tool) = tool else { let content = format!("No tool named {} exists", tool_use.name); - return Some(Task::ready(LanguageModelToolResult { - content: vec![LanguageModelToolResultContent::Text(Arc::from(content))], - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - output: None, - })); + return Some(Task::ready(( + owning_message_ix, + LanguageModelToolResult { + content: vec![LanguageModelToolResultContent::Text(Arc::from(content))], + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + }, + ))); + }; + + // Agent tools are JSON-schema tools. Custom text-tool deltas are rejected + // before considering partial-vs-complete input for these local tools. + let input = match tool_use.input.clone().into_json() { + Ok(input) => input, + Err(error) => { + return Some(Task::ready(( + owning_message_ix, + LanguageModelToolResult { + content: vec![LanguageModelToolResultContent::Text(Arc::from( + error.to_string(), + ))], + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + }, + ))); + } }; if !tool_use.is_input_complete { if tool.supports_input_streaming() { let running_turn = self.running_turn.as_mut()?; if let Some(sender) = running_turn.streaming_tool_inputs.get_mut(&tool_use.id) { - sender.send_partial(tool_use.input); + sender.send_partial(input); return None; } let (mut sender, tool_input) = ToolInputSender::channel(); - sender.send_partial(tool_use.input); + sender.send_partial(input); running_turn .streaming_tool_inputs .insert(tool_use.id.clone(), sender); @@ -3220,6 +3528,7 @@ impl Thread { tool_input, tool_use.id, tool_use.name, + owning_message_ix, event_stream, cancellation_rx, cx, @@ -3235,17 +3544,18 @@ impl Thread { .streaming_tool_inputs .remove(&tool_use.id) { - sender.send_full(tool_use.input); + sender.send_full(input); return None; } log::debug!("Running tool {}", tool_use.name); - let tool_input = ToolInput::ready(tool_use.input); + let tool_input = ToolInput::ready(input); Some(self.run_tool( tool, tool_input, tool_use.id, tool_use.name, + owning_message_ix, event_stream, cancellation_rx, cx, @@ -3258,17 +3568,44 @@ impl Thread { tool_input: ToolInput, tool_use_id: LanguageModelToolUseId, tool_name: Arc, + owning_message_ix: usize, event_stream: &ThreadEventStream, cancellation_rx: watch::Receiver, cx: &mut Context, - ) -> Task { + ) -> Task<(usize, LanguageModelToolResult)> { + // A workspace can become restricted after a thread has already started. + // Tools that aren't allowed in restricted workspaces must never run in + // that state, even though they were exposed to the model earlier. + if !tool.allow_in_restricted_mode() + && TrustedWorktrees::has_restricted_worktrees( + &self.project.read(cx).worktree_store(), + cx, + ) + { + return Task::ready(( + owning_message_ix, + LanguageModelToolResult { + tool_use_id, + tool_name, + is_error: true, + content: vec![LanguageModelToolResultContent::Text(Arc::from( + "workspace has become restricted", + ))], + output: None, + }, + )); + } + let fs = self.project.read(cx).fs().clone(); + let tool_call_id = scoped_tool_call_id(owning_message_ix, &tool_use_id); let tool_event_stream = ToolCallEventStream::new( tool_use_id.clone(), + tool_call_id, event_stream.clone(), Some(fs), cancellation_rx, self.sandbox_grants.clone(), + Some(cx.weak_entity()), ); tool_event_stream.update_fields( acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), @@ -3319,13 +3656,16 @@ impl Thread { Err(output) => (true, output), }; - LanguageModelToolResult { - tool_use_id, - tool_name, - is_error, - content: output.llm_output, - output: Some(output.raw_output), - } + ( + owning_message_ix, + LanguageModelToolResult { + tool_use_id, + tool_name, + is_error, + content: output.llm_output, + output: Some(output.raw_output), + }, + ) }) } @@ -3338,12 +3678,13 @@ impl Thread { event_stream: &ThreadEventStream, cancellation_rx: watch::Receiver, cx: &mut Context, - ) -> Option> { + ) -> Option> { + let owning_message_ix = self.messages.len(); let tool_use = LanguageModelToolUse { id: tool_use_id, name: tool_name, raw_input: raw_input.to_string(), - input: serde_json::json!({}), + input: language_model::LanguageModelToolUseInput::Json(serde_json::json!({})), is_input_complete: true, thought_signature: None, }; @@ -3351,6 +3692,7 @@ impl Thread { &tool_use, SharedString::from(&tool_use.name), acp::ToolKind::Other, + owning_message_ix, event_stream, ); @@ -3358,13 +3700,16 @@ impl Thread { let Some(tool) = tool else { let content = format!("No tool named {} exists", tool_use.name); - return Some(Task::ready(LanguageModelToolResult { - content: vec![LanguageModelToolResultContent::Text(Arc::from(content))], - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - output: None, - })); + return Some(Task::ready(( + owning_message_ix, + LanguageModelToolResult { + content: vec![LanguageModelToolResultContent::Text(Arc::from(content))], + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + }, + ))); }; let error_message = format!("Error parsing input JSON: {json_parse_error}"); @@ -3387,6 +3732,7 @@ impl Thread { tool_input, tool_use.id, tool_use.name, + owning_message_ix, event_stream, cancellation_rx, cx, @@ -3398,8 +3744,11 @@ impl Thread { tool_use: &LanguageModelToolUse, title: SharedString, kind: acp::ToolKind, + owning_message_ix: usize, event_stream: &ThreadEventStream, ) { + let tool_call_id = scoped_tool_call_id(owning_message_ix, &tool_use.id); + // Ensure the last message ends in the current tool use let last_message = self.pending_message(); @@ -3415,22 +3764,22 @@ impl Thread { if !has_tool_use { event_stream.send_tool_call( - &tool_use.id, + &tool_call_id, &tool_use.name, title, kind, - tool_use.input.clone(), + tool_use.input.to_display_json(), ); last_message .content .push(AgentMessageContent::ToolUse(tool_use.clone())); } else { event_stream.update_tool_call_fields( - &tool_use.id, + &tool_call_id, acp::ToolCallUpdateFields::new() .title(title.as_str()) .kind(kind) - .raw_input(tool_use.input.clone()), + .raw_input(tool_use.input.to_display_json()), None, ); } @@ -3449,7 +3798,11 @@ impl Thread { } pub fn has_failed_title_generation(&self) -> bool { - self.title_generation_failed + self.title_generation_error.is_some() + } + + pub fn title_generation_error(&self) -> Option { + self.title_generation_error.clone() } pub fn can_generate_title(&self) -> bool { @@ -3473,9 +3826,7 @@ impl Thread { ..Default::default() }; - for message in &self.messages { - request.messages.extend(message.to_request()); - } + self.extend_request_history_until(&mut request.messages, self.messages.len()); request.messages.push(LanguageModelRequestMessage { role: Role::User, @@ -3554,7 +3905,7 @@ impl Thread { on_generated_title: Option)>>, cx: &mut Context, ) { - self.title_generation_failed = false; + self.title_generation_error = None; log::debug!("Generating title with model: {:?}", model.name()); let temperature = AgentSettings::temperature_for_model(&model, cx); @@ -3565,22 +3916,26 @@ impl Thread { .await .context("failed to generate thread title") .map(SharedString::from) - .log_err() }); self.pending_title_generation = Some(cx.spawn(async move |this, cx| { let title = title_generation.await; _ = this.update(cx, |this, cx| { this.pending_title_generation = None; - if let Some(title) = title { - this.set_title(title.clone(), cx); - if let Some(on_generated_title) = on_generated_title { - on_generated_title(title, cx); + match title { + Ok(title) => { + this.set_title(title.clone(), cx); + if let Some(on_generated_title) = on_generated_title { + on_generated_title(title, cx); + } + } + Err(error) => { + let error = format!("{error:#}"); + log::error!("{error}"); + this.title_generation_error = Some(error.into()); + cx.emit(TitleUpdated); + cx.notify(); } - } else { - this.title_generation_failed = true; - cx.emit(TitleUpdated); - cx.notify(); } }); })); @@ -3589,7 +3944,7 @@ impl Thread { pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { self.pending_title_generation = None; - self.title_generation_failed = false; + self.title_generation_error = None; if Some(&title) != self.title.as_ref() { self.title = Some(title); cx.emit(TitleUpdated); @@ -3667,17 +4022,49 @@ impl Thread { let model = self .model() .ok_or_else(|| anyhow!(NoModelConfiguredError))?; + let sandboxing_enabled = crate::sandboxing::sandboxing_enabled(cx); let tools = if let Some(turn) = self.running_turn.as_ref() { turn.tools .iter() .filter_map(|(tool_name, tool)| { log::trace!("Including tool: {}", tool_name); - Some(LanguageModelRequestTool { - name: tool_name.to_string(), - description: tool.description().to_string(), - input_schema: tool.input_schema(model.tool_input_format()).log_err()?, - use_input_streaming: tool.supports_input_streaming(), - }) + let mut description = tool.description().to_string(); + let mut schema = tool.input_schema(model.tool_input_format()).log_err()?; + // TEMPORARY (sandboxing feature flag): with the flag off, + // the fetch and create_directory descriptions/schemas must + // not advertise sandbox-dependent behavior (host grants, + // out-of-project creation via the `reason` field), since + // the corresponding runtime paths are disabled. Restore + // the pre-sandboxing model-facing surface here rather than + // forking the tools; delete this when the flag is removed + // again. + if !sandboxing_enabled { + if tool_name.as_ref() == FetchTool::NAME { + description = + "Fetches a URL and returns the content as Markdown.".to_string(); + } else if tool_name.as_ref() == CreateDirectoryTool::NAME { + description = "Creates a new directory at the specified path within \ + the project. Returns confirmation that the directory was \ + created.\n\nThis tool creates a directory and all necessary \ + parent directories. It should be used whenever you need to \ + create new directories within the project.\nThe only supported \ + path outside the project is `~/.agents/skills` or a descendant, \ + for global agent skills." + .to_string(); + if let Some(properties) = schema + .get_mut("properties") + .and_then(|value| value.as_object_mut()) + { + properties.remove("reason"); + } + } + } + Some(LanguageModelRequestTool::function( + tool_name.to_string(), + description, + schema, + tool.supports_input_streaming(), + )) }) .collect::>() } else { @@ -3720,30 +4107,27 @@ impl Thread { } fn enabled_tools(&self, cx: &App) -> BTreeMap> { - let Some(model) = self.model.as_ref() else { + let Some(model) = self.model() else { return BTreeMap::new(); }; let Some(profile) = AgentSettings::get_global(cx).profiles.get(&self.profile_id) else { return BTreeMap::new(); }; - fn truncate(tool_name: &SharedString) -> SharedString { - if tool_name.len() > MAX_TOOL_NAME_LENGTH { - let mut truncated = tool_name.to_string(); - truncated.truncate(MAX_TOOL_NAME_LENGTH); - truncated.into() - } else { - tool_name.clone() - } - } - // Terminal variants are configured by users under the canonical // `terminal` name. Expose the one matching the current sandbox state // to the model under that name. let use_sandboxed_terminal = sandboxing_enabled_for_project(self.project.read(cx), cx); + // Tools that aren't allowed in restricted workspaces must never be + // provided to the model while the workspace is restricted, regardless + // of what the active profile enables. + let is_restricted = + TrustedWorktrees::has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx); + let mut tools = self .tools .iter() + .filter(|(_, tool)| !is_restricted || tool.allow_in_restricted_mode()) .filter_map(|(tool_name, tool)| { let terminal_variant = matches!( tool_name.as_ref(), @@ -3763,7 +4147,10 @@ impl Thread { Some((SharedString::from(TerminalTool::NAME), tool.clone())) } (TerminalTool::NAME | SandboxedTerminalTool::NAME, _) => None, - _ => Some((truncate(tool_name), tool.clone())), + _ => Some(( + provider_compatible_tool_name(tool_name.as_ref()).into(), + tool.clone(), + )), } } else { None @@ -3778,7 +4165,8 @@ impl Thread { for (server_id, server_tools) in self.context_server_registry.read(cx).servers() { for (tool_name, tool) in server_tools { if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) { - let tool_name = truncate(tool_name); + let tool_name: SharedString = + provider_compatible_tool_name(tool_name.as_ref()).into(); if !seen_tools.insert(tool_name.clone()) { duplicate_tool_names.insert(tool_name.clone()); } @@ -3795,7 +4183,8 @@ impl Thread { if duplicate_tool_names.contains(&tool_name) { let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); if available >= 2 { - let mut disambiguated = server_id.0.to_snake_case(); + let mut disambiguated = + provider_compatible_tool_name(&server_id.0.to_snake_case()).to_string(); disambiguated.truncate(available - 1); disambiguated.push('_'); disambiguated.push_str(&tool_name); @@ -3907,7 +4296,7 @@ impl Thread { let system_prompt = SystemPromptTemplate { project: self.project_context.read(cx), available_tools, - model_name: self.model.as_ref().map(|m| m.name().0.to_string()), + model_name: self.model().map(|m| m.name().0.to_string()), date: Local::now().format("%Y-%m-%d").to_string(), user_agents_md, sandboxing: crate::sandboxing::sandboxing_enabled_for_project( @@ -3937,42 +4326,19 @@ impl Thread { fn extend_request_history_until( &self, - messages: &mut Vec, + request_messages: &mut Vec, end_ix: usize, ) { - let Some(compaction_ix) = self.latest_compaction_message_ix_before(end_ix) else { - for message in &self.messages[..end_ix] { - messages.extend(message.to_request()); - } - return; - }; - - if matches!( - &*self.messages[compaction_ix], - Message::Compaction(CompactionInfo::Summary(_)) - ) { - messages.extend(self.retained_user_request_messages_before(compaction_ix)); - } - - for message in &self.messages[compaction_ix..end_ix] { - messages.extend(message.to_request()); - } - } - - fn latest_compaction_message_ix_before(&self, end_ix: usize) -> Option { - self.messages[..end_ix] - .iter() - .rposition(|message| matches!(&**message, Message::Compaction(_))) + extend_request_history_until(&self.messages, request_messages, end_ix); } - /// Captures the data for an `"Agent Compaction Completed"` telemetry event - /// at the moment a compaction starts. Returns `None` if there's no model. fn build_compaction_telemetry( &self, trigger: &'static str, + compaction_model: &Arc, cx: &App, ) -> Option { - let model = self.model.as_ref()?; + let model = self.model()?; let auto_compact = AgentSettings::get_global(cx).auto_compact; let max_tokens = model.max_token_count(); let max_input_tokens = max_tokens.saturating_sub(model.max_output_tokens().unwrap_or(0)); @@ -3985,7 +4351,7 @@ impl Thread { parent_thread_id: self.parent_thread_id().map(|id| id.to_string()), prompt_id: self.prompt_id.to_string(), model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), + compaction_model: compaction_model.telemetry_id(), thinking_effort: self.thinking_effort.clone(), max_tokens, tokens_before, @@ -4014,7 +4380,7 @@ impl Thread { return None; } - let model = self.model.as_ref()?; + let model = self.model()?; let max_token_count = model.max_token_count(); let max_input_tokens = max_token_count.saturating_sub(model.max_output_tokens().unwrap_or(0)); @@ -4039,8 +4405,7 @@ impl Thread { .map(|usage| (ix, usage)) }) }?; - if self - .latest_compaction_message_ix_before(self.messages.len()) + if latest_compaction_message_ix_before(&self.messages, self.messages.len()) .is_some_and(|compaction_ix| compaction_ix > usage_ix) { return None; @@ -4079,6 +4444,13 @@ impl Thread { Some(self.messages.len()) } + fn compaction_model(&self, cx: &App) -> Option> { + LanguageModelRegistry::read_global(cx) + .compaction_model() + .map(|m| m.model) + .or_else(|| self.model().cloned()) + } + fn build_compaction_request( &self, insertion_ix: usize, @@ -4104,41 +4476,6 @@ impl Thread { request } - fn retained_user_request_messages_before( - &self, - compaction_ix: usize, - ) -> Vec { - let mut remaining_bytes = COMPACTION_RETAINED_USER_MESSAGES_BYTE_BUDGET; - let mut retained_messages = Vec::new(); - - for message in self.messages[..compaction_ix].iter().rev() { - let Message::User(user_message) = &**message else { - continue; - }; - if user_message.content.is_empty() { - continue; - } - - let request_message = user_message.to_request(); - let byte_count = user_message_byte_len(&request_message); - if let Some(bytes) = remaining_bytes.checked_sub(byte_count) { - remaining_bytes = bytes; - retained_messages.push(request_message); - } else { - if remaining_bytes > 0 - && let Some(request_message) = - truncate_user_message_to_byte_budget(request_message, remaining_bytes) - { - retained_messages.push(request_message); - } - break; - } - } - - retained_messages.reverse(); - retained_messages - } - pub fn to_markdown(&self) -> String { let mut markdown = messages_to_markdown(&self.messages); @@ -4291,7 +4628,7 @@ struct CompactionTelemetry { parent_thread_id: Option, prompt_id: String, model: String, - model_provider: String, + compaction_model: String, thinking_effort: Option, max_tokens: u64, /// Tokens in the context window immediately before compaction. @@ -4315,7 +4652,7 @@ impl CompactionTelemetry { parent_thread_id = self.parent_thread_id, prompt_id = self.prompt_id, model = self.model, - model_provider = self.model_provider, + compaction_model = self.compaction_model, thinking_effort = self.thinking_effort, max_tokens = self.max_tokens, tokens_before = self.tokens_before, @@ -4414,7 +4751,7 @@ enum CompactionInsertion { /// (which may be before a trailing not-yet-answered user message). Auto { insertion_ix: usize }, /// Manual `/compact` appends a zero-content user message followed by the summary. - Manual { marker_id: UserMessageId }, + Manual { marker_id: ClientUserMessageId }, } struct RunningTurn { @@ -4476,6 +4813,75 @@ pub(crate) fn messages_to_markdown(messages: &[Arc]) -> String { markdown } +fn extend_request_history_until( + messages: &[Arc], + request_messages: &mut Vec, + end_ix: usize, +) { + let end_ix = end_ix.min(messages.len()); + let Some(compaction_ix) = latest_compaction_message_ix_before(messages, end_ix) else { + for message in &messages[..end_ix] { + request_messages.extend(message.to_request()); + } + return; + }; + + if matches!( + &*messages[compaction_ix], + Message::Compaction(CompactionInfo::Summary(_)) + ) { + request_messages.extend(retained_user_request_messages_before( + messages, + compaction_ix, + )); + } + + for message in &messages[compaction_ix..end_ix] { + request_messages.extend(message.to_request()); + } +} + +fn latest_compaction_message_ix_before(messages: &[Arc], end_ix: usize) -> Option { + messages[..end_ix] + .iter() + .rposition(|message| matches!(&**message, Message::Compaction(_))) +} + +fn retained_user_request_messages_before( + messages: &[Arc], + compaction_ix: usize, +) -> Vec { + let mut remaining_bytes = COMPACTION_RETAINED_USER_MESSAGES_BYTE_BUDGET; + let mut retained_messages = Vec::new(); + + for message in messages[..compaction_ix].iter().rev() { + let Message::User(user_message) = &**message else { + continue; + }; + if user_message.content.is_empty() { + continue; + } + + let request_message = user_message.to_request(); + let byte_count = user_message_byte_len(&request_message); + if let Some(bytes) = remaining_bytes.checked_sub(byte_count) { + remaining_bytes = bytes; + retained_messages.push(request_message); + } else { + if remaining_bytes > 0 + && let Some(request_message) = + truncate_user_message_to_byte_budget(request_message, remaining_bytes) + { + retained_messages.push(request_message); + } + break; + } + } + + retained_messages.reverse(); + retained_messages +} + pub fn build_thread_title_request( messages: &[Arc], temperature: Option, @@ -4485,9 +4891,7 @@ pub fn build_thread_title_request( temperature, ..Default::default() }; - for message in messages { - request.messages.extend(message.to_request()); - } + extend_request_history_until(messages, &mut request.messages, messages.len()); request.messages.push(LanguageModelRequestMessage { role: Role::User, content: vec![SUMMARIZE_THREAD_PROMPT.into()], @@ -4525,6 +4929,10 @@ pub struct TitleUpdated; impl EventEmitter for Thread {} +pub struct ModelChanged; + +impl EventEmitter for Thread {} + /// A channel-based wrapper that delivers tool input to a running tool. /// /// For non-streaming tools, created via `ToolInput::ready()` so `.recv()` resolves immediately. @@ -4700,6 +5108,14 @@ where true } + /// Whether this tool may be provided to an agent in a restricted workspace. + /// + /// Tools that return `false` are never exposed to the model while the + /// workspace is restricted, and will fail if invoked in that state. + fn allow_in_restricted_mode() -> bool { + true + } + /// Runs the tool with the provided input. /// /// Returns `Result` rather than `Result` @@ -4763,6 +5179,9 @@ pub trait AnyAgentTool { fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool { true } + fn allow_in_restricted_mode(&self) -> bool { + true + } /// See [`AgentTool::run`] for why this returns `Result`. fn run( self: Arc, @@ -4814,6 +5233,10 @@ where T::supports_provider(provider) } + fn allow_in_restricted_mode(&self) -> bool { + T::allow_in_restricted_mode() + } + fn run( self: Arc, input: ToolInput, @@ -4859,7 +5282,24 @@ where } } -#[derive(Clone)] +/// Builds the ACP-facing tool call id for a tool use in the message at +/// `message_ix`. +/// +/// Provider-issued `tool_use` ids aren't guaranteed unique across separate +/// request/response cycles in the same turn -- some providers reset a +/// per-request counter (e.g. `call_1`, `call_2`, ...), so the same raw id +/// can recur. Scoping by message index keeps the id stable for one tool +/// call's lifetime while preventing it from colliding with an unrelated one. +pub(crate) fn scoped_tool_call_id( + message_ix: usize, + tool_use_id: &LanguageModelToolUseId, +) -> acp::ToolCallId { + // `message_ix` is non-zero-padded decimal, so the `:` delimiter is always + // unambiguous -- this would break if the index were zero-padded. + acp::ToolCallId::new(format!("{message_ix}:{tool_use_id}")) +} + +#[derive(Clone)] struct ThreadEventStream(mpsc::UnboundedSender>); impl ThreadEventStream { @@ -4883,7 +5323,7 @@ impl ThreadEventStream { fn send_tool_call( &self, - id: &LanguageModelToolUseId, + id: &acp::ToolCallId, tool_name: &str, title: SharedString, kind: acp::ToolKind, @@ -4901,13 +5341,13 @@ impl ThreadEventStream { } fn initial_tool_call( - id: &LanguageModelToolUseId, + id: &acp::ToolCallId, tool_name: &str, title: String, kind: acp::ToolKind, input: serde_json::Value, ) -> acp::ToolCall { - acp::ToolCall::new(id.to_string(), title) + acp::ToolCall::new(id.clone(), title) .kind(kind) .raw_input(input) .meta(acp_thread::meta_with_tool_name(tool_name)) @@ -4915,13 +5355,13 @@ impl ThreadEventStream { fn update_tool_call_fields( &self, - tool_use_id: &LanguageModelToolUseId, + tool_call_id: &acp::ToolCallId, fields: acp::ToolCallUpdateFields, meta: Option, ) { self.0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( - acp::ToolCallUpdate::new(tool_use_id.to_string(), fields) + acp::ToolCallUpdate::new(tool_call_id.clone(), fields) .meta(meta) .into(), ))) @@ -4930,12 +5370,12 @@ impl ThreadEventStream { fn resolve_tool_call_authorization( &self, - tool_use_id: &LanguageModelToolUseId, + tool_call_id: &acp::ToolCallId, outcome: acp_thread::SelectedPermissionOutcome, ) { self.0 .unbounded_send(Ok(ThreadEvent::ToolCallAuthorizationResolved { - tool_call_id: acp::ToolCallId::new(tool_use_id.to_string()), + tool_call_id: tool_call_id.clone(), outcome, })) .ok(); @@ -5026,11 +5466,18 @@ pub(crate) enum SandboxFallbackDecision { #[derive(Clone)] pub struct ToolCallEventStream { tool_use_id: LanguageModelToolUseId, + /// The ACP-facing id for this tool call (see [`scoped_tool_call_id`]). + /// Distinct from `tool_use_id`, which is the raw, provider-issued id. + tool_call_id: acp::ToolCallId, stream: ThreadEventStream, fs: Option>, cancellation_rx: watch::Receiver, /// Shared, thread-scoped sandbox grants (see [`Thread::sandbox_grants`]). sandbox_grants: Rc>, + /// The owning thread, used to trigger a save when a "for this thread" + /// sandbox grant is recorded so it survives reopening. `None` in tests and + /// for streams not tied to a live thread. + thread: Option>, } impl ToolCallEventStream { @@ -5044,9 +5491,7 @@ impl ToolCallEventStream { /// thread-scoped sandbox grants. This mirrors how a real [`Thread`] builds a /// distinct event stream per tool call while sharing one set of grants, so /// tests can exercise sequences of tool calls within the same conversation. - // Only the macOS-gated terminal sandbox regression test uses this, so match - // its `cfg` to avoid a dead-code error on other platforms. - #[cfg(all(test, target_os = "macos"))] + #[cfg(test)] pub(crate) fn test_with_grants( sandbox_grants: Rc>, ) -> (Self, ToolCallEventStreamReceiver) { @@ -5055,10 +5500,12 @@ impl ToolCallEventStream { let stream = ToolCallEventStream::new( "test_id".into(), + acp::ToolCallId::new("test_id"), ThreadEventStream(events_tx), None, cancellation_rx, sandbox_grants, + None, ); (stream, ToolCallEventStreamReceiver(events_rx)) @@ -5071,10 +5518,12 @@ impl ToolCallEventStream { let stream = ToolCallEventStream::new( "test_id".into(), + acp::ToolCallId::new("test_id"), ThreadEventStream(events_tx), None, cancellation_rx, Rc::new(RefCell::new(ThreadSandboxGrants::default())), + None, ); ( @@ -5092,20 +5541,43 @@ impl ToolCallEventStream { fn new( tool_use_id: LanguageModelToolUseId, + tool_call_id: acp::ToolCallId, stream: ThreadEventStream, fs: Option>, cancellation_rx: watch::Receiver, sandbox_grants: Rc>, + thread: Option>, ) -> Self { Self { tool_use_id, + tool_call_id, stream, fs, cancellation_rx, sandbox_grants, + thread, } } + /// Whether the owning thread is a subagent, so prompts can say "for this + /// subagent" instead of "for this thread". + fn is_subagent(&self, cx: &App) -> bool { + self.thread + .as_ref() + .and_then(|thread| thread.upgrade()) + .is_some_and(|thread| thread.read(cx).is_subagent()) + } + + /// Persist the thread so a freshly recorded "for this thread" sandbox grant + /// survives a reopen. Saving is driven by the agent's `observe` on the + /// thread entity, so a no-op `notify` is enough to schedule it. + fn persist_thread_grants(thread: &Option>, cx: &AsyncApp) { + let Some(thread) = thread else { return }; + cx.update(|cx| { + thread.update(cx, |_thread, cx| cx.notify()).ok(); + }); + } + /// Returns a future that resolves when the user cancels the tool call. /// Tools should select on this alongside their main work to detect user cancellation. pub fn cancelled_by_user(&self) -> impl std::future::Future + '_ { @@ -5136,7 +5608,7 @@ impl ToolCallEventStream { pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) { self.stream - .update_tool_call_fields(&self.tool_use_id, fields, None); + .update_tool_call_fields(&self.tool_call_id, fields, None); } pub fn update_fields_with_meta( @@ -5145,12 +5617,12 @@ impl ToolCallEventStream { meta: Option, ) { self.stream - .update_tool_call_fields(&self.tool_use_id, fields, meta); + .update_tool_call_fields(&self.tool_call_id, fields, meta); } pub fn resolve_authorization(&self, outcome: acp_thread::SelectedPermissionOutcome) { self.stream - .resolve_tool_call_authorization(&self.tool_use_id, outcome); + .resolve_tool_call_authorization(&self.tool_call_id, outcome); } pub fn update_diff(&self, diff: Entity) { @@ -5158,7 +5630,7 @@ impl ToolCallEventStream { .0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp_thread::ToolCallUpdateDiff { - id: acp::ToolCallId::new(self.tool_use_id.to_string()), + id: self.tool_call_id.clone(), diff, } .into(), @@ -5296,8 +5768,6 @@ impl ToolCallEventStream { /// settings-driven authorization flow for regular tools. pub(crate) fn authorize_sandbox( &self, - title: impl Into, - command: Option, request: SandboxRequest, reason: String, cx: &mut App, @@ -5306,7 +5776,6 @@ impl ToolCallEventStream { return Task::ready(Ok(())); } - let title = title.into(); let (network_hosts, network_all_hosts) = match &request.network { crate::sandboxing::NetworkRequest::None => (Vec::new(), false), crate::sandboxing::NetworkRequest::AnyHost => (Vec::new(), true), @@ -5315,14 +5784,26 @@ impl ToolCallEventStream { } }; let sandbox_authorization_details = acp_thread::SandboxAuthorizationDetails { - command, + // The command stays in the tool-call title (set by the terminal + // tool), so the approval card keeps showing it; the details only + // describe the requested access and the agent's reason. + command: None, network_hosts, network_all_hosts, allow_fs_write_all: request.allow_fs_write_all, unsandboxed: request.unsandboxed, write_paths: request.write_paths.clone(), + // The Windows-drive warning is a separate pre-prompt + // (`authorize_windows_fs_warning`), never part of the escalation + // prompt, so this stays off here. + warn_windows_fs: false, reason, }; + let allow_thread_label = if self.is_subagent(cx) { + "Allow for this subagent" + } else { + "Allow for this thread" + }; let options = acp_thread::PermissionOptions::Flat(vec![ acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()), @@ -5331,7 +5812,7 @@ impl ToolCallEventStream { ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), - "Allow for this thread", + allow_thread_label, acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( @@ -5348,8 +5829,9 @@ impl ToolCallEventStream { let fs = self.fs.clone(); let stream = self.stream.clone(); - let tool_use_id = self.tool_use_id.clone(); + let tool_call_id = self.tool_call_id.clone(); let sandbox_grants = self.sandbox_grants.clone(); + let thread = self.thread.clone(); let auto_allow_outcome = match auto_resolve_permission_outcome(&options, true) { Ok(outcome) => outcome, Err(error) => return Task::ready(Err(error)), @@ -5361,8 +5843,10 @@ impl ToolCallEventStream { .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( ToolCallAuthorization { tool_call: acp::ToolCallUpdate::new( - tool_use_id.to_string(), - acp::ToolCallUpdateFields::new().title(title), + tool_call_id.clone(), + // Leave the title untouched so the card keeps + // showing the command (matching the fallback flow). + acp::ToolCallUpdateFields::new(), ) .meta(acp_thread::meta_with_sandbox_authorization( sandbox_authorization_details, @@ -5393,12 +5877,13 @@ impl ToolCallEventStream { }; futures::select_biased! { outcome = (&mut response_rx).fuse() => { - let outcome = outcome - .map_err(|_| anyhow!("authorization channel closed"))?; + let outcome = outcome.map_err(|_| anyhow!("authorization channel closed"))?; + ensure_tool_call_authorization_not_interrupted(&outcome)?; return Self::handle_sandbox_permission_outcome( &outcome, &request, sandbox_grants.clone(), + thread.clone(), fs.clone(), cx, ); @@ -5411,7 +5896,7 @@ impl ToolCallEventStream { )) { drop(response_rx); stream.resolve_tool_call_authorization( - &tool_use_id, + &tool_call_id, auto_allow_outcome.clone(), ); return Ok(()); @@ -5422,6 +5907,81 @@ impl ToolCallEventStream { }) } + /// Confirm, before running a command whose sandbox will contain a Windows + /// drive (DrvFs) path, that the user accepts the weaker integrity + /// guarantees. This is a transient gate *in front of* the normal sandbox + /// flow — it is never persisted or recorded as a grant; the only way to + /// stop it recurring is to disable `warn_ntfs_grants` in settings (offered + /// via the banner's gear). Returns `Ok(())` on "Continue" (after which the + /// caller proceeds to any escalation prompt) and `Err` on "Abort". + pub(crate) fn authorize_windows_fs_warning(&self, cx: &mut App) -> Task> { + // If the warning is already disabled, don't prompt. + if !AgentSettings::get_global(cx) + .sandbox_permissions + .warn_ntfs_grants + { + return Task::ready(Ok(())); + } + + let details = acp_thread::SandboxAuthorizationDetails { + command: None, + network_hosts: Vec::new(), + network_all_hosts: false, + allow_fs_write_all: false, + unsandboxed: false, + write_paths: Vec::new(), + warn_windows_fs: true, + reason: String::new(), + }; + let options = acp_thread::PermissionOptions::Flat(vec![ + acp::PermissionOption::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()), + "Continue", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), + "Abort", + acp::PermissionOptionKind::RejectOnce, + ), + ]); + + let stream = self.stream.clone(); + let tool_use_id = self.tool_use_id.clone(); + cx.spawn(async move |_cx| { + let (response_tx, response_rx) = oneshot::channel(); + if let Err(error) = stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new( + tool_use_id.to_string(), + acp::ToolCallUpdateFields::new(), + ) + .meta(acp_thread::meta_with_sandbox_authorization(details)), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::PermissionGrant, + }, + ))) + { + log::error!("Failed to send Windows-drive sandbox warning: {error}"); + return Err(anyhow!( + "Failed to send Windows-drive sandbox warning: {error}" + )); + } + + let outcome = response_rx + .await + .map_err(|_| anyhow!("authorization channel closed"))?; + match acp_thread::SandboxPermission::from_id(outcome.option_id.0.as_ref()) { + Some(acp_thread::SandboxPermission::AllowOnce) => Ok(()), + _ => Err(anyhow!("Windows-drive write aborted by user")), + } + }) + } + fn sandbox_request_covered_by_grants( request: &SandboxRequest, sandbox_grants: &Rc>, @@ -5437,6 +5997,7 @@ impl ToolCallEventStream { outcome: &acp_thread::SelectedPermissionOutcome, request: &SandboxRequest, sandbox_grants: Rc>, + thread: Option>, fs: Option>, cx: &AsyncApp, ) -> Result<()> { @@ -5449,6 +6010,7 @@ impl ToolCallEventStream { Some(acp_thread::SandboxPermission::AllowOnce) => Ok(()), Some(acp_thread::SandboxPermission::AllowThread) => { sandbox_grants.borrow_mut().record(request); + Self::persist_thread_grants(&thread, cx); Ok(()) } Some(acp_thread::SandboxPermission::AllowAlways) => { @@ -5511,14 +6073,22 @@ impl ToolCallEventStream { agent.set_sandbox_network_hosts(host_strings); } } + if request.allow_fs_write_all { agent.allow_sandbox_fs_write_all(); } if request.unsandboxed { agent.allow_sandbox_unsandboxed(); } - for path in request.write_paths { - agent.add_sandbox_write_path(path); + for granted in request.write_paths { + // Persist the full (requested, resolved-canonical) pair so a + // persistent grant is rebuilt from the vetted canonical + // across restarts, not re-resolved by path string. + agent.add_sandbox_write_path(settings::GrantedWritePathContent { + requested: granted.requested, + resolved: granted.resolved, + on_windows_fs: granted.on_windows_fs, + }); } }); }); @@ -5547,6 +6117,27 @@ impl ToolCallEventStream { self.sandbox_grants.borrow().fallback_granted_for_thread() } + /// Whether the user approved a model-requested `unsandboxed: true` escape + /// for the rest of this thread. Like the fallback grant, this makes every + /// command in the thread run without a sandbox. + pub(crate) fn unsandboxed_granted_for_thread(&self) -> bool { + self.sandbox_grants.borrow().unsandboxed_granted() + } + + /// Whether unsandboxed access is currently in effect: granted for this + /// thread (a model-requested `unsandboxed` escape or a sandbox-creation + /// fallback) or configured persistently via `allow_unsandboxed`. When true, + /// commands already run without any OS sandbox, so per-host network grants + /// no longer provide isolation and callers may skip host authorization + /// entirely. + pub(crate) fn unsandboxed_access_granted(&self, cx: &App) -> bool { + self.unsandboxed_granted_for_thread() + || self.sandbox_fallback_granted_for_thread() + || AgentSettings::get_global(cx) + .sandbox_permissions + .allow_unsandboxed + } + /// Ask the user how to proceed when the OS sandbox could not be created /// for a command (for example, `bwrap` is missing or user namespaces are /// disabled). @@ -5568,15 +6159,25 @@ impl ToolCallEventStream { &self, command: Option, reason: String, + docs_section: Option, retries: usize, cx: &mut App, ) -> Task> { - let details = acp_thread::SandboxFallbackAuthorizationDetails { command, reason }; + let details = acp_thread::SandboxFallbackAuthorizationDetails { + command, + reason, + docs_section, + }; let retry_label = if retries == 0 { "Retry".to_string() } else { format!("Retry (attempt {retries})") }; + let allow_thread_label = if self.is_subagent(cx) { + "Run without sandbox for this subagent" + } else { + "Run without sandbox for this thread" + }; let options = acp_thread::PermissionOptions::Flat(vec![ // Retry isn't an allow/deny choice; the UI renders it with its own // icon and we dispatch on the option id, so the kind here only @@ -5595,7 +6196,7 @@ impl ToolCallEventStream { ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), - "Run without sandbox for this thread", + allow_thread_label, acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( @@ -5612,8 +6213,9 @@ impl ToolCallEventStream { let fs = self.fs.clone(); let stream = self.stream.clone(); - let tool_use_id = self.tool_use_id.clone(); + let tool_call_id = self.tool_call_id.clone(); let sandbox_grants = self.sandbox_grants.clone(); + let thread = self.thread.clone(); cx.spawn(async move |cx| { let (response_tx, response_rx) = oneshot::channel(); if let Err(error) = stream @@ -5626,7 +6228,7 @@ impl ToolCallEventStream { // they're approving to run unsandboxed. The reason is // surfaced separately by the fallback details / warning. tool_call: acp::ToolCallUpdate::new( - tool_use_id.to_string(), + tool_call_id.clone(), acp::ToolCallUpdateFields::new(), ) .meta( @@ -5648,6 +6250,7 @@ impl ToolCallEventStream { let outcome = response_rx .await .map_err(|_| anyhow!("authorization channel closed"))?; + ensure_tool_call_authorization_not_interrupted(&outcome)?; let option_id = outcome.option_id.0.as_ref(); if option_id == acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID { @@ -5659,10 +6262,12 @@ impl ToolCallEventStream { } Some(acp_thread::SandboxPermission::AllowThread) => { sandbox_grants.borrow_mut().record_fallback(); + Self::persist_thread_grants(&thread, cx); Ok(SandboxFallbackDecision::RunUnsandboxed) } Some(acp_thread::SandboxPermission::AllowAlways) => { sandbox_grants.borrow_mut().record_fallback(); + Self::persist_thread_grants(&thread, cx); Self::persist_sandbox_unsandboxed_permission(fs, cx); Ok(SandboxFallbackDecision::RunUnsandboxed) } @@ -5676,8 +6281,10 @@ impl ToolCallEventStream { }) } - /// Persist the `allow_unsandboxed` setting so future commands skip the - /// sandbox when it can't be created, without prompting again. + /// Persist the `allow_unsandboxed` setting. Going forward this turns + /// sandboxing off for the model-facing surface: later turns expose the + /// plain `terminal` tool (with no sandbox prompt section) and commands run + /// without an OS sandbox. On Windows, WSL sandbox setup is skipped. #[cfg(any(target_os = "linux", target_os = "windows"))] fn persist_sandbox_unsandboxed_permission(fs: Option>, cx: &AsyncApp) { let Some(fs) = fs else { @@ -5713,7 +6320,7 @@ impl ToolCallEventStream { ) -> Task> { let options = acp_thread::PermissionOptions::Flat(options); let stream = self.stream.clone(); - let tool_use_id = self.tool_use_id.clone(); + let tool_call_id = self.tool_call_id.clone(); cx.spawn(async move |_cx| { let mut fields = acp::ToolCallUpdateFields::new(); if let Some(title) = title { @@ -5728,7 +6335,7 @@ impl ToolCallEventStream { .0 .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new(tool_use_id.to_string(), fields), + tool_call: acp::ToolCallUpdate::new(tool_call_id.clone(), fields), options, response: response_tx, context: None, @@ -5743,6 +6350,7 @@ impl ToolCallEventStream { let outcome = response_rx .await .map_err(|_| anyhow!("authorization channel closed"))?; + ensure_tool_call_authorization_not_interrupted(&outcome)?; Ok(outcome.option_id) }) } @@ -5780,7 +6388,7 @@ impl ToolCallEventStream { let fs = self.fs.clone(); let stream = self.stream.clone(); - let tool_use_id = self.tool_use_id.clone(); + let tool_call_id = self.tool_call_id.clone(); let auto_resolution_outcomes = if check_settings.is_some() { match ( auto_resolve_permission_outcome(&options, true), @@ -5799,7 +6407,7 @@ impl ToolCallEventStream { .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( ToolCallAuthorization { tool_call: acp::ToolCallUpdate::new( - tool_use_id.to_string(), + tool_call_id.clone(), acp::ToolCallUpdateFields::new().title(title), ), options, @@ -5817,6 +6425,7 @@ impl ToolCallEventStream { let outcome = response_rx .await .map_err(|_| anyhow!("authorization channel closed"))?; + ensure_tool_call_authorization_not_interrupted(&outcome)?; return Self::persist_permission_outcome(&outcome, fs, cx); }; @@ -5844,8 +6453,8 @@ impl ToolCallEventStream { }; futures::select_biased! { outcome = (&mut response_rx).fuse() => { - let outcome = outcome - .map_err(|_| anyhow!("authorization channel closed"))?; + let outcome = outcome.map_err(|_| anyhow!("authorization channel closed"))?; + ensure_tool_call_authorization_not_interrupted(&outcome)?; return Self::persist_permission_outcome(&outcome, fs.clone(), cx); } _ = settings_changed.fuse() => { @@ -5858,7 +6467,7 @@ impl ToolCallEventStream { ToolPermissionDecision::Allow => { drop(response_rx); stream.resolve_tool_call_authorization( - &tool_use_id, + &tool_call_id, auto_allow_outcome.clone(), ); return Ok(()); @@ -5866,7 +6475,7 @@ impl ToolCallEventStream { ToolPermissionDecision::Deny(reason) => { drop(response_rx); stream.resolve_tool_call_authorization( - &tool_use_id, + &tool_call_id, auto_deny_outcome.clone(), ); return Err(anyhow!(reason)); @@ -6170,12 +6779,15 @@ mod tests { use language_model::LanguageModelToolUseId; use language_model::fake_provider::FakeLanguageModel; use serde_json::json; + use settings::LanguageModelProviderSetting; use std::sync::Arc; async fn setup_thread_for_test(cx: &mut TestAppContext) -> (Entity, ThreadEventStream) { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); + + LanguageModelRegistry::test(cx); }); let fs = fs::FakeFs::new(cx.background_executor.clone()); @@ -6212,6 +6824,19 @@ mod tests { AgentSettings::override_global(settings, cx); } + fn set_registry_compaction_model(cx: &mut App, model: Option>) { + use language_model::fake_provider::FakeLanguageModelProvider; + use language_model::{ConfiguredModel, LanguageModelProvider}; + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + let configured = model.map(|m| ConfiguredModel { + provider: Arc::new(FakeLanguageModelProvider::default()) + as Arc, + model: m, + }); + registry.set_compaction_model(configured, cx); + }); + } + #[test] fn test_summary_compaction_renders_for_request_and_markdown() { let message = Message::Compaction(CompactionInfo::Summary("Older context".into())); @@ -6234,7 +6859,7 @@ mod tests { ); } - fn user_text_message(id: UserMessageId, text: &str) -> Arc { + fn user_text_message(id: ClientUserMessageId, text: &str) -> Arc { Arc::new(Message::User(UserMessage { id, content: vec![UserMessageContent::Text(text.to_string())].into(), @@ -6266,11 +6891,101 @@ mod tests { .collect() } + fn request_texts(messages: &[LanguageModelRequestMessage]) -> Vec { + messages + .iter() + .map(LanguageModelRequestMessage::string_contents) + .collect() + } + + #[gpui::test] + async fn test_thread_summary_request_uses_compacted_history(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let summary_model = Arc::new(FakeLanguageModel::default()); + + let summary_task = cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_summarization_model(Some(summary_model.clone()), cx); + thread + .messages + .push(user_text_message(ClientUserMessageId::new(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + thread.messages.push(summary_compaction("first summary")); + thread.messages.push(user_text_message( + ClientUserMessageId::new(), + "between user", + )); + thread + .messages + .push(agent_text_message("between assistant")); + thread.messages.push(summary_compaction("latest summary")); + thread + .messages + .push(user_text_message(ClientUserMessageId::new(), "after user")); + thread.messages.push(agent_text_message("after assistant")); + + thread.summary(cx) + }) + }); + cx.run_until_parked(); + + let summary_request = summary_model.pending_completions().pop().unwrap(); + assert_eq!( + summary_request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + assert_eq!( + request_texts(&summary_request.messages), + vec![ + "old user".to_string(), + "between user".to_string(), + summary_request_text("latest summary"), + "after user".to_string(), + "after assistant".to_string(), + SUMMARIZE_THREAD_DETAILED_PROMPT.to_string(), + ] + ); + + summary_model.send_completion_stream_text_chunk(&summary_request, "thread summary"); + summary_model.end_completion_stream(&summary_request); + assert_eq!(summary_task.await.as_deref(), Some("thread summary")); + } + + #[test] + fn test_thread_title_request_uses_compacted_history() { + let messages = vec![ + user_text_message(ClientUserMessageId::new(), "old user"), + agent_text_message("old assistant"), + summary_compaction("first summary"), + user_text_message(ClientUserMessageId::new(), "between user"), + agent_text_message("between assistant"), + summary_compaction("latest summary"), + user_text_message(ClientUserMessageId::new(), "after user"), + agent_text_message("after assistant"), + ]; + + let request = build_thread_title_request(&messages, Some(0.2)); + + assert_eq!(request.intent, Some(CompletionIntent::ThreadSummarization)); + assert_eq!(request.temperature, Some(0.2)); + assert_eq!( + request_texts(&request.messages), + vec![ + "old user".to_string(), + "between user".to_string(), + summary_request_text("latest summary"), + "after user".to_string(), + "after assistant".to_string(), + SUMMARIZE_THREAD_PROMPT.to_string(), + ] + ); + } + #[gpui::test] async fn test_compaction_threshold_uses_percentage_setting(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); - let user_message_id = UserMessageId::new(); + let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { @@ -6306,7 +7021,7 @@ mod tests { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); model.set_max_output_tokens(Some(32_000)); - let user_message_id = UserMessageId::new(); + let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { @@ -6369,7 +7084,7 @@ mod tests { async fn test_compaction_threshold_respects_enabled_setting(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); - let user_message_id = UserMessageId::new(); + let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { set_auto_compact_settings( @@ -6401,7 +7116,7 @@ mod tests { async fn test_compaction_threshold_respects_token_settings(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); - let user_message_id = UserMessageId::new(); + let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { set_auto_compact_settings( @@ -6473,7 +7188,7 @@ mod tests { let model = Arc::new(FakeLanguageModel::default()); // A context window below the minimum disables auto-compaction. model.set_max_token_count(MIN_COMPACTION_CONTEXT_WINDOW - 1); - let user_message_id = UserMessageId::new(); + let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { @@ -6500,8 +7215,8 @@ mod tests { ) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); - let old_user_message_id = UserMessageId::new(); - let new_user_message_id = UserMessageId::new(); + let old_user_message_id = ClientUserMessageId::new(); + let new_user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { @@ -6579,8 +7294,8 @@ mod tests { // A context window below the minimum and no recorded token usage would // both disable *automatic* compaction. Manual compaction forces it anyway. model.set_max_token_count(MIN_COMPACTION_CONTEXT_WINDOW - 1); - let user_message_id = UserMessageId::new(); - let compact_message_id = UserMessageId::new(); + let user_message_id = ClientUserMessageId::new(); + let compact_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { @@ -6663,13 +7378,17 @@ mod tests { thread.set_model(model.clone(), cx); thread .messages - .push(user_text_message(UserMessageId::new(), "old user")); + .push(user_text_message(ClientUserMessageId::new(), "old user")); thread.messages.push(agent_text_message("old assistant")); }); }); let _events = cx - .update(|cx| thread.update(cx, |thread, cx| thread.compact(UserMessageId::new(), cx))) + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + }) .unwrap(); cx.run_until_parked(); // The compaction request is in flight but hasn't streamed a summary. @@ -6698,13 +7417,17 @@ mod tests { thread.set_model(model.clone(), cx); thread .messages - .push(user_text_message(UserMessageId::new(), "old user")); + .push(user_text_message(ClientUserMessageId::new(), "old user")); thread.messages.push(agent_text_message("old assistant")); }); }); let mut events = cx - .update(|cx| thread.update(cx, |thread, cx| thread.compact(UserMessageId::new(), cx))) + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + }) .unwrap(); cx.run_until_parked(); @@ -6739,7 +7462,11 @@ mod tests { cx.update(|cx| thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx))); let _events = cx - .update(|cx| thread.update(cx, |thread, cx| thread.compact(UserMessageId::new(), cx))) + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + }) .unwrap(); cx.run_until_parked(); @@ -6755,13 +7482,13 @@ mod tests { #[gpui::test] async fn test_manual_compact_marker_replays_as_empty_user_message(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; - let marker_id = UserMessageId::new(); + let marker_id = ClientUserMessageId::new(); let mut replay_events = cx.update(|cx| { thread.update(cx, |thread, cx| { thread .messages - .push(user_text_message(UserMessageId::new(), "before")); + .push(user_text_message(ClientUserMessageId::new(), "before")); thread.messages.push(agent_text_message("answer")); thread.messages.push(Arc::new(Message::User(UserMessage { id: marker_id.clone(), @@ -6795,12 +7522,189 @@ mod tests { ); } + /// When `agent.compaction_model` is configured, manual `/compact` streams + /// to the configured model rather than the thread's primary model. + #[gpui::test] + async fn test_compaction_uses_configured_compaction_model(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let thread_model = Arc::new(FakeLanguageModel::default()); + let compaction_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(thread_model.clone(), cx); + thread + .messages + .push(user_text_message(ClientUserMessageId::new(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + }); + set_registry_compaction_model(cx, Some(compaction_model.clone())); + }); + + let _events = cx + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + }) + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + thread_model.pending_completions().len(), + 0, + "thread's primary model should not have been used for compaction" + ); + let request = compaction_model + .pending_completions() + .pop() + .expect("compaction model should have received the request"); + assert_eq!( + request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + + thread.read_with(cx, |thread, _cx| { + let telemetry = thread + .pending_compaction_telemetry + .as_ref() + .expect("pending telemetry"); + assert_eq!(telemetry.model, compaction_model.telemetry_id()); + }); + + compaction_model.send_completion_stream_text_chunk(&request, "summary"); + compaction_model.end_completion_stream(&request); + cx.run_until_parked(); + } + + /// When `agent.compaction_model` is configured but doesn't resolve (e.g. + /// the provider isn't registered), manual `/compact` falls back to the + /// thread's primary model and the telemetry reflects the actual stream + /// model — not the one the user tried to configure. + #[gpui::test] + async fn test_compaction_falls_back_when_compaction_model_unavailable(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let thread_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(thread_model.clone(), cx); + thread + .messages + .push(user_text_message(ClientUserMessageId::new(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + }); + // Settings say "configured"; registry says "couldn't resolve". + let mut settings = AgentSettings::get_global(cx).clone(); + settings.compaction_model = Some(LanguageModelSelection { + provider: LanguageModelProviderSetting("missing".into()), + model: "missing-model".into(), + enable_thinking: false, + effort: None, + speed: None, + }); + AgentSettings::override_global(settings, cx); + set_registry_compaction_model(cx, None); + }); + + let _events = cx + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + }) + .unwrap(); + cx.run_until_parked(); + + let request = thread_model + .pending_completions() + .pop() + .expect("thread model should have received the fallback request"); + assert_eq!( + request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + + thread.read_with(cx, |thread, _cx| { + let telemetry = thread + .pending_compaction_telemetry + .as_ref() + .expect("pending telemetry"); + assert_eq!(telemetry.model, thread_model.telemetry_id()); + }); + + thread_model.send_completion_stream_text_chunk(&request, "summary"); + thread_model.end_completion_stream(&request); + cx.run_until_parked(); + } + + /// Auto-compaction triggered by the threshold also honors + /// `agent.compaction_model`. + #[gpui::test] + async fn test_auto_compaction_uses_compaction_model(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let thread_model = Arc::new(FakeLanguageModel::default()); + let compaction_model = Arc::new(FakeLanguageModel::default()); + let old_user_message_id = ClientUserMessageId::new(); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(thread_model.clone(), cx); + thread + .messages + .push(user_text_message(old_user_message_id.clone(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + thread.request_token_usage.insert( + old_user_message_id.clone(), + TokenUsage { + input_tokens: u64::MAX, + ..Default::default() + }, + ); + }); + set_auto_compact_settings( + cx, + agent_settings::AutoCompactSettings { + enabled: true, + threshold: agent_settings::AutoCompactThreshold::Percentage(0.5), + }, + ); + set_registry_compaction_model(cx, Some(compaction_model.clone())); + }); + + // The auto-compact gate fires inside `run_turn` when we kick off a + // new user message. Drive a `send` so `perform_compaction_if_needed` + // runs. + let _events = cx + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), vec!["new prompt"], cx) + }) + }) + .unwrap(); + cx.run_until_parked(); + + assert_eq!(thread_model.pending_completions().len(), 0); + let request = compaction_model + .pending_completions() + .pop() + .expect("compaction model should have received the auto-compaction request"); + assert_eq!( + request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + + compaction_model.send_completion_stream_text_chunk(&request, "summary"); + compaction_model.end_completion_stream(&request); + cx.run_until_parked(); + } + #[gpui::test] async fn test_compaction_usage_counts_toward_cumulative_usage(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); - let old_user_message_id = UserMessageId::new(); - let new_user_message_id = UserMessageId::new(); + let old_user_message_id = ClientUserMessageId::new(); + let new_user_message_id = ClientUserMessageId::new(); let prior_usage = TokenUsage { input_tokens: 960_000, output_tokens: 25, @@ -6899,7 +7803,7 @@ mod tests { #[gpui::test] async fn test_replay_emits_context_compaction(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; - let user_message_id = UserMessageId::new(); + let user_message_id = ClientUserMessageId::new(); let mut replay_events = cx.update(|cx| { thread.update(cx, |thread, cx| { @@ -6951,18 +7855,20 @@ mod tests { let request_messages = cx.update(|cx| { thread.update(cx, |thread, cx| { - thread - .messages - .push(user_text_message(UserMessageId::new(), "before native")); + thread.messages.push(user_text_message( + ClientUserMessageId::new(), + "before native", + )); thread.messages.push(Arc::new(Message::Compaction( CompactionInfo::ProviderNative { provider: LanguageModelProviderId::from("openai".to_string()), items: vec![json!({"type": "compaction"})], }, ))); - thread - .messages - .push(user_text_message(UserMessageId::new(), "after native")); + thread.messages.push(user_text_message( + ClientUserMessageId::new(), + "after native", + )); thread.build_request_messages(Vec::new(), cx) }) @@ -6984,7 +7890,7 @@ mod tests { let request_messages = cx.update(|cx| { thread.update(cx, |thread, cx| { thread.messages.push(user_text_message( - UserMessageId::new(), + ClientUserMessageId::new(), "dropped older user", )); thread @@ -6992,15 +7898,15 @@ mod tests { .push(agent_text_message("dropped assistant")); thread .messages - .push(user_text_message(UserMessageId::new(), &long_text)); + .push(user_text_message(ClientUserMessageId::new(), &long_text)); thread .messages - .push(user_text_message(UserMessageId::new(), "new")); + .push(user_text_message(ClientUserMessageId::new(), "new")); thread.messages.push(summary_compaction("summary context")); thread.messages.push(agent_text_message("after assistant")); thread .messages - .push(user_text_message(UserMessageId::new(), "after user")); + .push(user_text_message(ClientUserMessageId::new(), "after user")); thread.build_request_messages(Vec::new(), cx) }) @@ -7127,17 +8033,15 @@ mod tests { allow_fs_write_all: false, unsandboxed: false, write_paths: vec![ - PathBuf::from("/tmp/build"), - PathBuf::from("/tmp/cache"), - PathBuf::from("/tmp/logs"), - PathBuf::from("/tmp/secret"), + settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/build")), + settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/cache")), + settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/logs")), + settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/secret")), ], }; let authorize = cx.update(|cx| { event_stream.authorize_sandbox( - "Allow write access?", - None, request.clone(), "needs to write build artifacts".to_string(), cx, @@ -7216,6 +8120,7 @@ mod tests { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "bwrap not found on PATH".to_string(), + Some("installing-bubblewrap".to_string()), 0, cx, ) @@ -7227,6 +8132,10 @@ mod tests { .expect("fallback authorization should include details"); assert_eq!(details.command.as_deref(), Some("cargo build")); assert_eq!(details.reason, "bwrap not found on PATH"); + assert_eq!( + details.docs_section.as_deref(), + Some("installing-bubblewrap") + ); let acp_thread::PermissionOptions::Flat(options) = &authorization.options else { panic!("expected flat fallback permission options"); @@ -7267,6 +8176,7 @@ mod tests { event_stream.authorize_sandbox_fallback( None, "probe failed".to_string(), + None, retries, cx, ) @@ -7311,6 +8221,7 @@ mod tests { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "user namespaces are disabled".to_string(), + None, 0, cx, ) @@ -7340,7 +8251,13 @@ mod tests { let (event_stream, mut receiver) = ToolCallEventStream::test(); let authorize = cx.update(|cx| { - event_stream.authorize_sandbox_fallback(None, "bwrap probe failed".to_string(), 0, cx) + event_stream.authorize_sandbox_fallback( + None, + "bwrap probe failed".to_string(), + None, + 0, + cx, + ) }); let authorization = receiver.expect_authorization().await; authorization @@ -7415,7 +8332,7 @@ mod tests { id: registered_tool_use_id.clone(), name: ReplayImageTool::NAME.into(), raw_input: "null".to_string(), - input: json!(null), + input: language_model::LanguageModelToolUseInput::Json(json!(null)), is_input_complete: true, thought_signature: None, }; @@ -7423,7 +8340,7 @@ mod tests { id: missing_tool_use_id.clone(), name: "missing_image_tool".into(), raw_input: "{}".to_string(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }; @@ -7487,8 +8404,16 @@ mod tests { } } - assert!(tool_use_ids_with_image_content.contains(®istered_tool_use_id.to_string())); - assert!(tool_use_ids_with_image_content.contains(&missing_tool_use_id.to_string())); + // Both tool uses live in the message pushed above, at index 0 (see + // `scoped_tool_call_id`). + assert!( + tool_use_ids_with_image_content + .contains(&scoped_tool_call_id(0, ®istered_tool_use_id).to_string()) + ); + assert!( + tool_use_ids_with_image_content + .contains(&scoped_tool_call_id(0, &missing_tool_use_id).to_string()) + ); } #[gpui::test] @@ -7685,7 +8610,7 @@ mod tests { let (_cancellation_tx, cancellation_rx) = watch::channel(false); - let result = cx + let (_owning_message_ix, result) = cx .update(|cx| { thread.update(cx, |thread, cx| { // Call the function under test @@ -7730,7 +8655,10 @@ mod tests { assert_eq!(tool_use.raw_input, raw_input.to_string()); assert!(tool_use.is_input_complete); // Should fall back to empty object for invalid JSON - assert_eq!(tool_use.input, json!({})); + assert_eq!( + tool_use.input, + language_model::LanguageModelToolUseInput::Json(json!({})) + ); } _ => panic!("Expected ToolUse content"), } diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index f5aecdbf682df3..c09c2df88fd4b9 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -1,5 +1,5 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use futures::{FutureExt, future::Shared}; use gpui::{App, Context, Entity, Global, Task, prelude::*}; @@ -160,7 +160,6 @@ mod tests { request_token_usage: HashMap::default(), model: None, profile: None, - imported: false, subagent_context: None, speed: None, thinking_enabled: false, @@ -168,6 +167,7 @@ mod tests { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: Default::default(), } } diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index d57cdb93953ace..601c24d73af2c0 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -580,8 +580,10 @@ mod tests { inline_assistant_model: None, inline_assistant_use_streaming_tools: false, commit_message_model: None, + commit_message_include_project_rules: true, commit_message_instructions: None, thread_summary_model: None, + compaction_model: None, inline_alternatives: vec![], favorite_models: vec![], default_profile: AgentProfileId::default(), diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index f2e92dd28570fd..89211e0ac6fb59 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -47,18 +47,25 @@ where T: DeserializeOwned, D: Deserializer<'de>, { - #[derive(Deserialize)] - #[serde(untagged)] - enum ValueOrJsonString { - Value(T), - String(String), + fn to_custom_error(e: serde_json::Error) -> E + where + E: serde::de::Error, + { + E::custom(format!("{e}")) } - match ValueOrJsonString::::deserialize(deserializer)? { - ValueOrJsonString::Value(value) => Ok(value), - ValueOrJsonString::String(string) => serde_json::from_str::(&string).map_err(|error| { - D::Error::custom(format!("failed to parse stringified value: {error}")) - }), + let raw_value = serde_json::Value::deserialize(deserializer) + .map_err(|error| D::Error::custom(format!("invalid JSON: {error}")))?; + + match T::deserialize(&raw_value) { + Ok(value) => Ok(value), + Err(original_error) => { + let Some(string) = raw_value.as_str() else { + return Err(to_custom_error(original_error)); + }; + + serde_json::from_str(string).map_err(to_custom_error) + } } } @@ -84,6 +91,7 @@ pub use rename_tool::*; pub use skill_tool::*; pub use spawn_agent_tool::*; pub use symbol_locator::*; + pub use terminal_tool::*; pub use tool_permissions::*; pub use web_search_tool::*; @@ -137,15 +145,27 @@ macro_rules! tools { false } + /// Returns whether the tool with the given name may be provided to an + /// agent in a restricted workspace. Unknown tools (e.g. MCP tools) are + /// considered allowed. + pub fn tool_allowed_in_restricted_mode(name: &str) -> bool { + $( + if name == <$tool>::NAME { + return <$tool>::allow_in_restricted_mode(); + } + )* + true + } + /// A list of all built-in tools pub fn built_in_tools() -> impl Iterator { fn language_model_tool() -> LanguageModelRequestTool { - LanguageModelRequestTool { - name: T::NAME.to_string(), - description: T::description().to_string(), - input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), - use_input_streaming: T::supports_input_streaming(), - } + LanguageModelRequestTool::function( + T::NAME.to_string(), + T::description().to_string(), + T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), + T::supports_input_streaming(), + ) } [ $( @@ -218,3 +238,25 @@ pub fn tool_feature_flag_enabled(tool_name: &str, cx: &App) -> bool { _ => true, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fetch_and_terminal_are_forbidden_in_restricted_mode() { + assert!(!tool_allowed_in_restricted_mode(FetchTool::NAME)); + assert!(!tool_allowed_in_restricted_mode(TerminalTool::NAME)); + + // Every other built-in tool, and unknown (e.g. MCP) tools, are allowed. + for name in ALL_TOOL_NAMES { + let expected = *name != FetchTool::NAME && *name != TerminalTool::NAME; + assert_eq!( + tool_allowed_in_restricted_mode(name), + expected, + "unexpected restricted-mode policy for tool `{name}`" + ); + } + assert!(tool_allowed_in_restricted_mode("some_mcp_tool")); + } +} diff --git a/crates/agent/src/tools/apply_code_action_tool.rs b/crates/agent/src/tools/apply_code_action_tool.rs index 409546d89d0ba6..aa3d5ac18bff58 100644 --- a/crates/agent/src/tools/apply_code_action_tool.rs +++ b/crates/agent/src/tools/apply_code_action_tool.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::sync::Arc; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 4cc80e7de7c7a2..27e4996d3283ea 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -1,5 +1,5 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use collections::{BTreeMap, HashMap}; use context_server::{ContextServerId, client::NotificationSubscription}; @@ -8,7 +8,11 @@ use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedStrin use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent}; use project::context_server_store::{ContextServerStatus, ContextServerStore}; use std::sync::Arc; -use util::ResultExt; +use util::{ResultExt, markdown::MarkdownEscaped}; + +/// Maximum number of characters to show from a tool argument in the +/// collapsed tool-call header. Longer values are truncated with an ellipsis. +const MAX_INLINE_ARG_LEN: usize = 120; /// Generates a tool ID for an MCP tool that can be used in settings. /// @@ -318,8 +322,8 @@ impl AnyAgentTool for ContextServerTool { acp::ToolKind::Other } - fn initial_title(&self, _input: serde_json::Value, _cx: &mut App) -> SharedString { - format!("Run MCP tool `{}`", self.tool.name).into() + fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString { + format_mcp_initial_title(&self.tool.name, &input).into() } fn input_schema( @@ -482,6 +486,38 @@ impl AnyAgentTool for ContextServerTool { } } +/// Builds the header label shown for an MCP tool call. When the input is an +/// object with a single string-valued field, the value is inlined next to the +/// tool name so the primary argument (e.g. a URL, path, or query) is visible +/// without expanding the input block — matching the UX of built-in tools like +/// `Fetch`. All other shapes fall back to the tool name alone. +fn format_mcp_initial_title(tool_name: &str, input: &serde_json::Value) -> String { + if let Some(value) = single_string_arg(input) { + let preview = truncate_chars(value, MAX_INLINE_ARG_LEN); + format!("Run MCP tool `{}` {}", tool_name, MarkdownEscaped(&preview)) + } else { + format!("Run MCP tool `{}`", tool_name) + } +} + +fn single_string_arg(input: &serde_json::Value) -> Option<&str> { + let obj = input.as_object()?; + if obj.len() != 1 { + return None; + } + obj.values().next()?.as_str() +} + +fn truncate_chars(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out: String = s.chars().take(max).collect(); + out.push('…'); + out + } +} + pub fn get_prompt( server_store: &Entity, server_id: &ContextServerId, @@ -539,4 +575,92 @@ mod tests { // Note: Tests for MCP tool ID collision with built-in tools and permission // decisions are in crates/agent/src/tool_permissions.rs to avoid duplication. + + #[test] + fn test_format_mcp_initial_title_inlines_single_string_arg() { + let input = serde_json::json!({ "url": "https://example.com/page" }); + assert_eq!( + format_mcp_initial_title("open_url_in_browser", &input), + "Run MCP tool `open_url_in_browser` https://example.com/page" + ); + } + + #[test] + fn test_format_mcp_initial_title_no_args() { + let input = serde_json::json!({}); + assert_eq!( + format_mcp_initial_title("cleanup", &input), + "Run MCP tool `cleanup`" + ); + } + + #[test] + fn test_format_mcp_initial_title_null_input() { + assert_eq!( + format_mcp_initial_title("cleanup", &serde_json::Value::Null), + "Run MCP tool `cleanup`" + ); + } + + #[test] + fn test_format_mcp_initial_title_multiple_fields_falls_back() { + let input = serde_json::json!({ "x": "a", "y": "b" }); + assert_eq!( + format_mcp_initial_title("do_thing", &input), + "Run MCP tool `do_thing`" + ); + } + + #[test] + fn test_format_mcp_initial_title_non_string_field_falls_back() { + let input = serde_json::json!({ "count": 42 }); + assert_eq!( + format_mcp_initial_title("tick", &input), + "Run MCP tool `tick`" + ); + } + + #[test] + fn test_format_mcp_initial_title_truncates_long_values() { + let long = "x".repeat(MAX_INLINE_ARG_LEN + 50); + let input = serde_json::json!({ "q": long }); + let title = format_mcp_initial_title("search", &input); + assert!( + title.ends_with('…'), + "expected truncation ellipsis, got: {title}" + ); + // Prefix + backticked name + space + MAX chars + ellipsis — no full 170-char value. + assert!(title.chars().count() < MAX_INLINE_ARG_LEN + 50); + } + + #[test] + fn test_format_mcp_initial_title_escapes_markdown_in_value() { + let input = serde_json::json!({ "q": "**bold** _italic_" }); + let title = format_mcp_initial_title("search", &input); + // Asterisks and underscores must be escaped so the header renders literally. + assert!(title.contains("\\*"), "expected \\*, got: {title}"); + assert!(title.contains("\\_"), "expected \\_, got: {title}"); + } + + #[test] + fn test_truncate_chars_boundary() { + assert_eq!(truncate_chars("abc", 3), "abc"); + assert_eq!(truncate_chars("abcd", 3), "abc…"); + } + + #[test] + fn test_truncate_chars_handles_multibyte() { + // "café" is 4 chars but 5 bytes — byte-based truncation would panic. + assert_eq!(truncate_chars("café", 4), "café"); + assert_eq!(truncate_chars("café", 3), "caf…"); + } + + #[test] + fn test_single_string_arg_ignores_empty_string() { + // An empty string is still a string — we inline it rather than fall back, + // which lets callers tell "the server sent an empty arg" apart from + // "no args at all". + let input = serde_json::json!({ "q": "" }); + assert_eq!(single_string_arg(&input), Some("")); + } } diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index 42a7311c572593..1730da89669329 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -7,7 +7,7 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, authorize_with_sensitive_settings, decide_permission_for_paths, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, Entity, Task}; diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index fdec1da2b75a50..9871d2bf77b65a 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -2,10 +2,10 @@ use super::tool_permissions::{ authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape, resolve_creatable_global_skill_path, sensitive_settings_kind, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -17,12 +17,26 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, authorize_with_sensitive_settings, decide_permission_for_path, }; -use std::path::Path; +use std::path::{Path, PathBuf}; -/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. +/// Creates a new directory at the specified path, and all necessary parent directories. Returns confirmation that the directory was created. /// -/// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project. -/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. +/// Use this whenever you need to create new directories. Paths inside the project are created directly. +/// +#[cfg_attr( + any(target_os = "linux", target_os = "macos"), + doc = "This tool can also create a directory **outside** the project. When agent terminal \ + commands are sandboxed, doing so grants those commands write access to exactly that new \ + directory — so, rather than requesting write access to a broad existing parent (e.g. your \ + home directory) just to create something inside it, create the specific directory here \ + first and then write into it. The only other supported path outside the project is \ + `~/.agents/skills` or a descendant, for global agent skills." +)] +#[cfg_attr( + not(any(target_os = "linux", target_os = "macos")), + doc = "The only supported path outside the project is `~/.agents/skills` or a descendant, \ + for global agent skills." +)] #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CreateDirectoryToolInput { /// The path of the new directory. @@ -40,6 +54,20 @@ pub struct CreateDirectoryToolInput { /// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`. /// pub path: String, + + #[cfg_attr( + any(target_os = "linux", target_os = "macos"), + doc = "Justification for creating a directory **outside** the project, shown to the \ + user (attributed to you) in the approval prompt that grants sandboxed terminal \ + commands write access to it. Required only for out-of-project paths; ignored for \ + paths inside the project or the global skills dir." + )] + #[cfg_attr( + not(any(target_os = "linux", target_os = "macos")), + doc = "Unused on this platform." + )] + #[serde(default)] + pub reason: Option, } pub struct CreateDirectoryTool { @@ -83,6 +111,28 @@ impl AgentTool for CreateDirectoryTool { let project = self.project.clone(); cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; + + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); + + // Resolve where this directory lives. The global agent-skills dir is a + // special case allowed outside the project; anything else outside the + // project is handled as a narrow sandbox write grant below. + let global_skill_directory = + resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await; + let in_project = project.read_with(cx, |project, cx| { + project.find_project_path(&input.path, cx).is_some() + }); + + // A path outside the project (and not the global skills dir) can only + // be created as a narrow sandbox write grant: create the directory and + // grant sandboxed terminal commands write access to exactly it. The + // sandbox approval prompt — which shows the real, canonicalized target + // — fully replaces the normal permission and symlink-escape prompts + // here. + if global_skill_directory.is_none() && !in_project { + return create_out_of_project_directory(&project, &input, &event_stream, cx).await; + } + let decision = cx.update(|cx| { decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx)) }); @@ -93,7 +143,6 @@ impl AgentTool for CreateDirectoryTool { let destination_path: Arc = input.path.as_str().into(); - let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; let symlink_escape_target = project.read_with(cx, |project, cx| { @@ -149,9 +198,7 @@ impl AgentTool for CreateDirectoryTool { authorize.await.map_err(|e| e.to_string())?; } - if let Some(global_skill_directory) = - resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await - { + if let Some(global_skill_directory) = global_skill_directory { futures::select! { result = fs.create_dir(&global_skill_directory).fuse() => { result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?; @@ -185,6 +232,106 @@ impl AgentTool for CreateDirectoryTool { } } +/// Create a directory that lives **outside** the project by granting sandboxed +/// terminal commands write access to exactly it. +/// +/// The directory is created (Linux: eagerly, pinning the inode; macOS: after +/// approval) and the user is shown the real, canonicalized target in the sandbox +/// approval prompt — which is what defends against a concurrent symlink swap: the +/// grant is always against the inode/path the user actually saw. On denial, only +/// the directories we created are removed. +async fn create_out_of_project_directory( + project: &Entity, + input: &CreateDirectoryToolInput, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, +) -> Result { + // Narrowing a grant to a brand-new directory only makes sense when the + // project's terminal commands are sandboxed, and only on platforms that can + // grant a not-yet-existing directory. Otherwise keep the historical + // "outside the project" rejection. + let sandboxing = project.read_with(cx, |project, cx| { + crate::sandboxing::sandboxing_enabled_for_project(project, cx) + }); + let platform_supported = cfg!(any(target_os = "linux", target_os = "macos")); + if !sandboxing || !platform_supported { + return Err("Path to create was outside the project".to_string()); + } + + let Some(reason) = input + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + else { + return Err( + "Creating a directory outside the project grants sandboxed terminal commands write \ + access to it, so a `reason` is required: briefly justify why the directory is needed, \ + then try again." + .to_string(), + ); + }; + let reason = reason.to_string(); + + let absolute = resolve_absolute_path(project, &input.path, cx) + .ok_or_else(|| format!("Couldn't resolve `{}` to an absolute path.", input.path))?; + + let prepared = cx + .background_spawn(async move { sandbox::GrantableWriteDir::prepare(&absolute) }) + .await + .map_err(|error| format!("Creating directory {}: {error}", input.path))?; + + let canonical = prepared.canonical_path().to_path_buf(); + // The directory was just created and its inode pinned, so persist the + // resolved canonical alongside the raw request: enforcement rebuilds the + // grant from the vetted canonical via a verifying reopen. + let request = crate::sandboxing::SandboxRequest { + write_paths: vec![settings::GrantedWritePath::resolved( + prepared.untrusted_raw_path().to_path_buf(), + canonical.clone(), + )], + ..Default::default() + }; + + let approve = cx.update(|cx| event_stream.authorize_sandbox(request, reason, cx)); + match approve.await { + Ok(()) => { + let display = canonical.display().to_string(); + cx.background_spawn(async move { prepared.finalize() }) + .await + .map_err(|error| format!("Creating directory {display}: {error}"))?; + Ok(format!("Created directory {display}")) + } + Err(error) => { + // Roll back exactly what we created; leave the user no litter. + cx.background_spawn(async move { prepared.discard() }).await; + Err(format!("Create directory cancelled: {error}")) + } + } +} + +/// Resolve a model-provided path to an absolute, lexically-normalized path. +/// Relative paths are joined onto the first worktree root. +fn resolve_absolute_path( + project: &Entity, + raw: &str, + cx: &mut AsyncApp, +) -> Option { + let path = Path::new(raw); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + let base = project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + })?; + base.join(path) + }; + util::paths::normalize_lexically(&absolute).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -231,7 +378,10 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - ToolInput::resolved(CreateDirectoryToolInput { path: input_path }), + ToolInput::resolved(CreateDirectoryToolInput { + path: input_path, + reason: None, + }), event_stream, cx, ) @@ -286,6 +436,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: outside_path.to_string_lossy().into_owned(), + reason: None, }), event_stream, cx, @@ -342,6 +493,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -404,6 +556,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -463,6 +616,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -545,6 +699,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -561,4 +716,110 @@ mod tests { "Deny policy should not emit symlink authorization prompt", ); } + + /// Out-of-project creation goes through the sandbox write-grant prompt and, + /// on approval, creates the *specific* new directory (not its broad parent). + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[gpui::test] + async fn test_create_directory_out_of_project_creates_and_grants(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + // The sandbox create path operates on the *real* filesystem, so use a + // real directory outside the (fake) project. + let scratch = tempfile::tempdir().unwrap(); + let target = scratch.path().join("new_grant_dir"); + assert!(!target.exists()); + + let tool = Arc::new(CreateDirectoryTool::new(project)); + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let path_input = target.to_string_lossy().into_owned(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CreateDirectoryToolInput { + path: path_input, + reason: Some("scratch space for the build".into()), + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let details = acp_thread::sandbox_authorization_details_from_meta(&auth.tool_call.meta) + .expect("out-of-project create should request a sandbox write grant"); + // The grant is for exactly the new directory, not its parent, and + // carries the resolved canonical established when it was created. + let expected_canonical = scratch.path().canonicalize().unwrap().join("new_grant_dir"); + assert_eq!(details.write_paths.len(), 1); + assert_eq!( + details.write_paths[0].canonical_or_requested(), + expected_canonical + ); + + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), + acp::PermissionOptionKind::AllowAlways, + )) + .unwrap(); + + let result = task.await; + assert!(result.is_ok(), "expected success, got {result:?}"); + assert!( + target.is_dir(), + "the new directory should have been created" + ); + } + + /// Denying the grant removes the directory we eagerly created, leaving no + /// trace on the filesystem. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[gpui::test] + async fn test_create_directory_out_of_project_denied_cleans_up(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let scratch = tempfile::tempdir().unwrap(); + let target = scratch.path().join("denied_dir"); + + let tool = Arc::new(CreateDirectoryTool::new(project)); + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let path_input = target.to_string_lossy().into_owned(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CreateDirectoryToolInput { + path: path_input, + reason: Some("scratch space".into()), + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), + acp::PermissionOptionKind::RejectOnce, + )) + .unwrap(); + + let result = task.await; + assert!(result.is_err(), "denied create should fail"); + assert!( + !target.exists(), + "denied create should leave no directory behind" + ); + } } diff --git a/crates/agent/src/tools/create_thread_tool.rs b/crates/agent/src/tools/create_thread_tool.rs index 9f87412d027973..c19462e076a70c 100644 --- a/crates/agent/src/tools/create_thread_tool.rs +++ b/crates/agent/src/tools/create_thread_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use gpui::{App, SharedString, Task}; use language_model::LanguageModelToolResultContent; diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index bae19d7692e040..0f837e5144e318 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -7,7 +7,7 @@ use crate::{ authorize_with_sensitive_settings, decide_permission_for_path, }; use action_log::ActionLog; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext, Entity, SharedString, Task}; @@ -247,9 +247,7 @@ impl AgentTool for DeletePathTool { } let deletion_task = project - .update(cx, |project, cx| { - project.delete_file(project_path, false, cx) - }) + .update(cx, |project, cx| project.delete_file(project_path, cx)) .ok_or_else(|| { format!("Couldn't delete {path} because that path isn't in this project.") })?; diff --git a/crates/agent/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs index 89d4ef54677dd8..ea5bdd8e71d6bf 100644 --- a/crates/agent/src/tools/diagnostics_tool.rs +++ b/crates/agent/src/tools/diagnostics_tool.rs @@ -1,5 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use futures::{Future, FutureExt as _}; use gpui::{App, AsyncApp, Entity, Task}; use language::{DiagnosticSeverity, OffsetRangeExt}; diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index fc513d904a3445..64f6af0a49c592 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -7,7 +7,7 @@ use super::edit_session::{ }; use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, ToolInputPayload}; use action_log::ActionLog; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::FutureExt as _; use gpui::{App, AsyncApp, Entity, Task, WeakEntity}; @@ -324,6 +324,150 @@ mod tests { assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); } + #[gpui::test] + async fn test_streaming_edit_exact_fragments(cx: &mut TestAppContext) { + let content = concat!( + "fn spaces() {\n", + " spaces_old();\n", + "}\n", + "fn tabs() {\n", + "\ttabs_old();\n", + "}\n", + "controls: keyboard WASD, voxel-based\n", + "prefix OLD suffix\n", + "foo suffix\n", + "foo\n", + ); + let (edit_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.rs": content})).await; + let result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/file.rs".into(), + edits: vec![ + Edit { + old_text: "keyboard WASD, voxel-based".into(), + new_text: "arrow keys".into(), + }, + Edit { + old_text: "spaces_old();".into(), + new_text: "spaces_new();\nspaces_more();".into(), + }, + Edit { + old_text: "tabs_old();".into(), + new_text: "tabs_new();\ntabs_more();".into(), + }, + Edit { + old_text: "OLD".into(), + new_text: "NEW\n".into(), + }, + Edit { + old_text: "foo\n".into(), + new_text: "bar\n".into(), + }, + ], + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!( + new_text, + concat!( + "fn spaces() {\n", + " spaces_new();\n", + " spaces_more();\n", + "}\n", + "fn tabs() {\n", + "\ttabs_new();\n", + "\ttabs_more();\n", + "}\n", + "controls: arrow keys\n", + "prefix NEW\n", + " suffix\n", + "foo suffix\n", + "bar\n", + ) + ); + } + + #[gpui::test] + async fn test_streaming_edit_first_line_missing_indent(cx: &mut TestAppContext) { + // Reproduces https://github.com/zed-industries/zed/issues/60302: the + // first line of the multi-line `old_text` omits its leading + // indentation while subsequent lines include theirs, so the indent + // delta computed from the first line must not be applied to the + // following lines. `old_text` also omits the `self.extra` line, so + // the query lines don't correspond one-to-one to the matched buffer + // rows and the indent pairing must follow the fuzzy match's + // alignment instead of assuming equal line counts. + let content = concat!( + "class Outer:\n", + " def method(self):\n", + " self.kept = \"unchanged\"\n", + " self.target_a = \"before\"\n", + " self.extra = \"row\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"\n", + " self.kept_2 = \"unchanged\"\n", + ); + let (edit_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.py": content})).await; + let result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/file.py".into(), + edits: vec![Edit { + old_text: concat!( + "self.target_a = \"before\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"", + ) + .into(), + new_text: concat!( + "self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + " self.target_c = \"after\"\n", + " self.target_d = \"after\"", + ) + .into(), + }], + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + // The matched range includes the `self.extra` row, so it is replaced + // along with the rest of the match. + assert_eq!( + new_text, + concat!( + "class Outer:\n", + " def method(self):\n", + " self.kept = \"unchanged\"\n", + " self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + " self.target_c = \"after\"\n", + " self.target_d = \"after\"\n", + " self.kept_2 = \"unchanged\"\n", + ) + ); + } + #[gpui::test] async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) { let (edit_tool, _project, _action_log, _fs, _thread) = setup_test( @@ -556,6 +700,33 @@ mod tests { ); } + #[gpui::test] + async fn test_streaming_edit_rejects_overlapping_matches(cx: &mut TestAppContext) { + let (edit_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "aaaaa"})).await; + let result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/file.txt".into(), + edits: vec![Edit { + old_text: "aaaa".into(), + new_text: "replacement".into(), + }], + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Error { error, diff, .. } = result.unwrap_err() else { + panic!("expected error"); + }; + assert!(error.contains("matched multiple locations")); + assert!(diff.is_empty()); + } + /// When the edit fails after a session is created but before any edits are /// actually applied (e.g., the first `old_text` doesn't match), the empty /// diff placeholder in the UI should be replaced with the error message. @@ -2890,6 +3061,19 @@ mod tests { assert!(input.edits.is_none()); } + #[test] + fn test_wrong_edit_field_names_produce_actionable_error() { + let err = serde_json::from_value::(json!({ + "path": "test.go", + "edits": [{"old_str": "hello", "new_text": "world"}] + })) + .unwrap_err(); + + // When the model uses incorrect field names, the error should + // tell it what to fix. + assert_eq!(err.to_string(), "missing field `old_text`"); + } + async fn setup_test_with_fs( cx: &mut TestAppContext, fs: Arc, diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index 9fd116fe18b0fe..f96f84c7f68220 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -6,7 +6,7 @@ use super::tool_permissions::resolve_creatable_global_skill_path; use crate::{Thread, ToolCallEventStream}; use acp_thread::Diff; use action_log::ActionLog; -use agent_client_protocol::schema::{self as acp, ToolCallLocation, ToolCallUpdateFields}; +use agent_client_protocol::schema::v1::{self as acp, ToolCallLocation, ToolCallUpdateFields}; use anyhow::Result; use collections::HashSet; use futures::{FutureExt, channel::oneshot}; @@ -16,14 +16,14 @@ use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry}; use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; -use reindent::{Reindenter, compute_indent_delta}; +use reindent::{IndentDelta, Reindenter, compute_indent_delta, compute_rest_indent_delta}; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::ops::Range; use std::path::PathBuf; use std::sync::Arc; use streaming_diff::{CharOperation, StreamingDiff}; -use streaming_fuzzy_matcher::StreamingFuzzyMatcher; +use streaming_fuzzy_matcher::{SearchMatch, SearchMatches, StreamingFuzzyMatcher}; use streaming_parser::{EditEvent, StreamingParser, WriteEvent}; use text::ToOffset; use ui::SharedString; @@ -393,6 +393,7 @@ enum EditPipelineEntry { edit_cursor: usize, reindenter: Reindenter, original_snapshot: text::BufferSnapshot, + strip_trailing_newline: bool, }, } @@ -506,7 +507,10 @@ impl EditPipeline { if !chunk.is_empty() { matcher.push(chunk, None); } - let range = extract_match( + let ResolvedMatch { + search_match: SearchMatch { range, line_pairs }, + is_exact, + } = extract_match( matcher.finish(), buffer, edit_index, @@ -527,16 +531,40 @@ impl EditPipeline { ); let buffer_indent = snapshot.line_indent_for_row(line); + let query_lines = matcher.query_lines(); let query_indent = text::LineIndent::from_iter( - matcher - .query_lines() + query_lines .first() .map(|s| s.as_str()) .unwrap_or("") .chars(), ); - let indent_delta = compute_indent_delta(buffer_indent, query_indent); + let contextual_delta = compute_indent_delta(buffer_indent, query_indent); + let first_line_delta = if is_exact { + // An exact range may start mid-line, where the retained prefix already + // provides the first replacement line's indentation. + IndentDelta::Spaces(0) + } else { + contextual_delta + }; + // Query row 0 is excluded: its delta is `first_line_delta`, + // which intentionally differs when the model stripped the + // first line's indentation. + let rest_delta = compute_rest_indent_delta( + contextual_delta, + line_pairs + .iter() + .filter(|(query_row, _)| *query_row != 0) + .filter_map(|(query_row, buffer_row)| { + let query_line = query_lines.get(*query_row as usize)?; + Some(( + snapshot.line_indent_for_row(*buffer_row), + text::LineIndent::from_iter(query_line.chars()), + )) + }), + ); + let strip_trailing_newline = snapshot.contains_str_at(range.end, "\n"); let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::(); log::debug!( @@ -551,8 +579,9 @@ impl EditPipeline { self.current_edit = Some(EditPipelineEntry::StreamingNewText { streaming_diff: StreamingDiff::new(old_text_in_buffer), edit_cursor: range.start, - reindenter: Reindenter::new(indent_delta), + reindenter: Reindenter::with_deltas(first_line_delta, rest_delta), original_snapshot: text_snapshot, + strip_trailing_newline, }); cx.update(|cx| { @@ -606,11 +635,17 @@ impl EditPipeline { mut edit_cursor, mut reindenter, original_snapshot, + strip_trailing_newline, }) = self.current_edit.take() else { return Ok(()); }; + let chunk = if strip_trailing_newline { + chunk.strip_suffix('\n').unwrap_or(chunk) + } else { + chunk + }; let mut final_text = reindenter.push(chunk); final_text.push_str(&reindenter.finish()); @@ -914,32 +949,46 @@ fn apply_char_operations( } } +struct ResolvedMatch { + search_match: SearchMatch, + is_exact: bool, +} + fn extract_match( - matches: Vec>, + matches: SearchMatches, buffer: &Entity, edit_index: &usize, file_changed_since_last_read: bool, cx: &mut AsyncApp, -) -> Result, String> { +) -> Result { + let (matches, is_exact) = match matches { + SearchMatches::Exact(matches) => (matches, true), + SearchMatches::Fuzzy(matches) => (matches, false), + }; let file_changed_since_last_read_message = if file_changed_since_last_read { " The file has changed on disk since you last read it." } else { "" }; - match matches.len() { - 0 => Err(format!( + match matches.as_slice() { + [] => Err(format!( "Could not find matching text for edit at index {}. \ The old_text did not match any content in the file.{} \ Please read the file again to get the current content.", edit_index, file_changed_since_last_read_message, )), - 1 => Ok(matches.into_iter().next().unwrap()), + [search_match] => Ok(ResolvedMatch { + search_match: search_match.clone(), + is_exact, + }), _ => { let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); let lines = matches .iter() - .map(|range| (snapshot.offset_to_point(range.start).row + 1).to_string()) + .map(|search_match| { + (snapshot.offset_to_point(search_match.range.start).row + 1).to_string() + }) .collect::>() .join(", "); Err(format!( @@ -1226,11 +1275,11 @@ fn resolve_path( let file_name = path .file_name() .and_then(|file_name| file_name.to_str()) - .and_then(|file_name| RelPath::unix(file_name).ok()) + .and_then(|file_name| RelPath::from_unix_str(file_name).ok()) .ok_or_else(|| "Can't create file: invalid filename".to_string())?; let new_file_path = parent_project_path.map(|parent| ProjectPath { - path: parent.path.join(file_name), + path: parent.path.join(file_name).into(), ..parent }); diff --git a/crates/agent/src/tools/edit_session/reindent.rs b/crates/agent/src/tools/edit_session/reindent.rs index 7f08749e475f6a..ed4066e30d26a6 100644 --- a/crates/agent/src/tools/edit_session/reindent.rs +++ b/crates/agent/src/tools/edit_session/reindent.rs @@ -1,7 +1,7 @@ use language::LineIndent; use std::{cmp, iter}; -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum IndentDelta { Spaces(isize), Tabs(isize), @@ -31,20 +31,60 @@ pub fn compute_indent_delta(buffer_indent: LineIndent, query_indent: LineIndent) } } +/// Computes the indent delta for the lines after the first, given per-line +/// `(buffer, query)` indents for those lines. +/// +/// When the remaining lines agree on a consistent delta, that delta is +/// returned even if it differs from `first_line_delta`. This handles queries +/// where only the first line's indentation was stripped. When the remaining +/// lines are inconsistent (or all blank), falls back to `first_line_delta`, +/// preserving the uniform re-indentation behavior. +pub fn compute_rest_indent_delta( + first_line_delta: IndentDelta, + indent_pairs: impl IntoIterator, +) -> IndentDelta { + let mut rest_delta = None; + for (buffer_indent, query_indent) in indent_pairs { + if buffer_indent.line_blank || query_indent.line_blank { + continue; + } + let delta = compute_indent_delta(buffer_indent, query_indent); + match rest_delta { + None => rest_delta = Some(delta), + Some(existing) if existing == delta => {} + Some(_) => return first_line_delta, + } + } + rest_delta.unwrap_or(first_line_delta) +} + /// Synchronous re-indentation adapter. Buffers incomplete lines and applies /// an `IndentDelta` to each line's leading whitespace before emitting it. +/// +/// Models sometimes omit the leading indentation only on the first line of +/// `old_text`/`new_text` (e.g. when copying from mid-line context), so the +/// first line and the remaining lines can require different deltas. pub struct Reindenter { - delta: IndentDelta, + first_line_delta: IndentDelta, + rest_delta: IndentDelta, buffer: String, in_leading_whitespace: bool, + on_first_line: bool, } impl Reindenter { - pub fn new(delta: IndentDelta) -> Self { + #[cfg(test)] + fn uniform(delta: IndentDelta) -> Self { + Self::with_deltas(delta, delta) + } + + pub fn with_deltas(first_line_delta: IndentDelta, rest_delta: IndentDelta) -> Self { Self { - delta, + first_line_delta, + rest_delta, buffer: String::new(), in_leading_whitespace: true, + on_first_line: true, } } @@ -70,14 +110,19 @@ impl Reindenter { None => (self.buffer.len(), true), }; let line = &self.buffer[start_ix..line_end]; + let delta = if self.on_first_line { + self.first_line_delta + } else { + self.rest_delta + }; if self.in_leading_whitespace { - if let Some(non_whitespace_ix) = line.find(|c| self.delta.character() != c) { + if let Some(non_whitespace_ix) = line.find(|c| delta.character() != c) { // We found a non-whitespace character, adjust indentation // based on the delta. let new_indent_len = - cmp::max(0, non_whitespace_ix as isize + self.delta.len()) as usize; - indented.extend(iter::repeat(self.delta.character()).take(new_indent_len)); + cmp::max(0, non_whitespace_ix as isize + delta.len()) as usize; + indented.extend(iter::repeat(delta.character()).take(new_indent_len)); indented.push_str(&line[non_whitespace_ix..]); self.in_leading_whitespace = false; } else if is_pending_line && !is_final { @@ -97,6 +142,7 @@ impl Reindenter { break; } else { self.in_leading_whitespace = true; + self.on_first_line = false; indented.push('\n'); start_ix = line_end + 1; } @@ -116,7 +162,7 @@ mod tests { #[test] fn test_indent_single_chunk() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); let out = r.push(" abc\n def\n ghi"); // All three lines are emitted: "ghi" starts with spaces but // contains non-whitespace, so it's processed immediately. @@ -127,7 +173,7 @@ mod tests { #[test] fn test_outdent_tabs() { - let mut r = Reindenter::new(IndentDelta::Tabs(-2)); + let mut r = Reindenter::uniform(IndentDelta::Tabs(-2)); let out = r.push("\t\t\t\tabc\n\t\tdef\n\t\t\t\t\t\tghi"); assert_eq!(out, "\t\tabc\ndef\n\t\t\t\tghi"); let out = r.finish(); @@ -136,7 +182,7 @@ mod tests { #[test] fn test_incremental_chunks() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); // Feed " ab" — the `a` is non-whitespace, so the line is // processed immediately even without a trailing newline. let out = r.push(" ab"); @@ -151,7 +197,7 @@ mod tests { #[test] fn test_zero_delta() { - let mut r = Reindenter::new(IndentDelta::Spaces(0)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(0)); let out = r.push(" hello\n world\n"); assert_eq!(out, " hello\n world\n"); let out = r.finish(); @@ -160,7 +206,7 @@ mod tests { #[test] fn test_clamp_negative_indent() { - let mut r = Reindenter::new(IndentDelta::Spaces(-10)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(-10)); let out = r.push(" abc\n"); // max(0, 2 - 10) = 0, so no leading spaces. assert_eq!(out, "abc\n"); @@ -170,7 +216,7 @@ mod tests { #[test] fn test_whitespace_only_lines() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); let out = r.push(" \n code\n"); // First line is all whitespace — emitted verbatim. Second line is indented. assert_eq!(out, " \n code\n"); @@ -178,6 +224,95 @@ mod tests { assert_eq!(out, ""); } + #[test] + fn test_distinct_first_line_delta() { + // First line's indentation was stripped in the query (delta +8), + // while the remaining lines are already correct (delta 0). Chunks + // split mid-line and mid-indentation to exercise the streaming path, + // and the blank line is passed through verbatim. + let mut r = Reindenter::with_deltas(IndentDelta::Spaces(8), IndentDelta::Spaces(0)); + let mut out = r.push("self.target_a = "); + out.push_str(&r.push("\"after\"\n ")); + out.push_str(&r.push(" self.target_b = \"after\"\n")); + out.push_str(&r.push("\n self.target_c = \"after\"")); + out.push_str(&r.finish()); + assert_eq!( + out, + concat!( + " self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + "\n", + " self.target_c = \"after\"", + ) + ); + } + + fn line_indent(text: &str) -> LineIndent { + LineIndent::from_iter(text.chars()) + } + + #[test] + fn test_compute_rest_indent_delta() { + let first_line_delta = IndentDelta::Spaces(8); + + // Remaining lines that agree on a delta override the first-line + // delta, and blank lines are skipped when forming the consensus. + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(""), line_indent("")), + (line_indent(" c"), line_indent(" c")), + ], + ), + IndentDelta::Spaces(0) + ); + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(" "), line_indent("")), + (line_indent(" c"), line_indent(" c")), + ], + ), + IndentDelta::Spaces(4) + ); + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![(line_indent("\t\tb"), line_indent("\tb"))], + ), + IndentDelta::Tabs(1) + ); + + // Inconsistent remaining lines fall back to the first-line delta... + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(" c"), line_indent(" c")), + ], + ), + first_line_delta + ); + + // ...and so do all-blank and empty pairings. + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![(line_indent(" "), line_indent(""))], + ), + first_line_delta + ); + assert_eq!( + compute_rest_indent_delta(first_line_delta, vec![]), + first_line_delta + ); + } + #[test] fn test_compute_indent_delta_spaces() { let buffer = LineIndent { diff --git a/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs b/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs index e6a56099a29321..321fb1c3530d91 100644 --- a/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs +++ b/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs @@ -4,6 +4,9 @@ use std::{cmp, ops::Range}; const REPLACEMENT_COST: u32 = 1; const INSERTION_COST: u32 = 3; const DELETION_COST: u32 = 10; +// Two matches are enough to prove ambiguity, so stop scanning early; exact +// matches are byte-identical and can't be ranked. +const MAX_EXACT_MATCHES: usize = 2; /// A streaming fuzzy matcher that can process text chunks incrementally /// and return the best match found so far at each step. @@ -12,10 +15,25 @@ pub struct StreamingFuzzyMatcher { query_lines: Vec, line_hint: Option, incomplete_line: String, - matches: Vec>, + raw_query: String, + matches: Vec, matrix: SearchMatrix, } +/// A match candidate: the matched byte range plus the 0-based +/// `(query_row, buffer_row)` line pairs the search aligned to produce it. +#[derive(Clone, Debug)] +pub(super) struct SearchMatch { + pub(super) range: Range, + pub(super) line_pairs: Vec<(u32, u32)>, +} + +#[derive(Debug)] +pub(super) enum SearchMatches { + Exact(Vec), + Fuzzy(Vec), +} + impl StreamingFuzzyMatcher { pub fn new(snapshot: TextBufferSnapshot) -> Self { let buffer_line_count = snapshot.max_point().row as usize + 1; @@ -24,6 +42,7 @@ impl StreamingFuzzyMatcher { query_lines: Vec::new(), line_hint: None, incomplete_line: String::new(), + raw_query: String::new(), matches: Vec::new(), matrix: SearchMatrix::new(buffer_line_count + 1), } @@ -45,6 +64,7 @@ impl StreamingFuzzyMatcher { /// query so far, or `None` if no suitable match exists yet. pub fn push(&mut self, chunk: &str, line_hint: Option) -> Option> { // Add the chunk to our incomplete line buffer + self.raw_query.push_str(chunk); self.incomplete_line.push_str(chunk); self.line_hint = line_hint; @@ -62,36 +82,68 @@ impl StreamingFuzzyMatcher { } let best_match = self.select_best_match(); - best_match.or_else(|| self.matches.first().cloned()) + best_match.or_else(|| { + self.matches + .first() + .map(|search_match| search_match.range.clone()) + }) } /// Finish processing and return the final best match(es). /// /// This processes any remaining incomplete line before returning the final /// match result. - pub fn finish(&mut self) -> Vec> { + pub fn finish(&mut self) -> SearchMatches { + let exact_ranges = find_exact_matches(&self.snapshot, &self.raw_query); + if !exact_ranges.is_empty() { + if !self.incomplete_line.is_empty() { + self.query_lines + .push(std::mem::take(&mut self.incomplete_line)); + } + let matches = exact_ranges + .into_iter() + .map(|range| { + let start_row = self.snapshot.offset_to_point(range.start).row; + let line_pairs = (0..self.query_lines.len()) + .map(|query_row| (query_row as u32, start_row + query_row as u32)) + .collect(); + SearchMatch { range, line_pairs } + }) + .collect(); + return SearchMatches::Exact(matches); + } + // Process any remaining incomplete line if !self.incomplete_line.is_empty() { - if self.matches.len() == 1 { - let range = &mut self.matches[0]; + if let [only_match] = self.matches.as_mut_slice() { + let range = &mut only_match.range; if range.end < self.snapshot.len() && self .snapshot .contains_str_at(range.end + 1, &self.incomplete_line) { range.end += 1 + self.incomplete_line.len(); - return self.matches.clone(); + // Record the line and its alignment so that `query_lines` + // and `line_pairs` stay in sync with the lines covered by + // the returned range. + let extended_row = self.snapshot.offset_to_point(range.end).row; + self.query_lines + .push(std::mem::take(&mut self.incomplete_line)); + only_match + .line_pairs + .push(((self.query_lines.len() - 1) as u32, extended_row)); + return SearchMatches::Fuzzy(self.matches.clone()); } } - self.query_lines.push(self.incomplete_line.clone()); - self.incomplete_line.clear(); + self.query_lines + .push(std::mem::take(&mut self.incomplete_line)); self.matches = self.resolve_location_fuzzy(); } - self.matches.clone() + SearchMatches::Fuzzy(self.matches.clone()) } - fn resolve_location_fuzzy(&mut self) -> Vec> { + fn resolve_location_fuzzy(&mut self) -> Vec { let new_query_line_count = self.query_lines.len(); let old_query_line_count = self.matrix.rows.saturating_sub(1); if new_query_line_count == old_query_line_count { @@ -167,7 +219,7 @@ impl StreamingFuzzyMatcher { // Find ranges for the matches let mut valid_matches = Vec::new(); for &buffer_row_end in &matches_with_best_cost { - let mut matched_lines = 0; + let mut line_pairs = Vec::new(); let mut query_row = new_query_line_count; let mut buffer_row_start = buffer_row_end; while query_row > 0 && buffer_row_start > 0 { @@ -176,7 +228,7 @@ impl StreamingFuzzyMatcher { SearchDirection::Diagonal => { query_row -= 1; buffer_row_start -= 1; - matched_lines += 1; + line_pairs.push((query_row as u32, buffer_row_start)); } SearchDirection::Up => { query_row -= 1; @@ -186,9 +238,10 @@ impl StreamingFuzzyMatcher { } } } + line_pairs.reverse(); let matched_buffer_row_count = buffer_row_end - buffer_row_start; - let matched_ratio = matched_lines as f32 + let matched_ratio = line_pairs.len() as f32 / (matched_buffer_row_count as f32).max(new_query_line_count as f32); if matched_ratio >= 0.8 { let buffer_start_ix = self @@ -198,11 +251,14 @@ impl StreamingFuzzyMatcher { buffer_row_end - 1, self.snapshot.line_len(buffer_row_end - 1), )); - valid_matches.push((buffer_row_start, buffer_start_ix..buffer_end_ix)); + valid_matches.push(SearchMatch { + range: buffer_start_ix..buffer_end_ix, + line_pairs, + }); } } - valid_matches.into_iter().map(|(_, range)| range).collect() + valid_matches } /// Return the best match with starting position close enough to line_hint. @@ -216,8 +272,8 @@ impl StreamingFuzzyMatcher { return None; } - if self.matches.len() == 1 { - return self.matches.first().cloned(); + if let [only_match] = self.matches.as_slice() { + return Some(only_match.range.clone()); } let Some(line_hint) = self.line_hint else { @@ -228,14 +284,14 @@ impl StreamingFuzzyMatcher { let mut best_match = None; let mut best_distance = u32::MAX; - for range in &self.matches { - let start_point = self.snapshot.offset_to_point(range.start); + for search_match in &self.matches { + let start_point = self.snapshot.offset_to_point(search_match.range.start); let start_line = start_point.row; let distance = start_line.abs_diff(line_hint); if distance <= LINE_HINT_TOLERANCE && distance < best_distance { best_distance = distance; - best_match = Some(range.clone()); + best_match = Some(search_match.range.clone()); } } @@ -243,6 +299,62 @@ impl StreamingFuzzyMatcher { } } +// Aho-Corasick's overlapping search requires a contiguous haystack, while its +// streaming search skips overlapping matches. KMP detects overlapping ambiguity +// while scanning rope chunks without copying the buffer. +fn find_exact_matches(snapshot: &TextBufferSnapshot, query: &str) -> Vec> { + if query.is_empty() { + return Vec::new(); + } + + let query = query.as_bytes(); + let trailing_line_ending_len = if query.ends_with(b"\r\n") { + 2 + } else if query.ends_with(b"\n") { + 1 + } else { + 0 + }; + let mut prefix_lengths = vec![0; query.len()]; + let mut prefix_length = 0; + for query_index in 1..query.len() { + while prefix_length > 0 && query[query_index] != query[prefix_length] { + prefix_length = prefix_lengths[prefix_length - 1]; + } + if query[query_index] == query[prefix_length] { + prefix_length += 1; + prefix_lengths[query_index] = prefix_length; + } + } + + let mut matches = Vec::new(); + let mut matched_length = 0; + for (offset, byte) in snapshot + .bytes_in_range(0..snapshot.len()) + .flatten() + .copied() + .enumerate() + { + while matched_length > 0 && byte != query[matched_length] { + matched_length = prefix_lengths[matched_length - 1]; + } + if byte == query[matched_length] { + matched_length += 1; + } + if matched_length == query.len() { + let raw_end = offset + 1; + let start = raw_end - query.len(); + let end = raw_end - trailing_line_ending_len; + matches.push(start..end); + if matches.len() == MAX_EXACT_MATCHES { + break; + } + matched_length = prefix_lengths[matched_length - 1]; + } + } + matches +} + fn fuzzy_eq(left: &str, right: &str) -> bool { const THRESHOLD: f64 = 0.8; @@ -521,7 +633,7 @@ mod tests { assert_location_resolution( concat!( " Lorem\n", - "« ipsum»\n", + " «ipsum»\n", " dolor sit amet\n", " consecteur", ), @@ -734,6 +846,111 @@ mod tests { ); } + #[gpui::test] + fn test_exact_match_takes_precedence_over_fuzzy_match() { + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + concat!( + "prefix keyboard WASD, voxel-based suffix\n", + "keyboard WASD, voxel-baseX\n", + ), + ); + let snapshot = buffer.snapshot(); + let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); + + assert_eq!(matcher.push("keyboard WASD, voxel-based", None), None); + let SearchMatches::Exact(matches) = matcher.finish() else { + panic!("expected an exact match"); + }; + let [search_match] = matches.as_slice() else { + panic!("expected one match, got {}", matches.len()); + }; + assert_eq!( + snapshot + .text_for_range(search_match.range.clone()) + .collect::(), + "keyboard WASD, voxel-based" + ); + } + + #[gpui::test] + fn test_exact_match_uses_trailing_newline_to_disambiguate() { + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + "foo suffix\nfoo\n", + ); + let snapshot = buffer.snapshot(); + let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); + + matcher.push("foo\n", None); + let SearchMatches::Exact(matches) = matcher.finish() else { + panic!("expected an exact match"); + }; + let [search_match] = matches.as_slice() else { + panic!("expected one match, got {}", matches.len()); + }; + assert_eq!( + snapshot + .text_for_range(search_match.range.clone()) + .collect::(), + "foo" + ); + } + + #[gpui::test] + fn test_exact_newline_only_match_excludes_line_ending() { + let buffer = TextBuffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "a\nb"); + let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone()); + + matcher.push("\n", None); + let SearchMatches::Exact(matches) = matcher.finish() else { + panic!("expected an exact match"); + }; + let [search_match] = matches.as_slice() else { + panic!("expected one match, got {}", matches.len()); + }; + assert_eq!(search_match.range, 1..1); + } + + #[gpui::test] + fn test_exact_overlapping_matches_are_ambiguous() { + let buffer = TextBuffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "aaaaa"); + let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone()); + + matcher.push("aaaa", None); + let matches = matcher.finish(); + + assert!(matches!(matches, SearchMatches::Exact(_))); + assert_eq!(match_ranges(&matches), vec![0..4, 1..5]); + } + + #[gpui::test] + fn test_exact_multiline_match_does_not_extend_incomplete_line() { + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + "prefix fragment\nnext\nnext\n", + ); + let snapshot = buffer.snapshot(); + let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); + + assert_eq!(matcher.push("fragment\nnext", None), None); + let SearchMatches::Exact(matches) = matcher.finish() else { + panic!("expected an exact match"); + }; + let [search_match] = matches.as_slice() else { + panic!("expected one match, got {}", matches.len()); + }; + assert_eq!( + snapshot + .text_for_range(search_match.range.clone()) + .collect::(), + "fragment\nnext" + ); + } + #[gpui::test] fn test_prefix_of_last_line_resolves_to_correct_range() { let text = indoc! {r#" @@ -755,26 +972,32 @@ mod tests { ); let snapshot = buffer.snapshot(); - // Query with a partial last line. + // Query with a partial last line. This is a verbatim substring of the + // buffer, so it resolves through the exact-match path. let query = "}\n\n\n\nfn render_search"; let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); matcher.push(query, None); let matches = matcher.finish(); + let matched = search_matches(&matches); // The match should include the line containing "fn render_search". - let matched_text = matches - .first() - .map(|range| snapshot.text_for_range(range.clone()).collect::()); + let matched_text = matched.first().map(|search_match| { + snapshot + .text_for_range(search_match.range.clone()) + .collect::() + }); assert!( - matches.len() == 1, + matched.len() == 1, "Expected exactly one match, got {}: {:?}", - matches.len(), + matched.len(), matched_text, ); - let matched_text = matched_text.unwrap(); + let Some(matched_text) = matched_text else { + panic!("expected a match"); + }; pretty_assertions::assert_eq!( matched_text, "}\n\n\n\nfn render_search", @@ -798,7 +1021,8 @@ mod tests { matcher.push(chunk, None); } - let actual_ranges = matcher.finish(); + let actual_matches = matcher.finish(); + let actual_ranges = match_ranges(&actual_matches); // If no expected ranges, we expect no match if expected_ranges.is_empty() { @@ -831,6 +1055,84 @@ mod tests { } } + #[test] + fn test_line_pairs_skip_unmatched_buffer_line() { + let text = indoc! {r#" + class Outer: + def method(self): + self.kept = "unchanged" + self.target_a = "before" + self.extra = "row" + self.target_b = "before" + self.target_c = "before" + self.target_d = "before" + self.kept_2 = "unchanged" + "#}; + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); + let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone()); + + // The query omits the `self.extra` row that sits between the matched + // buffer lines. + matcher.push( + concat!( + " self.target_a = \"before\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"\n", + ), + None, + ); + let SearchMatches::Fuzzy(matches) = matcher.finish() else { + panic!("expected a fuzzy match"); + }; + let [search_match] = matches.as_slice() else { + panic!("expected one match, got {}", matches.len()); + }; + assert_eq!(search_match.line_pairs, [(0, 3), (1, 5), (2, 6), (3, 7)]); + } + + #[test] + fn test_line_pairs_include_extended_incomplete_line() { + let text = indoc! {r#" + fn on_query_change(&mut self, cx: &mut Context) { + self.filter(cx); + } + + + + fn render_search(&self, cx: &mut Context) -> Div { + div() + } + "#}; + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); + let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone()); + + // The last query line is incomplete and gets appended to the match by + // `finish` via verbatim comparison rather than the fuzzy search. The + // trailing space after `}` keeps the query from being an exact + // substring of the buffer, so the fuzzy extension path is exercised. + matcher.push("} \n\n\n\nfn render_search", None); + let SearchMatches::Fuzzy(matches) = matcher.finish() else { + panic!("expected a fuzzy match"); + }; + let [search_match] = matches.as_slice() else { + panic!("expected one match, got {}", matches.len()); + }; + assert_eq!( + search_match.line_pairs, + [(0, 2), (1, 3), (2, 4), (3, 5), (4, 6)] + ); + assert_eq!(matcher.query_lines().len(), 5); + } + fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec { let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); @@ -852,11 +1154,26 @@ mod tests { .map(|range| finder.snapshot.text_for_range(range).collect::()) } + fn search_matches(matches: &SearchMatches) -> &[SearchMatch] { + match matches { + SearchMatches::Exact(matches) | SearchMatches::Fuzzy(matches) => matches, + } + } + + fn match_ranges(matches: &SearchMatches) -> Vec> { + search_matches(matches) + .iter() + .map(|search_match| search_match.range.clone()) + .collect() + } + fn finish(mut finder: StreamingFuzzyMatcher) -> Option { let snapshot = finder.snapshot.clone(); let matches = finder.finish(); - matches - .first() - .map(|range| snapshot.text_for_range(range.clone()).collect::()) + search_matches(&matches).first().map(|search_match| { + snapshot + .text_for_range(search_match.range.clone()) + .collect::() + }) } } diff --git a/crates/agent/src/tools/edit_session/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs index b210dd76e98480..62b5c5e3706f2c 100644 --- a/crates/agent/src/tools/edit_session/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -114,7 +114,7 @@ impl StreamingParser { if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done { // new_text appeared after old_text, so old_text is done — emit everything. let start = find_char_boundary(old_text, state.old_text_emitted_len); - let chunk = normalize_done_chunk(old_text[start..].to_string()); + let chunk = old_text[start..].to_string(); state.old_text_done = true; state.old_text_emitted_len = old_text.len(); events.push(EditEvent::OldTextChunk { @@ -217,12 +217,12 @@ impl StreamingParser { state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::OldTextChunk { edit_index: index, - chunk: normalize_done_chunk(edit.old_text.clone()), + chunk: edit.old_text.clone(), done: true, }); events.push(EditEvent::NewTextChunk { edit_index: index, - chunk: normalize_done_chunk(edit.new_text.clone()), + chunk: edit.new_text.clone(), done: true, }); continue; @@ -230,7 +230,7 @@ impl StreamingParser { if !state.old_text_done { let start = find_char_boundary(&edit.old_text, state.old_text_emitted_len); - let chunk = normalize_done_chunk(edit.old_text[start..].to_string()); + let chunk = edit.old_text[start..].to_string(); state.old_text_done = true; state.old_text_emitted_len = edit.old_text.len(); events.push(EditEvent::OldTextChunk { @@ -242,7 +242,7 @@ impl StreamingParser { if !state.new_text_done { let start = find_char_boundary(&edit.new_text, state.new_text_emitted_len); - let chunk = normalize_done_chunk(edit.new_text[start..].to_string()); + let chunk = edit.new_text[start..].to_string(); state.new_text_done = true; state.new_text_emitted_len = edit.new_text.len(); events.push(EditEvent::NewTextChunk { @@ -301,12 +301,12 @@ impl StreamingParser { state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::OldTextChunk { edit_index: previous_index, - chunk: normalize_done_chunk(old_text.to_string()), + chunk: old_text.to_string(), done: true, }); events.push(EditEvent::NewTextChunk { edit_index: previous_index, - chunk: normalize_done_chunk(new_text.to_string()), + chunk: new_text.to_string(), done: true, }); return Some(events); @@ -319,7 +319,7 @@ impl StreamingParser { state.old_text_emitted_len = old_text.len(); events.push(EditEvent::OldTextChunk { edit_index: previous_index, - chunk: normalize_done_chunk(old_text[start..].to_string()), + chunk: old_text[start..].to_string(), done: true, }); } @@ -332,7 +332,7 @@ impl StreamingParser { state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::NewTextChunk { edit_index: previous_index, - chunk: normalize_done_chunk(new_text[start..].to_string()), + chunk: new_text[start..].to_string(), done: true, }); } @@ -387,13 +387,6 @@ fn find_char_boundary(text: &str, target: usize) -> usize { pos } -fn normalize_done_chunk(mut chunk: String) -> String { - if chunk.ends_with('\n') { - chunk.pop(); - } - chunk -} - #[cfg(test)] mod tests { use super::*; @@ -555,7 +548,7 @@ mod tests { } #[test] - fn test_done_chunks_strip_trailing_newline() { + fn test_done_chunks_preserve_trailing_newlines() { let mut parser = StreamingParser::default(); let events = parser.finalize_edits(&[Edit { @@ -567,12 +560,12 @@ mod tests { &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "before".into(), + chunk: "before\n".into(), done: true, }, EditEvent::NewTextChunk { edit_index: 0, - chunk: "after".into(), + chunk: "after\n".into(), done: true, }, ] @@ -580,7 +573,7 @@ mod tests { } #[test] - fn test_partial_edit_chunks_hold_back_trailing_newline() { + fn test_partial_edit_preserves_trailing_newlines() { let mut parser = StreamingParser::default(); let events = parser.push_edits(&[PartialEdit { @@ -598,12 +591,12 @@ mod tests { &[ EditEvent::OldTextChunk { edit_index: 0, - chunk: "before".into(), + chunk: "before\n".into(), done: true, }, EditEvent::NewTextChunk { edit_index: 0, - chunk: "after".into(), + chunk: "after\n".into(), done: true, }, ] diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index eb690cdcdf08b3..4a8537805db40b 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -503,7 +503,9 @@ impl EditToolTest { if tool_use.is_input_complete && tool_use.name.as_ref() == EditFileTool::NAME => { - let input: EditFileToolInput = serde_json::from_value(tool_use.input) + let input: EditFileToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as EditFileToolInput")?; return Ok(input); } @@ -607,7 +609,9 @@ fn tool_use( id: LanguageModelToolUseId::from(id.into()), name: name.into(), raw_input: serde_json::to_string_pretty(&input).unwrap(), - input: serde_json::to_value(input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(input).unwrap(), + ), is_input_complete: true, thought_signature: None, }) diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index d5c3ac1ba87be1..31b35c0e539542 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -327,7 +327,9 @@ async fn extract_tool_use( Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME => { - let input: TerminalToolInput = serde_json::from_value(tool_use.input) + let input: TerminalToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as TerminalToolInput")?; return Ok(input); } diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index 3fce2b04047728..c0fb1de7de7b88 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -323,7 +323,9 @@ impl WriteToolTest { if tool_use.is_input_complete && tool_use.name.as_ref() == WriteFileTool::NAME => { - let input: WriteFileToolInput = serde_json::from_value(tool_use.input) + let input: WriteFileToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as WriteFileToolInput")?; return Ok(input); } @@ -410,7 +412,9 @@ fn tool_use( id: LanguageModelToolUseId::from(id.into()), name: name.into(), raw_input: serde_json::to_string_pretty(&input).unwrap(), - input: serde_json::to_value(input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(input).unwrap(), + ), is_input_complete: true, thought_signature: None, }) diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs index 716b4b364eed1e..028f1279d41ccc 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use std::sync::Arc; use std::{borrow::Cow, cell::RefCell}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, bail}; use futures::{AsyncReadExt as _, FutureExt as _}; use gpui::{App, AppContext as _, Task}; @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use ui::SharedString; use util::markdown::{MarkdownEscaped, MarkdownInlineCode}; +use crate::sandboxing::{NetworkRequest, SandboxRequest}; use crate::{AgentTool, ToolCallEventStream, ToolInput}; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] @@ -22,7 +23,41 @@ enum ContentType { Json, } +/// The maximum number of HTTP redirects the fetch tool will follow. Each hop is +/// re-authorized against the shared network grants before being followed. +const MAX_REDIRECTS: usize = 20; + +/// The outcome of a single (non-redirect-following) HTTP request. +enum FetchStep { + /// The server responded with a redirect to this absolute URL. Its host must + /// be authorized before the redirect is followed. + Redirect(String), + /// A terminal response was received and converted to Markdown. + Complete(String), +} + +/// Prepends `https://` when the URL has no explicit HTTP(S) scheme, matching the +/// behavior the fetch tool has always had for user/model-supplied URLs. +fn normalize_url(url: &str) -> Cow<'_, str> { + if !url.starts_with("https://") && !url.starts_with("http://") { + Cow::Owned(format!("https://{url}")) + } else { + Cow::Borrowed(url) + } +} + /// Fetches a URL and returns the content as Markdown. +/// +/// This tool is not run inside the terminal OS sandbox, but it still refuses to +/// reach any host that hasn't been granted network access. It shares the same +/// per-host grants as the `terminal` tool: approving a host for one authorizes +/// it for the other, whether the grant is for this thread or saved permanently. +/// HTTP redirects are followed one hop at a time, and each hop's host must be +/// granted the same way, so a granted host can't redirect the request to a host +/// that hasn't been approved. +/// When unsandboxed access has been granted, these restrictions are lifted +/// entirely, matching the terminal, which is also how loopback and IP-literal +/// hosts (which can't be granted individually) become reachable. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct FetchToolInput { /// The URL to fetch. @@ -38,14 +73,35 @@ impl FetchTool { Self { http_client } } - async fn build_message(http_client: Arc, url: &str) -> Result { - let url = if !url.starts_with("https://") && !url.starts_with("http://") { - Cow::Owned(format!("https://{url}")) - } else { - Cow::Borrowed(url) - }; + /// Performs a single HTTP GET *without* following redirects, so the tool can + /// re-authorize each hop against the shared network grants before following + /// it. Returns the redirect target when the server responds with a 3xx, or + /// the final content converted to Markdown otherwise. + async fn fetch_step(http_client: Arc, url: &str) -> Result { + let normalized = normalize_url(url); + + let mut response = http_client + .get(&normalized, AsyncBody::default(), false) + .await?; - let mut response = http_client.get(&url, AsyncBody::default(), true).await?; + let status = response.status(); + if status.is_redirection() { + let location = response + .headers() + .get("location") + .context("redirect response is missing a Location header")? + .to_str() + .context("redirect response has an invalid Location header")?; + let target = url::Url::parse(&normalized) + .with_context(|| format!("could not parse URL {normalized:?}"))? + .join(location) + .with_context(|| format!("invalid redirect target {location:?}"))?; + anyhow::ensure!( + matches!(target.scheme(), "http" | "https"), + "refusing to follow redirect to non-HTTP(S) URL {target}" + ); + return Ok(FetchStep::Redirect(target.to_string())); + } let mut body = Vec::new(); response @@ -54,12 +110,9 @@ impl FetchTool { .await .context("error reading response body")?; - if response.status().is_client_error() { + if status.is_client_error() { let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); + bail!("status error {}, response: {text:?}", status.as_u16()); } let Some(content_type) = response.headers().get("content-type") else { @@ -77,7 +130,7 @@ impl FetchTool { ContentType::Html }; - match content_type { + let text = match content_type { ContentType::Html => { let mut handlers: Vec = vec![ Rc::new(RefCell::new(markdown::WebpageChromeRemover)), @@ -87,7 +140,7 @@ impl FetchTool { Rc::new(RefCell::new(markdown::TableHandler::new())), Rc::new(RefCell::new(markdown::StyledTextHandler)), ]; - if url.contains("wikipedia.org") { + if normalized.contains("wikipedia.org") { use html_to_markdown::structure::wikipedia; handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); @@ -99,21 +152,65 @@ impl FetchTool { handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); } - convert_html_to_markdown(&body[..], &mut handlers) + convert_html_to_markdown(&body[..], &mut handlers)? } - ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), + ContentType::Plaintext => std::str::from_utf8(&body)?.to_owned(), ContentType::Json => { let json: serde_json::Value = serde_json::from_slice(&body)?; - Ok(format!( - "```json\n{}\n```", - serde_json::to_string_pretty(&json)? - )) + format!("```json\n{}\n```", serde_json::to_string_pretty(&json)?) } - } + }; + + Ok(FetchStep::Complete(text)) } } +/// Resolve the host of `url` and confirm it doesn't point into loopback / +/// private / link-local space, applying the same forbidden-IP policy the +/// terminal sandbox's proxy uses. Returns an error (including "resolves only to +/// forbidden addresses") that aborts the fetch. +/// +/// DNS resolution blocks, so callers should run this off the foreground thread. +/// See the caller for why this is a gate rather than a full resolve-to-connect +/// pin. +fn verify_host_not_forbidden(url: &str) -> Result<()> { + let normalized = normalize_url(url); + let parsed = + url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?; + let host = parsed + .host_str() + .with_context(|| format!("URL {url:?} has no host to reach"))?; + // Default to the scheme's port when the URL omits one; resolution needs a + // port but the value doesn't affect which IPs a host resolves to. + let port = parsed + .port_or_known_default() + .unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 }); + + http_proxy::PinnedHost::resolve(host, port).map(|_pinned| ())?; + Ok(()) +} + +/// Extracts the host from a fetch URL as a [`http_proxy::HostPattern`] so it can +/// be matched against the shared network grants. Mirrors the scheme handling in +/// [`normalize_url`] (defaulting to `https://` when none is given). +fn host_pattern_for_url(url: &str) -> Result { + let normalized = normalize_url(url); + let parsed = + url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?; + let host = parsed + .host_str() + .with_context(|| format!("URL {url:?} has no host to authorize network access for"))?; + http_proxy::HostPattern::parse(host).map_err(|error| match error { + http_proxy::HostPatternError::IpLiteral(_) => anyhow::anyhow!( + "cannot fetch {host:?}: loopback and IP-literal hosts can't be granted network \ + access individually. They are only reachable once unsandboxed access has been \ + granted (for example, via a terminal command that requests it)." + ), + error => anyhow::anyhow!("cannot authorize network access to {host:?}: {error}"), + }) +} + impl AgentTool for FetchTool { type Input = FetchToolInput; type Output = String; @@ -124,6 +221,10 @@ impl AgentTool for FetchTool { acp::ToolKind::Fetch } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -145,6 +246,8 @@ impl AgentTool for FetchTool { cx.spawn(async move |cx| { let input: FetchToolInput = input.recv().await.map_err(|e| e.to_string())?; + // First, the standard tool-permission gate (honors the fetch tool's + // allow/deny/confirm rules). let authorize = cx.update(|cx| { let context = crate::ToolPermissionContext::new(Self::NAME, vec![input.url.clone()]); @@ -155,22 +258,111 @@ impl AgentTool for FetchTool { cx, ) }); - - let fetch_task = cx.background_spawn({ - let http_client = http_client.clone(); - let url = input.url.clone(); - async move { - authorize.await?; - Self::build_message(http_client, &url).await + futures::select! { + result = authorize.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); } + }; + + // Then, unless unsandboxed access is already in effect, the per-host + // network grant shared with the terminal tool. If the host isn't + // already granted (for this thread or in saved settings) the user is + // shown the same escalation prompt the terminal uses; a denial + // aborts the fetch. This tool never runs inside the OS sandbox, so + // the grant is only consulted to decide whether the request may + // proceed. When unsandboxed access has been granted the terminal + // already runs without isolation, so we drop fetch's restrictions + // too — including reaching hosts that can't be granted individually + // (loopback and IP literals). + // + // Crucially, this authorization is applied to every redirect hop as + // well as the initial URL, so a granted host can't 30x-redirect the + // fetch to a host the user never approved. We disable the HTTP + // client's own redirect following and re-run the grant for each hop + // before requesting it. + // + // When the sandboxing feature flag is off the terminal isn't + // sandboxed either, so gating fetch by host would provide no + // isolation; skip it entirely (the pre-sandboxing behavior). + let unsandboxed = cx.update(|cx| { + !crate::sandboxing::sandboxing_enabled(cx) + || event_stream.unsandboxed_access_granted(cx) }); - let text = futures::select! { - result = fetch_task.fuse() => result.map_err(|e| e.to_string())?, - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Fetch cancelled by user".to_string()); + let mut current_url = input.url.clone(); + let mut redirects = 0; + let text = loop { + if !unsandboxed { + let host = host_pattern_for_url(¤t_url).map_err(|e| e.to_string())?; + let authorize_host = cx.update(|cx| { + let request = SandboxRequest { + network: NetworkRequest::Hosts(vec![host]), + ..Default::default() + }; + event_stream.authorize_sandbox(request, String::new(), cx) + }); + futures::select! { + result = authorize_host.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); + } + }; + + // Authorizing the *hostname* is not enough: a granted host + // (or a redirect to one) whose DNS points into loopback / + // private / link-local space would otherwise let the model + // reach the local machine or LAN (SSRF / DNS rebinding). + // Resolve and vet the host now, applying the same + // forbidden-IP policy the terminal sandbox's proxy enforces. + // + // NOTE: this is a gate, not a full pin. `HttpClientWithUrl` + // resolves the hostname again when it connects, so a DNS + // answer that flips between this check and that connect could + // still slip through. Closing that residual window would + // require the HTTP client to connect to a pre-vetted IP + // (`PinnedHost::socket_addrs`) rather than re-resolving; + // until then this blocks the realistic case of a stably + // resolving host that points at forbidden space. + let verify_task = cx.background_spawn({ + let url = current_url.clone(); + async move { verify_host_not_forbidden(&url) } + }); + futures::select! { + result = verify_task.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); + } + }; + } + + let fetch_task = cx.background_spawn({ + let http_client = http_client.clone(); + let url = current_url.clone(); + async move { Self::fetch_step(http_client, &url).await } + }); + + let step = futures::select! { + result = fetch_task.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); + } + }; + + match step { + FetchStep::Complete(text) => break text, + FetchStep::Redirect(target) => { + redirects += 1; + if redirects > MAX_REDIRECTS { + return Err(format!( + "exceeded the maximum of {MAX_REDIRECTS} redirects" + )); + } + current_url = target; + } } }; + if text.trim().is_empty() { return Err("no textual content found".to_string()); } @@ -178,3 +370,45 @@ impl AgentTool for FetchTool { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + // These use IP-literal URLs, which "resolve" to themselves, so the SSRF gate + // is exercised without depending on real DNS. IP literals can't be *granted* + // network access (that's a separate, earlier check), but they can be the + // target a granted hostname redirects to or resolves into — which is exactly + // the case this gate defends. + + #[test] + fn verify_host_rejects_loopback_literal() { + let error = verify_host_not_forbidden("http://127.0.0.1/internal") + .expect_err("loopback must be refused"); + assert!( + error.to_string().contains("loopback"), + "error should explain the forbidden range, got: {error}" + ); + } + + #[test] + fn verify_host_rejects_private_and_metadata_literals() { + for url in [ + "http://10.0.0.5/", + "https://192.168.1.1/", + "http://169.254.169.254/latest/meta-data/", // cloud metadata + "http://[::1]/", + ] { + assert!( + verify_host_not_forbidden(url).is_err(), + "expected {url} to be refused as a forbidden destination" + ); + } + } + + #[test] + fn verify_host_allows_public_literal() { + verify_host_not_forbidden("https://93.184.215.14/") + .expect("a public address must be allowed through the gate"); + } +} diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 32ab6ad4ebc2c4..eb08000d3a859e 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -1,5 +1,6 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use acp_thread::MentionUri; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use futures::FutureExt as _; use gpui::{App, AppContext, Entity, SharedString, Task}; @@ -155,10 +156,13 @@ impl AgentTool for FindPathTool { paginated_matches .iter() .map(|path| { + let uri = MentionUri::File { + abs_path: path.clone(), + }; acp::ToolCallContent::Content(acp::Content::new( acp::ContentBlock::ResourceLink(acp::ResourceLink::new( path.to_string_lossy(), - format!("file://{}", path.display()), + uri.to_uri().to_string(), )), )) }) diff --git a/crates/agent/src/tools/find_references_tool.rs b/crates/agent/src/tools/find_references_tool.rs index 8cd1cb548f2ca9..8bcf8e7e1abcbb 100644 --- a/crates/agent/src/tools/find_references_tool.rs +++ b/crates/agent/src/tools/find_references_tool.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::symbol_locator::{LocationDisplay, SymbolLocator}; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/get_code_actions_tool.rs b/crates/agent/src/tools/get_code_actions_tool.rs index 6008d68e52b121..db6db293f6af4c 100644 --- a/crates/agent/src/tools/get_code_actions_tool.rs +++ b/crates/agent/src/tools/get_code_actions_tool.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::sync::Arc; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/go_to_definition_tool.rs b/crates/agent/src/tools/go_to_definition_tool.rs index 2c217ed105a2ef..57c355d69de83e 100644 --- a/crates/agent/src/tools/go_to_definition_tool.rs +++ b/crates/agent/src/tools/go_to_definition_tool.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::symbol_locator::{LocationDisplay, SymbolLocator}; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index fba329339ed3b5..914964dbd4951a 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -1,5 +1,6 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use acp_thread::MentionUri; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::{FutureExt as _, StreamExt}; use gpui::{App, Entity, SharedString, Task}; @@ -175,7 +176,7 @@ impl AgentTool for GrepTool { let project = project.downgrade(); // Keep the search alive for the duration of result iteration. Dropping this task is the // cancellation mechanism; we intentionally do not detach it. - let SearchResults {rx, _task_handle} = results; + let SearchResults {rx, ..} = results; futures::pin_mut!(rx); let mut output = String::new(); @@ -328,10 +329,15 @@ impl AgentTool for GrepTool { output.push_str("\n```\n"); if let Some(abs_path) = &abs_path { + let uri = MentionUri::Selection { + abs_path: Some(abs_path.clone()), + line_range: range.start.row..=end_row, + column: None, + }; content.push(acp::ToolCallContent::Content(acp::Content::new( acp::ContentBlock::ResourceLink(acp::ResourceLink::new( format!("{}#{}", path.display(), line_label), - format!("file://{}#{}", abs_path.display(), line_label), + uri.to_uri().to_string(), )), ))); locations.push( @@ -411,6 +417,7 @@ mod tests { use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; + use std::path::PathBuf; use unindent::Unindent; use util::path; @@ -629,21 +636,30 @@ mod tests { .collect::>(); assert_eq!(links.len(), 2, "expected one resource link per match"); - let alpha_uri = format!("file://{}#L1", path!("/root/src/alpha.txt")); + let selection_uri = |abs_path: &str| { + MentionUri::Selection { + abs_path: Some(PathBuf::from(abs_path)), + line_range: 0..=0, + column: None, + } + .to_uri() + .to_string() + }; + + let alpha_uri = selection_uri(path!("/root/src/alpha.txt")); assert!( links.iter().any(|link| { - link.name.replace('\\', "/") == "root/src/alpha.txt#L1" - && link.uri.replace('\\', "/") == alpha_uri.replace('\\', "/") + link.name.replace('\\', "/") == "root/src/alpha.txt#L1" && link.uri == alpha_uri }), "missing clickable link for alpha.txt, got: {links:?}" ); - let beta_uri = format!("file://{}#L1", path!("/root/beta.txt")); + let beta_uri = selection_uri(path!("/root/beta.txt")); assert!( - links.iter().any(|link| { - link.name.replace('\\', "/") == "root/beta.txt#L1" - && link.uri.replace('\\', "/") == beta_uri.replace('\\', "/") - }), + links + .iter() + .any(|link| link.name.replace('\\', "/") == "root/beta.txt#L1" + && link.uri == beta_uri), "missing clickable link for beta.txt, got: {links:?}" ); diff --git a/crates/agent/src/tools/list_agents_and_models_tool.rs b/crates/agent/src/tools/list_agents_and_models_tool.rs index 5c9b2b22df4486..c4fe9c7e33c3c9 100644 --- a/crates/agent/src/tools/list_agents_and_models_tool.rs +++ b/crates/agent/src/tools/list_agents_and_models_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use gpui::{App, SharedString, Task}; use language_model::LanguageModelToolResultContent; diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs index 637c625bce0e09..5fe605176a8f1b 100644 --- a/crates/agent/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -3,7 +3,7 @@ use super::tool_permissions::{ resolve_global_skill_path, resolve_project_path, }; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, anyhow}; use fs::Fs; use futures::StreamExt as _; diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index 8156d0714a7e73..57eafc7f9c03ad 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -7,7 +7,7 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, authorize_with_sensitive_settings, decide_permission_for_paths, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index cf075d74a2954c..0c5996a6f6977f 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -1,5 +1,5 @@ use action_log::ActionLog; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, anyhow}; use futures::FutureExt as _; use gpui::{App, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/rename_tool.rs b/crates/agent/src/tools/rename_tool.rs index d05b9872b8c4c2..46e1bddc8923e9 100644 --- a/crates/agent/src/tools/rename_tool.rs +++ b/crates/agent/src/tools/rename_tool.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::sync::Arc; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use collections::HashSet; use gpui::{App, Entity, SharedString, Task}; use project::Project; diff --git a/crates/agent/src/tools/skill_tool.rs b/crates/agent/src/tools/skill_tool.rs index 24714e637c731a..92efb4596041a5 100644 --- a/crates/agent/src/tools/skill_tool.rs +++ b/crates/agent/src/tools/skill_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_skills::Skill; use anyhow::Result; use gpui::{App, AsyncApp, SharedString, Task}; @@ -704,8 +704,8 @@ mod tests { // Approve once and confirm the tool then completes successfully. auth.response .send(acp_thread::SelectedPermissionOutcome::new( - agent_client_protocol::schema::PermissionOptionId::new("allow"), - agent_client_protocol::schema::PermissionOptionKind::AllowOnce, + agent_client_protocol::schema::v1::PermissionOptionId::new("allow"), + agent_client_protocol::schema::v1::PermissionOptionKind::AllowOnce, )) .unwrap(); diff --git a/crates/agent/src/tools/spawn_agent_tool.rs b/crates/agent/src/tools/spawn_agent_tool.rs index 32650ede909042..bc871a2f2c7171 100644 --- a/crates/agent/src/tools/spawn_agent_tool.rs +++ b/crates/agent/src/tools/spawn_agent_tool.rs @@ -1,10 +1,10 @@ use acp_thread::{SUBAGENT_SESSION_INFO_META_KEY, SubagentSessionInfo}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use gpui::{App, SharedString, Task}; use language_model::LanguageModelToolResultContent; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use std::rc::Rc; use std::sync::Arc; @@ -41,11 +41,31 @@ pub struct SpawnAgentToolInput { pub label: String, /// The prompt for the agent. For new sessions, include full context needed for the task. For follow-ups (with session_id), you can rely on the agent already having the previous message. pub message: String, - /// Session ID of an existing agent session to continue instead of creating a new one. - #[serde(default)] + /// Session ID of an existing agent session to continue instead of creating a new one. Omit to create a new agent. + #[serde(default, deserialize_with = "deserialize_session_id")] pub session_id: Option, } +fn deserialize_session_id<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + + if value + .as_str() + .is_some_and(|session_id| session_id.trim().is_empty()) + { + return Ok(None); + } + + serde_json::from_value(value) + .map(Some) + .map_err(serde::de::Error::custom) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] #[serde(rename_all = "snake_case")] @@ -254,3 +274,38 @@ impl AgentTool for SpawnAgentTool { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn deserializes_blank_session_id_as_absent() { + for session_id in [json!(null), json!(""), json!(" ")] { + let input: SpawnAgentToolInput = serde_json::from_value(json!({ + "label": "label", + "message": "message", + "session_id": session_id, + })) + .unwrap(); + + assert!(input.session_id.is_none()); + } + + let input: SpawnAgentToolInput = serde_json::from_value(json!({ + "label": "label", + "message": "message", + })) + .unwrap(); + assert!(input.session_id.is_none()); + + let input: SpawnAgentToolInput = serde_json::from_value(json!({ + "label": "label", + "message": "message", + "session_id": "existing-session", + })) + .unwrap(); + assert_eq!(input.session_id.unwrap().to_string(), "existing-session"); + } +} diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 898499a55af1b5..5998153558a9c7 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::FutureExt as _; use gpui::{App, AsyncApp, Entity, SharedString, Task}; @@ -15,7 +15,10 @@ use std::{ #[cfg(any(target_os = "linux", target_os = "windows"))] use crate::SandboxFallbackDecision; -use crate::sandboxing::{NetworkRequest, sandboxing_enabled_for_project}; +use crate::sandboxing::{ + NetworkRequest, sandbox_git_dirs, sandbox_worktree_writable_paths, + sandboxing_enabled_for_project, +}; use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput}; const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; @@ -41,13 +44,14 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this: /// /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). +/// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`. /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use. /// - /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. + /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, @@ -82,13 +86,14 @@ pub struct TerminalToolInput { /// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this: /// /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). +/// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`. /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct SandboxedTerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use. /// - /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. + /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, @@ -110,26 +115,26 @@ pub struct SandboxedTerminalToolInput { /// rejected. Requesting network access triggers a user approval prompt, so /// only list hosts you expect the command to need. #[cfg_attr( - target_os = "macos", + any(target_os = "macos", target_os = "linux"), doc = "\nHost-specific access is enforced by an HTTP/HTTPS proxy, so use \ `https://` URLs rather than `git@`/`ssh://`." )] #[cfg_attr( - target_os = "linux", - doc = "\nNOTE: on Linux the sandbox cannot restrict network access to specific \ - hosts. Any value here grants the command unrestricted outbound network access \ - (exactly like `allow_all_hosts`), and the user is asked to approve access to \ - all hosts rather than the ones you list. Prefer setting `allow_all_hosts` \ - directly when per-host restriction isn't available." + target_os = "windows", + doc = "\nNOTE: on Windows the sandbox cannot currently restrict network \ + access to specific hosts. Do not set `allow_hosts` on Windows; request \ + `allow_all_hosts: true` if the command needs network access, or omit \ + network permissions entirely." )] #[serde(default)] pub allow_hosts: Vec, /// Set to `true` only if the command needs outbound network access to /// hosts you can't enumerate up front. /// - /// This grants unrestricted outbound network access. Prefer `allow_hosts` - /// with specific hostnames whenever possible, so the user knows what's - /// being approved. Requesting it triggers a user approval prompt. + /// This grants unrestricted outbound network access. On platforms that + /// support host-specific grants, prefer `allow_hosts` with specific + /// hostnames whenever possible, so the user knows what's being approved. + /// Requesting it triggers a user approval prompt. #[serde(default)] pub allow_all_hosts: Option, /// Paths the command needs to write to outside the default-writable @@ -138,19 +143,27 @@ pub struct SandboxedTerminalToolInput { #[cfg_attr( target_os = "macos", doc = "Sandboxed commands can already write to the project worktree \ - directories and a per-command temporary directory, so only list paths \ - outside those." + directories and a per-thread temporary directory (exposed via \ + `$TMPDIR`), so only list paths outside those." )] /// Provide absolute or worktree-relative paths; each /// directory grants write access to its whole subtree. Prefer this over /// `allow_fs_write_all` whenever you can enumerate the paths. Requesting - /// paths triggers a user approval prompt. + /// paths triggers a user approval prompt. Git metadata paths cannot be + /// requested and will never be made writable while sandboxed. #[cfg_attr( target_os = "linux", doc = "\nOn Linux, every path here must be a directory that already exists. \ Requesting a file, or a path that does not exist yet, is an error. To create new \ files, request write access to the existing directory that will contain them." )] + #[cfg_attr( + target_os = "windows", + doc = "\nEvery path here must be an existing directory, given as a Windows drive \ + path (`C:\\...`) or a WSL absolute path (`/...`); a path that does not exist \ + cannot be granted. To write somewhere new, request write access to the nearest \ + existing parent directory." + )] #[serde(default)] pub fs_write_paths: Vec, /// Set to `true` only when the command needs to write outside the @@ -158,21 +171,33 @@ pub struct SandboxedTerminalToolInput { /// enumerated up front. /// /// This is a broad escape hatch — prefer `fs_write_paths` whenever the - /// set of paths is known. Requesting it triggers a user approval prompt. + /// set of paths is known. Protected Git metadata remains read-only. + /// Requesting it triggers a user approval prompt. #[serde(default, alias = "allow_fs_write")] pub allow_fs_write_all: Option, + /// Set to `true` only as a last resort, to run the command fully outside /// the sandbox. /// - /// First try the narrower options (`allow_hosts`, `fs_write_paths`, - /// `allow_fs_write_all`); use this only when the command needs behavior - /// the sandbox can't grant on a per-permission basis. Requesting it - /// triggers a user approval prompt. + /// First try the narrower options (`allow_hosts`, `fs_write_paths`, or + /// `allow_fs_write_all`); use this only when the command + /// needs behavior the sandbox can't grant on a per-permission basis, + /// including commands that must write Git metadata. + /// Requesting it triggers a user approval prompt. + #[cfg_attr( + target_os = "windows", + doc = "\nOn Windows, running unsandboxed also switches the shell. Sandboxed \ + commands run under WSL's Linux bash; an unsandboxed command instead runs in the \ + host's default shell — Git Bash (or scoop's bash) when one is installed, otherwise \ + PowerShell/cmd. Path conventions change accordingly (e.g. `C:\\...` or `/c/...` \ + rather than WSL's `/mnt/c/...`), so a command written for the sandboxed shell may \ + behave differently here." + )] #[serde(default)] pub unsandboxed: Option, /// A short justification for why this command needs the sandbox - /// permission(s) it requests (`allow_network`, `fs_write_paths`, - /// `allow_fs_write_all`, or `unsandboxed`). + /// permission(s) it requests (`allow_hosts`, `allow_all_hosts`, + /// `fs_write_paths`, `allow_fs_write_all`, or `unsandboxed`). /// /// Required whenever you request any of those permissions; omit it for /// ordinary commands that request none. Write it in your own voice — it @@ -275,6 +300,10 @@ impl AgentTool for TerminalTool { acp::ToolKind::Execute } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -313,6 +342,10 @@ impl AgentTool for SandboxedTerminalTool { acp::ToolKind::Execute } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -349,6 +382,37 @@ fn terminal_initial_title(input: Result) -> SharedStr } } +/// Windows only: resolve the `(release channel, version)` of the Linux `zed` to +/// provision inside WSL as the sandbox helper. Dev (source) builds have no +/// matching release, so they pull the latest nightly. Nightly builds also track +/// `latest`: nightly assets are keyed by their full build metadata +/// (`X.Y.Z+nightly..`), which `AppVersion` strips, so a bare `X.Y.Z` +/// never resolves on the nightly host. Preview and stable pin their exact +/// running version (stripped of pre-release/build metadata, which the release +/// API doesn't key on). +#[cfg(target_os = "windows")] +fn wsl_zed_release(cx: &App) -> Option<(String, String)> { + use release_channel::{AppVersion, ReleaseChannel}; + match *release_channel::RELEASE_CHANNEL { + ReleaseChannel::Dev | ReleaseChannel::Nightly => { + Some(("nightly".to_string(), "latest".to_string())) + } + channel => { + let version = AppVersion::global(cx); + Some(( + channel.dev_name().to_string(), + format!("{}.{}.{}", version.major, version.minor, version.patch), + )) + } + } +} + +/// Non-Windows platforms don't route through WSL, so there's no helper to fetch. +#[cfg(not(target_os = "windows"))] +fn wsl_zed_release(_cx: &App) -> Option<(String, String)> { + None +} + async fn run_terminal_tool( project: Entity, environment: Rc, @@ -359,44 +423,123 @@ async fn run_terminal_tool( let selection = input.selection; let sandbox_input = input.sandbox.clone().unwrap_or_default(); - let (working_dir, authorize, sandboxing, is_local_project) = cx.update(|cx| { - let working_dir = working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; - let context = - crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); - let authorize = - event_stream.authorize(SharedString::new(input.command.clone()), context, cx); - let sandboxing = - input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); - let is_local_project = project.read(cx).is_local(); - Result::<_, String>::Ok((working_dir, authorize, sandboxing, is_local_project)) - })?; + let (working_dir, authorize, sandboxing, is_local_project, wsl_zed_release) = + cx.update(|cx| { + let working_dir = + working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; + let context = + crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); + let authorize = + event_stream.authorize(SharedString::new(input.command.clone()), context, cx); + let sandboxing = + input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); + let is_local_project = project.read(cx).is_local(); + let wsl_zed_release = wsl_zed_release(cx); + Result::<_, String>::Ok(( + working_dir, + authorize, + sandboxing, + is_local_project, + wsl_zed_release, + )) + })?; authorize.await.map_err(|e| e.to_string())?; let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true); let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true); + let want_all_hosts = sandboxing && sandbox_input.allow_all_hosts == Some(true); + + let persistent = cx.update(|cx| { + agent_settings::AgentSettings::get_global(cx) + .sandbox_permissions + .clone() + }); + + // Standing permissions the user already approved — in settings or "for this + // thread" — that every command in the thread inherits and that the model + // cannot narrow. The actually-enforced policy is always at least this + // permissive, so a request asking for something *more* restrictive would be + // silently widened to the floor and mislead the model about its real access. + // Reject such requests with an explanation instead of running them. + let floor = event_stream + .effective_sandbox_request(&crate::sandboxing::SandboxRequest::default(), &persistent); + let unsandboxed_floor = sandboxing + && (event_stream.unsandboxed_granted_for_thread() + || event_stream.sandbox_fallback_granted_for_thread()); + let fs_unrestricted_floor = sandboxing && floor.allow_fs_write_all; + let net_unrestricted_floor = sandboxing && matches!(floor.network, NetworkRequest::AnyHost); + + if sandboxing && !want_unsandboxed { + if unsandboxed_floor { + // The user turned the sandbox off for this thread, so every command + // runs without one and no sandbox-scoping field can take effect. + // Name exactly which ones the model set so it can drop them. + let mut ineffective = Vec::new(); + if !sandbox_input.allow_hosts.is_empty() { + ineffective.push("`allow_hosts`"); + } + if sandbox_input.allow_all_hosts == Some(true) { + ineffective.push("`allow_all_hosts`"); + } + if !sandbox_input.fs_write_paths.is_empty() { + ineffective.push("`fs_write_paths`"); + } + if sandbox_input.allow_fs_write_all == Some(true) { + ineffective.push("`allow_fs_write_all`"); + } + if !ineffective.is_empty() { + return Err(format!( + "Sandboxing is disabled for this thread, so every command runs without an OS \ + sandbox and these fields have no effect: {}. Remove them and rerun the \ + command (it will run unsandboxed), or pass `unsandboxed: true` to acknowledge \ + it runs without a sandbox.", + ineffective.join(", "), + )); + } + } else { + if fs_unrestricted_floor + && !want_fs_write_all + && !sandbox_input.fs_write_paths.is_empty() + { + return Err( + "Unrestricted filesystem writes are enabled for this thread, so every command \ + can already write anywhere except protected Git metadata; `fs_write_paths` \ + cannot narrow that. Remove `fs_write_paths`." + .to_string(), + ); + } + if net_unrestricted_floor && !want_all_hosts && !sandbox_input.allow_hosts.is_empty() { + return Err( + "Unrestricted network access is enabled for this thread, so every command can \ + already reach any host; `allow_hosts` cannot narrow that. Remove `allow_hosts`." + .to_string(), + ); + } + } + } // Validate the model-supplied host patterns up front. Malformed input is // the model's responsibility, so surface it back as a tool-call error // (the model retries) rather than letting the user approve a request that // then fails. - let mut network = if sandboxing && !want_unsandboxed { + let network = if sandboxing && !want_unsandboxed { build_network_request(&sandbox_input)? } else { NetworkRequest::None }; // Host-specific network access is enforced by a loopback proxy that - // confines the sandbox to its port, and only macOS can do that. A - // non-local project's terminal can't reach the proxy, and the Linux - // sandbox (bwrap) has no host-restriction mechanism at all — it can only - // allow or deny the network wholesale. Whenever per-host restriction - // isn't enforceable, widen the request to "any host" before prompting, so - // the approval the user grants matches what's actually enforced - // (unrestricted egress) rather than naming hosts we can't pin. - let can_restrict_to_hosts = cfg!(target_os = "macos") && is_local_project; - if !can_restrict_to_hosts && network.is_requested() { - network = NetworkRequest::AnyHost; + // confines the sandbox to its port. A non-local project's terminal can't + // reach the proxy, and Windows does not support this path yet. Reject the + // narrower request rather than silently widening it to all-host access. + let can_restrict_to_hosts = + (cfg!(target_os = "macos") || cfg!(target_os = "linux")) && is_local_project; + if !can_restrict_to_hosts && matches!(network, NetworkRequest::Hosts(_)) { + return Err( + "This platform or project cannot restrict sandboxed network access to specific hosts. Use `allow_all_hosts: true` if the command needs network access." + .to_string(), + ); } let write_paths: Vec = if sandboxing && !want_unsandboxed { @@ -422,14 +565,61 @@ async fn run_terminal_tool( if !path.is_dir() { return Err(format!( "Cannot request sandbox write access to `{}`: on Linux, write access can only \ - be granted to directories that already exist. To create or modify files, \ - request write access to the existing directory that contains them, not the \ + be granted to directories that already exist. To create a new directory to write \ + into, use the `create_directory` tool (which creates it and grants write access to \ + exactly that directory) rather than requesting its parent. To modify existing \ + files, request write access to the existing directory that contains them, not the \ file path itself.", path.display() )); } } + // Resolve each requested path to its canonical target now, at approval + // intake, and carry the pair forward. Persisting the resolved canonical is + // what lets enforcement rebuild the grant via a verifying reopen rather than + // re-resolving the requested path by string (closing a symlink TOCTOU). A + // path that can't be resolved is dropped — fail-closed. + #[cfg(not(target_os = "windows"))] + let write_paths: Vec = write_paths + .into_iter() + .filter_map(|requested| match sandbox::resolve_canonical(&requested) { + Ok(resolved) => Some(settings::GrantedWritePath::resolved(requested, resolved)), + Err(error) => { + log::warn!( + "could not resolve sandbox write path {}: {error}", + requested.display() + ); + None + } + }) + .collect(); + #[cfg(target_os = "windows")] + let write_paths: Vec = { + let Some(release) = wsl_zed_release.clone() else { + return Err("Could not select a Linux Zed release for WSL sandboxing".to_string()); + }; + let mut resolved_paths = Vec::with_capacity(write_paths.len()); + for requested in write_paths { + match sandbox::resolve_canonical_for_grant(requested.clone(), release.clone()).await { + Ok(resolved) => { + resolved_paths.push(settings::GrantedWritePath::resolved_on_fs( + requested, + resolved.canonical, + resolved.on_windows_fs, + )); + } + Err(error) => { + log::warn!( + "could not resolve sandbox write path {} in WSL: {error:#}", + requested.display() + ); + } + } + } + resolved_paths + }; + let request = crate::sandboxing::SandboxRequest { network, allow_fs_write_all: !want_unsandboxed && want_fs_write_all, @@ -437,6 +627,41 @@ async fn run_terminal_tool( write_paths, }; + // Before any escalation prompt: if this command's sandbox will contain a + // path on a Windows drive (DrvFs) — from an explicit grant, a standing + // grant, or the default project directory — its integrity guarantees are + // weaker. When the warning is enabled, confirm with the user first. This + // gate is transient (never persisted): on "Continue" the normal flow + // (including any escalation prompt) proceeds; on "Abort" the command is + // cancelled. It recurs until the warning is disabled in settings. + if sandboxing && !want_unsandboxed && persistent.warn_ntfs_grants { + let effective = event_stream.effective_sandbox_request(&request, &persistent); + let contains_windows_fs = effective + .write_paths + .iter() + .any(|granted| granted.on_windows_fs) + || cx.update(|cx| { + let project = project.read(cx); + working_dir + .as_deref() + .is_some_and(|path| sandbox::path_is_on_windows_drive(path)) + || sandbox_worktree_writable_paths(project, cx) + .iter() + .any(|path| sandbox::path_is_on_windows_drive(path)) + }); + if contains_windows_fs + && cx + .update(|cx| event_stream.authorize_windows_fs_warning(cx)) + .await + .is_err() + { + return Ok( + "Command cancelled: the user declined to run a command whose sandbox writes to a Windows drive." + .to_string(), + ); + } + } + if request.needs_escalation() { let reason = sandbox_input .reason @@ -450,11 +675,8 @@ async fn run_terminal_tool( .to_string(), ); }; - let title = sandbox_approval_title(&request); - let command = Some(input.command.clone()); - let approve = cx.update(|cx| { - event_stream.authorize_sandbox(title, command, request.clone(), reason.to_string(), cx) - }); + let approve = + cx.update(|cx| event_stream.authorize_sandbox(request.clone(), reason.to_string(), cx)); if let Err(error) = approve.await { if want_unsandboxed { return Ok(format!( @@ -474,45 +696,46 @@ async fn run_terminal_tool( // create the sandbox it aborts. As the consumer we may still run the command // without a sandbox (when the user has opted into that), but we record // *why* in `sandbox_not_applied` so we can warn the user and tell the agent. + // A standing "run unsandboxed for this thread" grant (any platform) and the + // Linux/Windows sandbox-creation fallbacks reassign this; on platforms + // without a sandbox integration the binding stays `None` and wouldn't need + // `mut`. + #[cfg_attr( + not(any(target_os = "macos", target_os = "linux", target_os = "windows")), + allow(unused_mut) + )] let mut sandbox_not_applied: Option = None; - let sandbox_wrap = if sandboxing && !want_unsandboxed { - let sandbox_permissions = cx.update(|cx| { - agent_settings::AgentSettings::get_global(cx) - .sandbox_permissions - .clone() - }); - if sandbox_permissions.allow_unsandboxed { - // Unsandboxed execution is permanently allowed in settings: skip - // the sandbox machinery entirely and run the command the same way - // the unsandboxed terminal tool does. - sandbox_not_applied = Some(acp_thread::SandboxNotAppliedReason::DisabledForever); - None - } else if event_stream.sandbox_fallback_granted_for_thread() { - // The user allowed unsandboxed execution for the rest of this - // thread after an earlier sandbox failure (Linux and Windows, which - // share the `authorize_sandbox_fallback` flow). - #[cfg(any(target_os = "linux", target_os = "windows"))] - { - sandbox_not_applied = - Some(acp_thread::SandboxNotAppliedReason::DisabledForThisThread); - } + let sandbox_wrap = if sandboxing && !want_unsandboxed { + if unsandboxed_floor { + // Every command in this thread runs unsandboxed because the user + // approved it — a model-requested "run unsandboxed" escape granted + // for the thread, or the sandbox-creation fallback after a failure. + // Record why so the model is told it ran without isolation. + sandbox_not_applied = Some(acp_thread::SandboxNotAppliedReason::DisabledForThisThread); None } else { - let effective = event_stream.effective_sandbox_request(&request, &sandbox_permissions); - let writable_paths: Vec = cx.update(|cx| { - project - .read(cx) - .worktrees(cx) - .map(|w| w.read(cx).abs_path().to_path_buf()) - .collect::>() + let effective = event_stream.effective_sandbox_request(&request, &persistent); + if !can_restrict_to_hosts && matches!(effective.network, NetworkRequest::Hosts(_)) { + return Err( + "This platform or project has a saved host-specific network grant, but cannot enforce host-specific sandboxed network access. Request `allow_all_hosts: true` if the command needs network access." + .to_string(), + ); + } + let (writable_paths, protected_paths) = cx.update(|cx| { + ( + sandbox_worktree_writable_paths(project.read(cx), cx), + sandbox_git_dirs(project.read(cx), cx), + ) }); let wrap = acp_thread::SandboxWrap { writable_paths, extra_write_paths: effective.write_paths, + protected_paths, network: network_request_to_sandbox_network_access(&effective.network), allow_fs_write: effective.allow_fs_write_all, is_local: is_local_project, + wsl_zed_release: wsl_zed_release.clone(), }; // The viability check runs a brief probe subprocess, so do it off @@ -529,10 +752,9 @@ async fn run_terminal_tool( let mut retries = 0usize; loop { let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); let error = match cx .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .spawn(async move { probe_wrap.can_create_sandbox() }) .await { Ok(()) => break Some(wrap), @@ -550,6 +772,7 @@ async fn run_terminal_tool( event_stream.authorize_sandbox_fallback( Some(input.command.clone()), error.user_facing_message(), + Some(error.docs_section().to_string()), retries, cx, ) @@ -578,20 +801,26 @@ async fn run_terminal_tool( #[cfg(not(target_os = "linux"))] { let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); match cx .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .spawn(async move { probe_wrap.can_create_sandbox() }) .await { Ok(()) => Some(wrap), Err(error) => { - // The probe can't fail off Linux; keep failing open just - // in case a future platform's probe ever does. + // Off Linux the probe only fails when the policy itself + // can't be built (e.g. a required write grant or `.git` + // protection no longer exists or fails its verifying + // reopen). Running the command anyway would silently + // drop access the user approved — or a safety-critical + // protection — so fail closed with the reason instead. log::warn!( "Failed to create a sandbox for an agent terminal command: {error:?}" ); - None + return Err(format!( + "Cannot create a sandbox for this command: {}", + error.user_facing_message() + )); } } } @@ -640,8 +869,11 @@ async fn run_terminal_tool( // already running unsandboxed — goes straight back to the model. let Some(message) = effective_wrap.as_ref().and_then(|_| { error - .downcast_ref::() - .map(|unavailable| unavailable.message().to_string()) + .downcast_ref::() + .and_then(|error| match error { + sandbox::SandboxError::WslUnavailable(message) => Some(message.clone()), + _ => None, + }) }) else { return Err(format!("{error:#}")); }; @@ -653,6 +885,7 @@ async fn run_terminal_tool( event_stream.authorize_sandbox_fallback( Some(input.command.clone()), sandbox_error.user_facing_message(), + Some(sandbox_error.docs_section().to_string()), retries, cx, ) @@ -698,22 +931,41 @@ async fn run_terminal_tool( // chose to run through), tell the agent so it can account for the weaker // isolation. Computed here — after the Windows fallback above may have set // the reason — so every affected command communicates the state. - let sandbox_note = sandbox_not_applied.as_ref().map(|reason| match reason { - acp_thread::SandboxNotAppliedReason::DisabledForever => { - "Note: this command ran WITHOUT an OS sandbox because unsandboxed execution is \ - enabled in settings." - .to_string() - } - acp_thread::SandboxNotAppliedReason::DisabledForThisThread => { - "Note: this command ran WITHOUT an OS sandbox because you allowed unsandboxed \ - execution for the rest of this thread." - .to_string() + let sandbox_note = sandbox_not_applied.as_ref().map(|reason| { + // Only the Windows-specific block below mutates this; on other + // platforms the note is returned exactly as built. + #[cfg_attr(not(target_os = "windows"), allow(unused_mut))] + let mut note = match reason { + acp_thread::SandboxNotAppliedReason::DisabledForThisThread => { + "Note: this command ran WITHOUT an OS sandbox because the user allowed unsandboxed \ + execution for the rest of this thread." + .to_string() + } + acp_thread::SandboxNotAppliedReason::ErrorLinuxWsl(error) => format!( + "Note: this command ran WITHOUT an OS sandbox because one could not be \ + created ({}).", + error.user_facing_message() + ), + }; + // On Windows, running without a sandbox also changes the interpreter: + // the sandboxed path runs the command under WSL's Linux shell, but + // every unsandboxed path that reaches here falls back to the host + // shell (Git Bash, or PowerShell/cmd when no bash is installed) against + // native Windows paths. The model writes commands for the WSL/Linux + // sandbox, so the loss of isolation isn't the whole story — warn it + // that the shell and path conventions differ too, or a command that + // worked sandboxed may silently misbehave or fail here. + #[cfg(target_os = "windows")] + { + note.push(' '); + note.push_str( + "It also ran under the host shell (Git Bash, or PowerShell/cmd when no bash is \ + installed) instead of WSL's Linux shell, so the interpreter and path \ + conventions differ from the sandbox: Linux-only commands and `/mnt/...` paths \ + may fail. Rewrite the command for the host shell if it doesn't work.", + ); } - acp_thread::SandboxNotAppliedReason::ErrorLinuxWsl(error) => format!( - "Note: I tried to run this command inside an OS sandbox, but it could not be \ - created ({}). It ran WITHOUT a sandbox.", - error.user_facing_message() - ), + note }); let terminal_id = terminal.id(cx).map_err(|e| e.to_string())?; @@ -772,9 +1024,11 @@ async fn run_terminal_tool( let output = terminal.current_output(cx).map_err(|e| e.to_string())?; let result = process_content(output, &input.command, timed_out, user_stopped, selection); - Ok(match sandbox_note { - Some(note) => format!("{note}\n\n{result}"), - None => result, + let notes = sandbox_note.into_iter().collect::>(); + Ok(if notes.is_empty() { + result + } else { + format!("{}\n\n{result}", notes.join("\n\n")) }) } @@ -881,22 +1135,16 @@ fn network_request_to_sandbox_network_access( NetworkRequest::None => acp_thread::SandboxNetworkAccess::None, NetworkRequest::AnyHost => acp_thread::SandboxNetworkAccess::All, NetworkRequest::Hosts(hosts) => { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] { acp_thread::SandboxNetworkAccess::Restricted(http_proxy::Allowlist::from_patterns( hosts.iter().cloned(), )) } - // Only macOS (Seatbelt plus the loopback proxy) can confine egress - // to an allowlist; other sandboxes can only toggle the network - // wholesale, so a host-specific grant becomes full network access. - // `run_terminal_tool` widens once-requests to `AnyHost` up front so - // the approval prompt matches — this guards the persistent-grant - // path (host grants restored from settings). - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { let _ = hosts; - acp_thread::SandboxNetworkAccess::All + acp_thread::SandboxNetworkAccess::None } } } @@ -928,65 +1176,6 @@ fn build_network_request(sandbox: &TerminalSandboxInput) -> Result String { - if request.unsandboxed { - return "Allow this command to run outside the sandbox?".to_string(); - } - - let mut parts: Vec = Vec::new(); - if let Some(network_clause) = network_clause(&request.network) { - parts.push(network_clause); - } - if request.allow_fs_write_all { - parts.push("unrestricted filesystem writes".to_string()); - } else if !request.write_paths.is_empty() { - parts.push(format!( - "write access to {}", - write_path_summary(&request.write_paths) - )); - } - match parts.as_slice() { - [] => "Allow this command extra permissions?".to_string(), - [only] => format!("Allow {only}?"), - [first, second] => format!("Allow {first} and {second}?"), - _ => format!("Allow {}?", parts.join(", ")), - } -} - -/// The network clause for the approval title, or `None` when no network was -/// requested. -fn network_clause(network: &NetworkRequest) -> Option { - match network { - NetworkRequest::None => None, - NetworkRequest::AnyHost => Some("arbitrary network access".to_string()), - NetworkRequest::Hosts(hosts) => Some(format_hosts_clause(hosts)), - } -} - -fn format_hosts_clause(hosts: &[http_proxy::HostPattern]) -> String { - let names: Vec = hosts.iter().map(|host| host.to_string()).collect(); - match names.as_slice() { - [] => "network access".to_string(), - [single] => format!("network access to {single}"), - [first, second] => format!("network access to {first} and {second}"), - _ => { - let (last, init) = names.split_last().expect("non-empty"); - format!("network access to {}, and {last}", init.join(", ")) - } - } -} - -fn write_path_summary(paths: &[PathBuf]) -> String { - match paths { - [] => "0 paths".to_string(), - [path] => path.display().to_string(), - paths => format!("{} paths", paths.len()), - } -} - #[derive(Clone, Copy, Debug, Default)] struct TerminalOutputSelection { head_lines: Option, @@ -2724,19 +2913,6 @@ mod tests { ); } - fn sandbox_request( - network: NetworkRequest, - all: bool, - paths: &[&str], - ) -> crate::sandboxing::SandboxRequest { - crate::sandboxing::SandboxRequest { - network, - allow_fs_write_all: all, - unsandboxed: false, - write_paths: paths.iter().map(PathBuf::from).collect(), - } - } - #[test] fn test_join_write_paths_resolves_relative_and_absolute() { let base = PathBuf::from(if cfg!(windows) { @@ -2850,26 +3026,6 @@ mod tests { assert_eq!(joined, vec![expected_escape, expected_abs]); } - #[test] - fn test_sandbox_approval_title_unsandboxed() { - let mut request = sandbox_request(NetworkRequest::AnyHost, true, &["/tmp/build"]); - request.unsandboxed = true; - assert_eq!( - sandbox_approval_title(&request), - "Allow this command to run outside the sandbox?" - ); - } - - #[test] - fn test_sandbox_approval_title_summarizes_multiple_paths_by_count() { - let title = sandbox_approval_title(&sandbox_request( - NetworkRequest::None, - false, - &["/a", "/b", "/c", "/d"], - )); - assert_eq!(title, "Allow write access to 4 paths?"); - } - #[test] fn test_input_schema_includes_sandbox_flags() { // The sandboxed terminal tool advertises these fields so the model can @@ -3062,10 +3218,9 @@ mod tests { let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); let authorization = receiver.expect_authorization().await; - assert_eq!( - authorization.tool_call.fields.title.as_deref(), - Some("Allow this command to run outside the sandbox?") - ); + // The sandbox approval deliberately leaves the tool-call title untouched + // so the card keeps showing the command being approved. + assert_eq!(authorization.tool_call.fields.title, None); let details = acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) .expect("unsandboxed should request sandbox authorization details"); @@ -3227,7 +3382,7 @@ mod tests { details .write_paths .iter() - .any(|path| path.ends_with("build")), + .any(|path| path.requested.ends_with("build")), "re-prompt should request the same write path: {:?}", details.write_paths ); @@ -3246,86 +3401,183 @@ mod tests { assert_eq!(environment2.terminal_creation_count(), 0); } - fn host_request(list: &[&str]) -> NetworkRequest { - NetworkRequest::Hosts( - list.iter() - .map(|h| http_proxy::HostPattern::parse(h).unwrap()) - .collect(), - ) + /// Set up a sandboxing-enabled, auto-allowing project for the floor- + /// enforcement tests, with the given persistent settings and thread grants. + async fn floor_test_tool( + cx: &mut gpui::TestAppContext, + persistent: agent_settings::SandboxPermissions, + grants: crate::sandboxing::ThreadSandboxGrants, + ) -> ( + std::sync::Arc, + crate::ToolCallEventStream, + crate::ToolCallEventStreamReceiver, + std::rc::Rc, + ) { + use feature_flags::FeatureFlagAppExt as _; + + crate::tests::init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["sandboxing".to_string()]); + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + settings.tool_permissions.tools.remove(TerminalTool::NAME); + settings.sandbox_permissions = persistent; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/root", serde_json::json!({})).await; + let project = project::Project::test(fs, ["/root".as_ref()], cx).await; + + let environment = std::rc::Rc::new(cx.update(|cx| { + crate::tests::FakeThreadEnvironment::default().with_terminal( + crate::tests::FakeTerminalHandle::new_with_immediate_exit(cx, 0), + ) + })); + #[allow(clippy::arc_with_non_send_sync)] + let tool = std::sync::Arc::new(SandboxedTerminalTool::new(project, environment.clone())); + let grants = std::rc::Rc::new(std::cell::RefCell::new(grants)); + let (event_stream, receiver) = crate::ToolCallEventStream::test_with_grants(grants); + (tool, event_stream, receiver, environment) } - #[test] - fn test_sandbox_approval_title_all_access_and_network() { - assert_eq!( - sandbox_approval_title(&sandbox_request(NetworkRequest::AnyHost, true, &[])), - "Allow arbitrary network access and unrestricted filesystem writes?" - ); - assert_eq!( - sandbox_approval_title(&sandbox_request(NetworkRequest::AnyHost, false, &[])), - "Allow arbitrary network access?" - ); - assert_eq!( - sandbox_approval_title(&sandbox_request(NetworkRequest::None, true, &[])), - "Allow unrestricted filesystem writes?" + /// A standing "run unsandboxed for this thread" grant makes an ordinary + /// command (one that requests no escalation) run without a sandbox, and the + /// model is told so in the output. + #[gpui::test] + async fn test_unsandboxed_thread_grant_runs_bare_command_unsandboxed( + cx: &mut gpui::TestAppContext, + ) { + let mut grants = crate::sandboxing::ThreadSandboxGrants::default(); + grants.record(&crate::sandboxing::SandboxRequest { + unsandboxed: true, + ..Default::default() + }); + let (tool, event_stream, _receiver, environment) = + floor_test_tool(cx, agent_settings::SandboxPermissions::default(), grants).await; + + let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "echo hi", + "cd": "root", + })) + .unwrap(); + let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); + let result = task.await.expect("bare command should run"); + assert_eq!(environment.terminal_creation_count(), 1); + assert!( + result.contains("WITHOUT an OS sandbox"), + "a bare command in an unsandboxed thread must run unsandboxed: {result}" ); } - #[test] - fn test_sandbox_approval_title_specific_hosts() { - assert_eq!( - sandbox_approval_title(&sandbox_request(host_request(&["github.com"]), false, &[])), - "Allow network access to github.com?" - ); - assert_eq!( - sandbox_approval_title(&sandbox_request( - host_request(&["github.com", "*.npmjs.org"]), - false, - &[] - )), - "Allow network access to github.com and *.npmjs.org?" + /// Once the thread is unsandboxed, the model must not be able to ask for a + /// scoped sandbox (it would silently run unsandboxed instead) — the call is + /// rejected so the model fixes its request. + #[gpui::test] + async fn test_unsandboxed_thread_grant_rejects_scoping_request(cx: &mut gpui::TestAppContext) { + let mut grants = crate::sandboxing::ThreadSandboxGrants::default(); + grants.record(&crate::sandboxing::SandboxRequest { + unsandboxed: true, + ..Default::default() + }); + let (tool, event_stream, _receiver, environment) = + floor_test_tool(cx, agent_settings::SandboxPermissions::default(), grants).await; + + let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "touch build/out", + "cd": "root", + "fs_write_paths": ["build"], + "allow_all_hosts": true, + "reason": "write build artifacts", + })) + .unwrap(); + let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); + let error = task + .await + .expect_err("scoping a request in an unsandboxed thread should be rejected"); + assert!( + error.contains("Sandboxing is disabled for this thread"), + "unexpected error: {error}" ); - assert_eq!( - sandbox_approval_title(&sandbox_request( - host_request(&["github.com", "npmjs.org", "pypi.org"]), - false, - &[] - )), - "Allow network access to github.com, npmjs.org, and pypi.org?" + // The error must name exactly the fields that have no effect. + assert!( + error.contains("`fs_write_paths`") && error.contains("`allow_all_hosts`"), + "error should name the ineffective fields: {error}" ); + assert_eq!(environment.terminal_creation_count(), 0); } - #[test] - fn test_sandbox_approval_title_per_path_writes() { - assert_eq!( - sandbox_approval_title(&sandbox_request( - NetworkRequest::None, - false, - &["/tmp/build"] - )), - "Allow write access to /tmp/build?" - ); - assert_eq!( - sandbox_approval_title(&sandbox_request( - host_request(&["github.com"]), - false, - &["/tmp/build"] - )), - "Allow network access to github.com and write access to /tmp/build?" + /// A persistent "allow unrestricted filesystem writes" setting makes scoping + /// writes to specific paths meaningless, so such a request is rejected. + #[gpui::test] + async fn test_unrestricted_fs_setting_rejects_scoped_write_paths( + cx: &mut gpui::TestAppContext, + ) { + let persistent = agent_settings::SandboxPermissions { + allow_fs_write_all: true, + ..Default::default() + }; + let (tool, event_stream, _receiver, environment) = floor_test_tool( + cx, + persistent, + crate::sandboxing::ThreadSandboxGrants::default(), + ) + .await; + + let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "touch build/out", + "cd": "root", + "fs_write_paths": ["build"], + "reason": "write build artifacts", + })) + .unwrap(); + let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); + let error = task + .await + .expect_err("scoping writes when FS is unrestricted should be rejected"); + assert!( + error.contains("Unrestricted filesystem writes are enabled for this thread"), + "unexpected error: {error}" ); + assert_eq!(environment.terminal_creation_count(), 0); } - #[test] - fn test_all_access_takes_precedence_over_paths_in_title() { - // When all-access is requested, the specific paths are redundant and - // should not be listed. - assert_eq!( - sandbox_approval_title(&sandbox_request( - NetworkRequest::None, - true, - &["/tmp/build"] - )), - "Allow unrestricted filesystem writes?" + /// A standing "any host" network grant makes scoping to specific hosts + /// meaningless, so such a request is rejected. + #[gpui::test] + async fn test_unrestricted_network_grant_rejects_scoped_hosts(cx: &mut gpui::TestAppContext) { + let mut grants = crate::sandboxing::ThreadSandboxGrants::default(); + grants.record(&crate::sandboxing::SandboxRequest { + network: NetworkRequest::AnyHost, + ..Default::default() + }); + let (tool, event_stream, _receiver, environment) = + floor_test_tool(cx, agent_settings::SandboxPermissions::default(), grants).await; + + let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "curl https://github.com", + "cd": "root", + "allow_hosts": ["github.com"], + "reason": "fetch from github", + })) + .unwrap(); + let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); + let error = task + .await + .expect_err("scoping hosts when network is unrestricted should be rejected"); + assert!( + error.contains("Unrestricted network access is enabled for this thread"), + "unexpected error: {error}" ); + assert_eq!(environment.terminal_creation_count(), 0); + } + + fn host_request(list: &[&str]) -> NetworkRequest { + NetworkRequest::Hosts( + list.iter() + .map(|h| http_proxy::HostPattern::parse(h).unwrap()) + .collect(), + ) } #[test] @@ -3375,17 +3627,15 @@ mod tests { other => panic!("expected unrestricted network access, got {other:?}"), } - // Only macOS can confine egress to an allowlist; other platforms can - // only toggle the network wholesale, so a host request becomes full - // access there. + // macOS and Linux confine host requests through the allowlist proxy. match network_request_to_sandbox_network_access(&host_request(&["github.com"])) { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] acp_thread::SandboxNetworkAccess::Restricted(allowlist) => { assert!(allowlist.allows("github.com")); assert!(!allowlist.allows("example.com")); } - #[cfg(not(target_os = "macos"))] - acp_thread::SandboxNetworkAccess::All => {} + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + acp_thread::SandboxNetworkAccess::None => {} other => panic!("unexpected network access for host request, got {other:?}"), } } diff --git a/crates/agent/src/tools/tool_permissions.rs b/crates/agent/src/tools/tool_permissions.rs index 5fb1ade6abcbc4..d658a0470d1d39 100644 --- a/crates/agent/src/tools/tool_permissions.rs +++ b/crates/agent/src/tools/tool_permissions.rs @@ -2,7 +2,7 @@ use crate::{ Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision, decide_permission_for_path, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_skills::is_agents_skills_path; use anyhow::{Result, anyhow}; use fs::Fs; diff --git a/crates/agent/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs index 2938cee3e1d80f..73ac052c346f32 100644 --- a/crates/agent/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use cloud_llm_client::WebSearchResponse; use futures::FutureExt as _; diff --git a/crates/agent/src/tools/write_file_tool.rs b/crates/agent/src/tools/write_file_tool.rs index 0f8d96db0e4004..5956155694710b 100644 --- a/crates/agent/src/tools/write_file_tool.rs +++ b/crates/agent/src/tools/write_file_tool.rs @@ -4,7 +4,7 @@ use super::edit_session::{ }; use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, ToolInputPayload}; use action_log::ActionLog; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use futures::FutureExt as _; use gpui::{App, AsyncApp, Entity, Task, WeakEntity}; use language::LanguageRegistry; @@ -537,10 +537,11 @@ mod tests { let rust_language = Arc::new(language::Language::new( language::LanguageConfig { name: "Rust".into(), - matcher: language::LanguageMatcher { + matcher: (language::LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, None, diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 9b9890a70d529a..b1504cd9d846df 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -1,12 +1,13 @@ use acp_thread::{ AgentConnection, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, - AgentSessionListResponse, + AgentSessionListResponse, ElicitationStore, }; use action_log::ActionLog; -use agent_client_protocol::schema::{self as acp, ErrorCode}; -use agent_client_protocol::{ - Agent, Client, ConnectionTo, JsonRpcResponse, Lines, Responder, SentRequest, +use agent_client_protocol::schema::{ + ProtocolVersion, + v1::{self as acp, ErrorCode}, }; +use agent_client_protocol::{Agent, Client, ConnectionTo, JsonRpcResponse, Lines, Responder}; use anyhow::anyhow; use async_channel; use collections::{HashMap, HashSet}; @@ -21,12 +22,11 @@ use project::agent_server_store::{ use project::{AgentId, Project}; use remote::remote_client::Interactive; use serde::Deserialize; -use settings::SettingsStore; +use settings::{AgentConfigOptionValue, SettingsStore}; use std::path::PathBuf; use std::process::{ExitStatus, Stdio}; use std::rc::Rc; use std::sync::{Arc, Mutex}; -use std::time::Duration; use std::{any::Any, cell::RefCell, collections::VecDeque}; use task::{Shell, ShellBuilder, SpawnInTerminal}; use thiserror::Error; @@ -41,12 +41,11 @@ use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; use terminal::terminal_settings::{AlternateScroll, CursorShape}; -use crate::GEMINI_ID; +use crate::{CURSOR_ID, GEMINI_ID}; pub const GEMINI_TERMINAL_AUTH_METHOD_ID: &str = "spawn-gemini-cli"; +const PARAMETERIZED_MODEL_PICKER_META_KEY: &str = "parameterizedModelPicker"; const MAX_DEBUG_BACKLOG_MESSAGES: usize = 2000; -const ACP_RESPONSE_CHANNEL_CANCELLED: &str = - "response channel cancelled — connection may have dropped"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AcpDebugMessageDirection { @@ -82,17 +81,30 @@ pub struct AcpDebugMessage { } impl AcpDebugMessage { - fn parse(direction: AcpDebugMessageDirection, line: &str) -> Option { + fn parse_line(direction: AcpDebugMessageDirection, line: &str) -> Vec { if direction == AcpDebugMessageDirection::Stderr { - return Some(Self { + return vec![Self { direction, message: AcpDebugMessageContent::Stderr { line: Arc::from(line), }, - }); + }]; + } + + let Ok(value) = serde_json::from_str(line) else { + return Vec::new(); + }; + + match value { + serde_json::Value::Array(entries) => entries + .into_iter() + .filter_map(|entry| Self::parse_value(direction, entry)) + .collect(), + value => Self::parse_value(direction, value).into_iter().collect(), } + } - let value: serde_json::Value = serde_json::from_str(line).ok()?; + fn parse_value(direction: AcpDebugMessageDirection, value: serde_json::Value) -> Option { let object = value.as_object()?; let parsed_id = object @@ -179,26 +191,29 @@ impl AcpDebugLog { } fn record_line(&self, direction: AcpDebugMessageDirection, line: &str) { - let Some(message) = AcpDebugMessage::parse(direction, line) else { + let messages = AcpDebugMessage::parse_line(direction, line); + if messages.is_empty() { return; - }; - self.record_message(message); + } + self.record_messages(messages); } - fn record_message(&self, message: AcpDebugMessage) { + fn record_messages(&self, messages: Vec) { let mut state = self .state .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if state.messages.len() == MAX_DEBUG_BACKLOG_MESSAGES { - state.messages.pop_front(); - } - state.messages.push_back(message.clone()); - state.subscribers.retain(|sender| !sender.is_closed()); - for sender in &state.subscribers { - sender.try_send(message.clone()).log_err(); + for message in messages { + if state.messages.len() == MAX_DEBUG_BACKLOG_MESSAGES { + state.messages.pop_front(); + } + state.messages.push_back(message.clone()); + + for sender in &state.subscribers { + sender.try_send(message.clone()).log_err(); + } } } @@ -231,35 +246,6 @@ fn exited_load_error_with_stderr(status: ExitStatus, debug_log: &AcpDebugLog) -> } } -/// Awaits the response to an ACP request from a GPUI foreground task. -/// -/// The ACP SDK offers two ways to consume a [`SentRequest`]: -/// - [`SentRequest::block_task`]: linear `.await` inside a spawned task. -/// - [`SentRequest::on_receiving_result`]: a callback invoked when the -/// response arrives, with the guarantee that no other inbound messages -/// are processed while the callback runs. This is the recommended form -/// inside SDK handler callbacks, where [`block_task`] would deadlock. -/// -/// We use `on_receiving_result` with a oneshot bridge here (rather than -/// [`block_task`]) so that our handler-side code paths can share a single -/// request-awaiting helper. The SDK callback itself is trivial (one channel -/// send) so the extra ordering guarantee it imposes on the dispatch loop is -/// negligible. -fn into_foreground_future( - sent: SentRequest, -) -> impl Future> { - let (tx, rx) = futures::channel::oneshot::channel(); - let spawn_result = sent.on_receiving_result(async move |result| { - tx.send(result).ok(); - Ok(()) - }); - async move { - spawn_result?; - rx.await - .map_err(|_| acp::Error::internal_error().data(ACP_RESPONSE_CHANNEL_CANCELLED))? - } -} - #[derive(Debug, Error)] #[error("Unsupported version")] pub struct UnsupportedVersion; @@ -299,6 +285,7 @@ impl FlattenAcpResult for Result, anyhow::Error> { struct ClientContext { sessions: Rc>>, session_list: Rc>>>, + request_elicitations: Entity, } fn dispatch_queue_closed_error() -> acp::Error { @@ -351,7 +338,6 @@ where Notif: Send + 'static, { notification: Notif, - connection: ConnectionTo, handler: fn(Notif, &mut AsyncApp, &ClientContext), } @@ -363,17 +349,12 @@ where let Self { notification, handler, - .. } = *self; handler(notification, cx, ctx); } fn reject(self: Box) { - let Self { connection, .. } = *self; log::error!("ACP foreground dispatch queue closed while handling inbound notification"); - connection - .send_error_notification(dispatch_queue_closed_error()) - .log_err(); } } @@ -399,14 +380,12 @@ fn enqueue_request( fn enqueue_notification( dispatch_tx: &mpsc::UnboundedSender, notification: Notif, - connection: ConnectionTo, handler: fn(Notif, &mut AsyncApp, &ClientContext), ) where Notif: Send + 'static, { let work: ForegroundWork = Box::new(NotificationForegroundWork { notification, - connection, handler, }); if let Err(err) = dispatch_tx.unbounded_send(work) { @@ -424,6 +403,7 @@ pub struct AcpConnection { auth_methods: Vec, agent_server_store: WeakEntity, agent_capabilities: acp::AgentCapabilities, + request_elicitations: Entity, defaults: AcpConnectionDefaults, child: Option, session_list: Option>, @@ -446,11 +426,14 @@ pub struct AcpConnection { #[derive(Clone, Default)] struct AcpConnectionDefaults { mode: Rc>>, - config_options: Rc>>, + config_options: Rc>>, } impl AcpConnectionDefaults { - fn new(mode: Option, config_options: HashMap) -> Self { + fn new( + mode: Option, + config_options: HashMap, + ) -> Self { Self { mode: Rc::new(RefCell::new(mode)), config_options: Rc::new(RefCell::new(config_options)), @@ -461,11 +444,15 @@ impl AcpConnectionDefaults { self.mode.borrow().clone() } - fn config_option(&self, config_id: &str) -> Option { + fn config_option(&self, config_id: &str) -> Option { self.config_options.borrow().get(config_id).cloned() } - fn set(&self, mode: Option, config_options: HashMap) { + fn set( + &self, + mode: Option, + config_options: HashMap, + ) { *self.mode.borrow_mut() = mode; *self.config_options.borrow_mut() = config_options; } @@ -588,7 +575,9 @@ impl AgentSessionList for AcpSessionList { let acp_request = acp::ListSessionsRequest::new() .cwd(request.cwd) .cursor(request.cursor); - let response = into_foreground_future(conn.send_request(acp_request)) + let response = conn + .send_request(acp_request) + .block_task() .await .map_err(map_acp_error)?; Ok(AgentSessionListResponse { @@ -630,7 +619,8 @@ impl AgentSessionList for AcpSessionList { let updates_tx = self.updates_tx.clone(); let session_id = session_id.clone(); cx.foreground_executor().spawn(async move { - into_foreground_future(conn.send_request(acp::DeleteSessionRequest::new(session_id))) + conn.send_request(acp::DeleteSessionRequest::new(session_id)) + .block_task() .await .map_err(map_acp_error)?; updates_tx @@ -662,7 +652,7 @@ pub async fn connect( command: AgentServerCommand, agent_server_store: WeakEntity, default_mode: Option, - default_config_options: HashMap, + default_config_options: HashMap, cx: &mut AsyncApp, ) -> Result> { let conn = AcpConnection::stdio( @@ -678,7 +668,7 @@ pub async fn connect( Ok(Rc::new(conn) as _) } -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1; +const MINIMUM_SUPPORTED_VERSION: ProtocolVersion = ProtocolVersion::V1; /// Build a `Client` connection over `transport` with Zed's full /// agent→client handler set wired up. @@ -711,8 +701,8 @@ fn connect_client_future( macro_rules! on_notification { ($handler:ident) => {{ let dispatch_tx = dispatch_tx.clone(); - async move |notif, connection| { - enqueue_notification(&dispatch_tx, notif, connection, $handler); + async move |notif, _connection| { + enqueue_notification(&dispatch_tx, notif, $handler); Ok(()) } }}; @@ -754,11 +744,19 @@ fn connect_client_future( on_request!(handle_wait_for_terminal_exit), agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + on_request!(handle_create_elicitation), + agent_client_protocol::on_receive_request!(), + ) // --- Notification handlers (agent→client) --- .on_receive_notification( on_notification!(handle_session_notification), agent_client_protocol::on_receive_notification!(), ) + .on_receive_notification( + on_notification!(handle_complete_elicitation), + agent_client_protocol::on_receive_notification!(), + ) .connect_with( transport, move |connection: ConnectionTo| async move { @@ -790,6 +788,36 @@ impl Drop for SessionCreationGuard { } } +fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities { + let mut meta = acp::Meta::from_iter([ + ("terminal_output".into(), true.into()), + ("terminal-auth".into(), true.into()), + ]); + + if agent_id.as_ref() == CURSOR_ID { + meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into()); + } + + acp::ClientCapabilities::new() + .fs(acp::FileSystemCapabilities::new() + .read_text_file(true) + .write_text_file(true)) + .terminal(true) + .auth(acp::AuthCapabilities::new().terminal(true)) + .session( + acp::ClientSessionCapabilities::new().config_options( + acp::SessionConfigOptionsCapabilities::new() + .boolean(acp::BooleanConfigOptionCapabilities::new()), + ), + ) + .elicitation( + acp::ElicitationCapabilities::new() + .form(acp::ElicitationFormCapabilities::new()) + .url(acp::ElicitationUrlCapabilities::new()), + ) + .meta(meta) +} + impl AcpConnection { pub fn subscribe_debug_messages( &self, @@ -853,7 +881,7 @@ impl AcpConnection { command: AgentServerCommand, agent_server_store: WeakEntity, default_mode: Option, - default_config_options: HashMap, + default_config_options: HashMap, cx: &mut AsyncApp, ) -> Result { let root_dir = project.read_with(cx, |project, cx| { @@ -928,6 +956,7 @@ impl AcpConnection { let client_session_list: Rc>>> = Rc::new(RefCell::new(None)); + let request_elicitations = cx.new(|_| ElicitationStore::default()); // Set up the foreground dispatch channel for bridging Send handler // closures to the !Send foreground thread. @@ -1017,6 +1046,7 @@ impl AcpConnection { let dispatch_context = ClientContext { sessions: sessions.clone(), session_list: client_session_list.clone(), + request_elicitations: request_elicitations.clone(), }; let dispatch_task = cx.spawn({ let mut dispatch_rx = dispatch_rx; @@ -1027,42 +1057,24 @@ impl AcpConnection { } }); - let initialize_response = into_foreground_future( - connection.send_request( - acp::InitializeRequest::new(acp::ProtocolVersion::V1) - .client_capabilities( - acp::ClientCapabilities::new() - .fs(acp::FileSystemCapabilities::new() - .read_text_file(true) - .write_text_file(true)) - .terminal(true) - .auth(acp::AuthCapabilities::new().terminal(true)) - .meta(acp::Meta::from_iter([ - ("terminal_output".into(), true.into()), - ("terminal-auth".into(), true.into()), - ])), - ) + let initialize_response = connection + .send_request( + acp::InitializeRequest::new(ProtocolVersion::V1) + .client_capabilities(client_capabilities_for_agent(&agent_id)) .client_info( acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), ), - ), - ) - .boxed_local(); + ) + .block_task() + .boxed_local(); let (response, status_fut) = match futures::future::select(initialize_response, status_fut).await { futures::future::Either::Left((Ok(response), status_fut)) => (response, status_fut), futures::future::Either::Left((Err(error), status_fut)) => { - let response_channel_cancelled = error.code == ErrorCode::InternalError - && error.data.as_ref().and_then(|data| data.as_str()) - == Some(ACP_RESPONSE_CHANNEL_CANCELLED); - if !response_channel_cancelled { - return Err(error.into()); - } - let timer = cx .background_executor() - .timer(Duration::from_millis(250)) + .timer(std::time::Duration::from_millis(250)) .boxed_local(); if let futures::future::Either::Left((load_error, _timer)) = futures::future::select(status_fut, timer).await @@ -1157,6 +1169,7 @@ impl AcpConnection { sessions, pending_sessions: Rc::new(RefCell::new(HashMap::default())), agent_capabilities: response.agent_capabilities, + request_elicitations, defaults, session_list, debug_log, @@ -1179,6 +1192,7 @@ impl AcpConnection { connection: ConnectionTo, sessions: Rc>>, agent_capabilities: acp::AgentCapabilities, + request_elicitations: Entity, agent_server_store: WeakEntity, io_task: Task<()>, dispatch_task: Task<()>, @@ -1198,6 +1212,7 @@ impl AcpConnection { auth_methods: vec![], agent_server_store, agent_capabilities, + request_elicitations, defaults, child: None, session_list: None, @@ -1385,31 +1400,51 @@ impl AcpConnection { .filter_map(|config_option| { let default_value = self.defaults.config_option(config_option.id.0.as_ref())?; - let is_valid = match &config_option.kind { - acp::SessionConfigKind::Select(select) => match &select.options { - acp::SessionConfigSelectOptions::Ungrouped(options) => options - .iter() - .any(|opt| &*opt.value.0 == default_value.as_str()), - acp::SessionConfigSelectOptions::Grouped(groups) => { - groups.iter().any(|g| { - g.options - .iter() - .any(|opt| &*opt.value.0 == default_value.as_str()) - }) + let value_to_apply = match &config_option.kind { + acp::SessionConfigKind::Select(select) => { + let value_id = default_value.as_value_id()?; + match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .any(|opt| &*opt.value.0 == value_id) + .then(|| { + acp::SessionConfigOptionValue::value_id( + value_id.to_string(), + ) + }), + acp::SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .any(|group| { + group.options.iter().any(|opt| &*opt.value.0 == value_id) + }) + .then(|| { + acp::SessionConfigOptionValue::value_id( + value_id.to_string(), + ) + }), + _ => None, } - _ => false, - }, - _ => false, + } + acp::SessionConfigKind::Boolean(_) => default_value + .as_bool() + .map(acp::SessionConfigOptionValue::boolean), + _ => None, }; - if is_valid { + if let Some(value_to_apply) = value_to_apply { let initial_value = match &config_option.kind { acp::SessionConfigKind::Select(select) => { - Some(select.current_value.clone()) + acp::SessionConfigOptionValue::value_id( + select.current_value.clone(), + ) } - _ => None, + acp::SessionConfigKind::Boolean(boolean) => { + acp::SessionConfigOptionValue::boolean(boolean.current_value) + } + _ => return None, }; - Some((config_option.id.clone(), default_value, initial_value)) + + Some((config_option.id.clone(), value_to_apply, initial_value)) } else { log::warn!( "`{}` is not a valid value for config option `{}` in {}", @@ -1425,29 +1460,39 @@ impl AcpConnection { for (config_id, default_value, initial_value) in defaults_to_apply { cx.spawn({ - let default_value_id = acp::SessionConfigValueId::new(default_value.clone()); + let default_value_for_request = default_value.clone(); let session_id = session_id.clone(); let config_id_clone = config_id.clone(); let config_opts = config_options.clone(); let conn = self.connection.clone(); async move |_| { - let result = into_foreground_future(conn.send_request( - acp::SetSessionConfigOptionRequest::new( + let result = conn + .send_request(acp::SetSessionConfigOptionRequest::new( session_id, config_id_clone.clone(), - default_value_id, - ), - )) - .await - .log_err(); + default_value_for_request, + )) + .block_task() + .await + .log_err(); if result.is_none() { - if let Some(initial) = initial_value { - let mut opts = config_opts.borrow_mut(); - if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) { - if let acp::SessionConfigKind::Select(select) = &mut opt.kind { - select.current_value = initial; + let mut opts = config_opts.borrow_mut(); + if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) { + match (&mut opt.kind, &initial_value) { + ( + acp::SessionConfigKind::Select(select), + acp::SessionConfigOptionValue::ValueId { value }, + ) => { + select.current_value = value.clone(); } + ( + acp::SessionConfigKind::Boolean(boolean), + acp::SessionConfigOptionValue::Boolean { value }, + ) => { + boolean.current_value = *value; + } + _ => {} } } } @@ -1457,8 +1502,20 @@ impl AcpConnection { let mut opts = config_options.borrow_mut(); if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id) { - if let acp::SessionConfigKind::Select(select) = &mut opt.kind { - select.current_value = acp::SessionConfigValueId::new(default_value); + match (&mut opt.kind, &default_value) { + ( + acp::SessionConfigKind::Select(select), + acp::SessionConfigOptionValue::ValueId { value }, + ) => { + select.current_value = value.clone(); + } + ( + acp::SessionConfigKind::Boolean(boolean), + acp::SessionConfigOptionValue::Boolean { value }, + ) => { + boolean.current_value = *value; + } + _ => {} } } } @@ -1653,11 +1710,10 @@ impl AgentConnection for AcpConnection { let _slot_guard = slot_guard; prev_chain.await; - let response = into_foreground_future( - self.connection.send_request( - directories.into_new_session_request(mcp_servers), - ), - ) + let response = self + .connection + .send_request(directories.into_new_session_request(mcp_servers)) + .block_task() .await .map_err(map_acp_error)?; @@ -1681,12 +1737,12 @@ impl AgentConnection for AcpConnection { let modes = modes.clone(); let conn = self.connection.clone(); async move |_| { - let result = into_foreground_future( - conn.send_request(acp::SetSessionModeRequest::new( + let result = conn + .send_request(acp::SetSessionModeRequest::new( session_id, default_mode, - )), - ) + )) + .block_task() .await .log_err(); @@ -1790,11 +1846,13 @@ impl AgentConnection for AcpConnection { title, move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future(connection.send_request( - directories.into_load_session_request(session_id.clone(), mcp_servers), - )) - .await - .map_err(map_acp_error)?; + let response = connection + .send_request( + directories.into_load_session_request(session_id.clone(), mcp_servers), + ) + .block_task() + .await + .map_err(map_acp_error)?; Ok(SessionConfigResponse { modes: response.modes, config_options: response.config_options, @@ -1832,11 +1890,14 @@ impl AgentConnection for AcpConnection { title, move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future(connection.send_request( - directories.into_resume_session_request(session_id.clone(), mcp_servers), - )) - .await - .map_err(map_acp_error)?; + let response = connection + .send_request( + directories + .into_resume_session_request(session_id.clone(), mcp_servers), + ) + .block_task() + .await + .map_err(map_acp_error)?; Ok(SessionConfigResponse { modes: response.modes, config_options: response.config_options, @@ -1884,10 +1945,9 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let session_id = session_id.clone(); return cx.foreground_executor().spawn(async move { - into_foreground_future( - conn.send_request(acp::CloseSessionRequest::new(session_id)), - ) - .await?; + conn.send_request(acp::CloseSessionRequest::new(session_id)) + .block_task() + .await?; Ok(()) }); } @@ -1911,10 +1971,9 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let session_id = session_id.clone(); cx.foreground_executor().spawn(async move { - into_foreground_future( - conn.send_request(acp::CloseSessionRequest::new(session_id.clone())), - ) - .await?; + conn.send_request(acp::CloseSessionRequest::new(session_id.clone())) + .block_task() + .await?; Ok(()) }) } @@ -1939,7 +1998,8 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let session_id = session_id.clone(); cx.foreground_executor().spawn(async move { - into_foreground_future(conn.send_request(acp::CloseSessionRequest::new(session_id))) + conn.send_request(acp::CloseSessionRequest::new(session_id)) + .block_task() .await?; Ok(()) }) @@ -1989,7 +2049,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 { - into_foreground_future(conn.send_request(acp::AuthenticateRequest::new(method_id))) + conn.send_request(acp::AuthenticateRequest::new(method_id)) + .block_task() .await?; Ok(()) }) @@ -2006,14 +2067,15 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); cx.foreground_executor().spawn(async move { - into_foreground_future(conn.send_request(acp::LogoutRequest::new())).await?; + conn.send_request(acp::LogoutRequest::new()) + .block_task() + .await?; Ok(()) }) } fn prompt( &self, - _id: acp_thread::UserMessageId, params: acp::PromptRequest, cx: &mut App, ) -> Task> { @@ -2021,7 +2083,7 @@ impl AgentConnection for AcpConnection { let sessions = self.sessions.clone(); let session_id = params.session_id.clone(); cx.foreground_executor().spawn(async move { - let result = into_foreground_future(conn.send_request(params)).await; + let result = conn.send_request(params).block_task().await; let mut suppress_abort_err = false; @@ -2080,6 +2142,10 @@ impl AgentConnection for AcpConnection { self.connection.send_notification(params).log_err(); } + fn request_elicitations(&self) -> Option> { + Some(self.request_elicitations.clone()) + } + fn session_modes( &self, session_id: &acp::SessionId, @@ -2149,8 +2215,8 @@ pub mod test_support { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use acp_thread::{ - AgentSessionConfigOptions, AgentSessionModes, AgentSessionRetry, AgentSessionSetTitle, - AgentSessionTruncate, AgentTelemetry, UserMessageId, + AgentSessionClientUserMessageIds, AgentSessionConfigOptions, AgentSessionModes, + AgentSessionRetry, AgentSessionSetTitle, AgentSessionTruncate, AgentTelemetry, }; use super::*; @@ -2160,6 +2226,10 @@ pub mod test_support { load_session_count: Arc, close_session_count: Arc, fail_next_prompt: Arc, + auth_elicitation_request: Arc>>, + auth_elicitation_response: + Arc>>>, + auth_elicitation_completion: Arc>>, exit_status_sender: Arc>>>, } @@ -2192,6 +2262,23 @@ pub mod test_support { pub fn fail_next_prompt(&self) { self.fail_next_prompt.store(true, Ordering::SeqCst); } + + pub fn request_elicitation_during_auth( + &self, + request: acp::CreateElicitationRequest, + ) -> async_channel::Receiver { + let (response_tx, response_rx) = async_channel::bounded(1); + *self + .auth_elicitation_request + .lock() + .expect("auth elicitation request lock should not be poisoned") = Some(request); + *self + .auth_elicitation_response + .lock() + .expect("auth elicitation response lock should not be poisoned") = + Some(response_tx); + response_rx + } } impl crate::AgentServer for FakeAcpAgentServer { @@ -2212,6 +2299,9 @@ pub mod test_support { let load_session_count = self.load_session_count.clone(); let close_session_count = self.close_session_count.clone(); let fail_next_prompt = self.fail_next_prompt.clone(); + let auth_elicitation_request = self.auth_elicitation_request.clone(); + let auth_elicitation_response = self.auth_elicitation_response.clone(); + let auth_elicitation_completion = self.auth_elicitation_completion.clone(); let exit_status_sender = self.exit_status_sender.clone(); cx.spawn(async move |cx| { let harness = build_fake_acp_connection( @@ -2219,6 +2309,9 @@ pub mod test_support { load_session_count, close_session_count, fail_next_prompt, + auth_elicitation_request, + auth_elicitation_response, + auth_elicitation_completion, cx, ) .await?; @@ -2371,13 +2464,19 @@ pub mod test_support { self.inner.logout(cx) } + fn client_user_message_ids( + &self, + cx: &App, + ) -> Option> { + self.inner.client_user_message_ids(cx) + } + fn prompt( &self, - user_message_id: UserMessageId, params: acp::PromptRequest, cx: &mut App, ) -> Task> { - self.inner.prompt(user_message_id, params, cx) + self.inner.prompt(params, cx) } fn retry( @@ -2392,6 +2491,10 @@ pub mod test_support { self.inner.cancel(session_id, cx) } + fn request_elicitations(&self) -> Option> { + self.inner.request_elicitations() + } + fn truncate( &self, session_id: &acp::SessionId, @@ -2442,6 +2545,11 @@ pub mod test_support { load_session_count: Arc, close_session_count: Arc, fail_next_prompt: Arc, + auth_elicitation_request: Arc>>, + auth_elicitation_response: Arc< + Mutex>>, + >, + auth_elicitation_completion: Arc>>, cx: &mut AsyncApp, ) -> Result { let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); @@ -2471,8 +2579,41 @@ pub mod test_support { agent_client_protocol::on_receive_request!(), ) .on_receive_request( - async move |_req: acp::AuthenticateRequest, responder, _cx| { - responder.respond(Default::default()) + { + let auth_elicitation_request = auth_elicitation_request.clone(); + let auth_elicitation_response = auth_elicitation_response.clone(); + let auth_elicitation_completion = auth_elicitation_completion.clone(); + async move |_req: acp::AuthenticateRequest, responder, cx| { + let request = auth_elicitation_request + .lock() + .expect("auth elicitation request lock should not be poisoned") + .take(); + let response_tx = auth_elicitation_response + .lock() + .expect("auth elicitation response lock should not be poisoned") + .take(); + let completion = auth_elicitation_completion + .lock() + .expect("auth elicitation completion lock should not be poisoned") + .take(); + + if let Some(request) = request { + cx.send_request(request) + .on_receiving_result(async move |result| { + if let (Ok(response), Some(response_tx)) = (result, response_tx) + { + response_tx.send(response).await.ok(); + } + responder.respond(Default::default()) + })?; + if let Some(completion) = completion { + cx.send_notification(completion)?; + } + Ok(()) + } else { + responder.respond(Default::default()) + } + } }, agent_client_protocol::on_receive_request!(), ) @@ -2553,16 +2694,18 @@ pub mod test_support { .await .context("failed to receive fake ACP connection handle")?; - let response = into_foreground_future( - client_conn.send_request(acp::InitializeRequest::new(acp::ProtocolVersion::V1)), - ) - .await?; + let response = client_conn + .send_request(acp::InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await?; let agent_capabilities = response.agent_capabilities; + let request_elicitations = cx.new(|_| ElicitationStore::default()); let dispatch_context = ClientContext { sessions: sessions.clone(), session_list: client_session_list.clone(), + request_elicitations: request_elicitations.clone(), }; let dispatch_task = cx.spawn({ let mut dispatch_rx = dispatch_rx; @@ -2581,6 +2724,7 @@ pub mod test_support { client_conn, sessions, agent_capabilities, + request_elicitations, agent_server_store, client_io_task, dispatch_task, @@ -2616,11 +2760,77 @@ pub mod test_support { Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), &mut cx.to_async(), ) .await .expect("failed to initialize ACP connection") } + + #[cfg(test)] + pub async fn connect_fake_acp_connection_with_auth_elicitation( + project: Entity, + request: acp::CreateElicitationRequest, + cx: &mut gpui::TestAppContext, + ) -> ( + FakeAcpConnectionHarness, + async_channel::Receiver, + ) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + }); + + let (response_tx, response_rx) = async_channel::bounded(1); + let harness = build_fake_acp_connection( + project, + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(Some(request))), + Arc::new(Mutex::new(Some(response_tx))), + Arc::new(Mutex::new(None)), + &mut cx.to_async(), + ) + .await + .expect("failed to initialize ACP connection"); + + (harness, response_rx) + } + + #[cfg(test)] + pub async fn connect_fake_acp_connection_with_auth_elicitation_completion( + project: Entity, + request: acp::CreateElicitationRequest, + completion: acp::CompleteElicitationNotification, + cx: &mut gpui::TestAppContext, + ) -> ( + FakeAcpConnectionHarness, + async_channel::Receiver, + ) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + }); + + let (response_tx, response_rx) = async_channel::bounded(1); + let harness = build_fake_acp_connection( + project, + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(Some(request))), + Arc::new(Mutex::new(Some(response_tx))), + Arc::new(Mutex::new(Some(completion))), + &mut cx.to_async(), + ) + .await + .expect("failed to initialize ACP connection"); + + (harness, response_rx) + } } #[cfg(test)] @@ -2628,8 +2838,352 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; + use feature_flags::FeatureFlag as _; use settings::Settings as _; + fn init_feature_flags_test(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let mut settings_store = SettingsStore::test(cx); + settings_store.register_setting::(); + cx.set_global(settings_store); + cx.update_flags(false, vec![]); + }); + } + + #[gpui::test] + async fn client_capabilities_include_elicitation_without_acp_beta( + cx: &mut gpui::TestAppContext, + ) { + init_feature_flags_test(cx); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let elicitation = capabilities + .elicitation + .expect("elicitation should always be advertised"); + + assert!(elicitation.form.is_some()); + assert!(elicitation.url.is_some()); + } + + #[gpui::test] + async fn request_scoped_elicitation_during_auth_uses_connection_store( + cx: &mut gpui::TestAppContext, + ) { + init_feature_flags_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + + let request_id = acp::RequestId::Number(1); + let (harness, response_rx) = + test_support::connect_fake_acp_connection_with_auth_elicitation( + project, + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(request_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .await; + let connection = harness.connection.clone(); + let auth_task = + cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx)); + cx.run_until_parked(); + + let store = connection + .request_elicitations() + .expect("ACP connections expose request-scoped elicitations"); + let elicitation_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one request-scoped elicitation, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, request_id); + elicitation.id.clone() + }); + assert!( + connection.sessions.borrow().is_empty(), + "auth-time request-scoped elicitations must not require a session" + ); + + let expected_content = std::collections::BTreeMap::from([( + "name".to_string(), + acp::ElicitationContentValue::from("Ada"), + )]); + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(expected_content.clone()), + )), + cx, + ); + }); + + let response = response_rx + .recv() + .await + .expect("fake auth flow should receive elicitation response"); + assert_eq!( + response.action, + acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(expected_content) + ) + ); + auth_task.await.expect("auth should complete"); + } + + #[gpui::test] + async fn request_scoped_url_elicitation_completion_before_consent_is_ignored( + cx: &mut gpui::TestAppContext, + ) { + init_feature_flags_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + + let request_id = acp::RequestId::Number(1); + let url_elicitation_id = acp::ElicitationId::new("auth-url"); + let (harness, response_rx) = + test_support::connect_fake_acp_connection_with_auth_elicitation_completion( + project, + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationRequestScope::new(request_id.clone()), + url_elicitation_id.clone(), + "https://auth.example.com/device", + ), + "Authorize Zed in your browser", + ), + acp::CompleteElicitationNotification::new(url_elicitation_id), + cx, + ) + .await; + let connection = harness.connection.clone(); + let auth_task = + cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx)); + cx.run_until_parked(); + + assert!( + matches!( + response_rx.try_recv(), + Err(async_channel::TryRecvError::Empty) + ), + "completion before consent must not answer the elicitation request" + ); + + let store = connection + .request_elicitations() + .expect("ACP connections expose request-scoped elicitations"); + let entry_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one request-scoped elicitation, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, request_id); + assert!(matches!( + elicitation.status, + acp_thread::ElicitationStatus::Pending { .. } + )); + elicitation.id.clone() + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + let response = response_rx + .recv() + .await + .expect("fake auth flow should receive elicitation response"); + assert_eq!( + response.action, + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()) + ); + store.read_with(cx, |store, _| { + let Some((_, elicitation)) = store.elicitation(&entry_id) else { + panic!("missing request-scoped elicitation"); + }; + assert!(matches!( + elicitation.status, + acp_thread::ElicitationStatus::Accepted + )); + }); + + auth_task.await.expect("auth should complete"); + } + + #[gpui::test] + async fn request_scoped_elicitation_ignores_open_sessions(cx: &mut gpui::TestAppContext) { + init_feature_flags_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + + let request_id = acp::RequestId::Number(1); + let (harness, response_rx) = + test_support::connect_fake_acp_connection_with_auth_elicitation( + project.clone(), + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(request_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .await; + let connection = harness.connection.clone(); + let work_dirs = util::path_list::PathList::new(&[std::path::Path::new("/a")]); + + let first_thread = cx + .update(|cx| { + connection.clone().load_session( + acp::SessionId::new("session-1"), + project.clone(), + work_dirs.clone(), + None, + cx, + ) + }) + .await + .expect("first load_session should succeed"); + let second_thread = cx + .update(|cx| { + connection.clone().load_session( + acp::SessionId::new("session-2"), + project, + work_dirs, + None, + cx, + ) + }) + .await + .expect("second load_session should succeed"); + cx.run_until_parked(); + assert_eq!( + connection.sessions.borrow().len(), + 2, + "test setup should have multiple open sessions" + ); + + let auth_task = + cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx)); + cx.run_until_parked(); + + let store = connection + .request_elicitations() + .expect("ACP connections expose request-scoped elicitations"); + let elicitation_id = store.read_with(cx, |store, _| { + let [elicitation] = store.elicitations() else { + panic!( + "expected one request-scoped elicitation, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, request_id); + elicitation.id.clone() + }); + + for thread in [first_thread, second_thread] { + thread.read_with(cx, |thread, _| { + assert!( + thread.entries().iter().all(|entry| !matches!( + entry, + acp_thread::AgentThreadEntry::Elicitation(_) + )), + "request-scoped elicitation should not be inserted into a session thread" + ); + }); + } + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + }); + + let response = response_rx + .recv() + .await + .expect("fake auth flow should receive elicitation response"); + assert_eq!(response.action, acp::ElicitationAction::Decline); + auth_task.await.expect("auth should complete"); + } + + #[test] + fn cursor_client_capabilities_include_parameterized_model_picker_meta() { + let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID)); + let meta = capabilities + .meta + .expect("expected client capabilities meta"); + + assert_eq!( + meta.get(PARAMETERIZED_MODEL_PICKER_META_KEY), + Some(&serde_json::json!(true)) + ); + assert_eq!(meta.get("terminal_output"), Some(&serde_json::json!(true))); + assert_eq!(meta.get("terminal-auth"), Some(&serde_json::json!(true))); + } + + #[test] + fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() { + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let meta = capabilities + .meta + .expect("expected client capabilities meta"); + + assert!(!meta.contains_key(PARAMETERIZED_MODEL_PICKER_META_KEY)); + } + + #[test] + fn client_capabilities_include_boolean_config_options() { + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + + assert!( + capabilities + .session + .and_then(|session| session.config_options) + .and_then(|config_options| config_options.boolean) + .is_some() + ); + } + #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { let command = AgentServerCommand { @@ -2775,6 +3329,78 @@ mod tests { ); } + #[test] + fn debug_log_records_each_json_rpc_batch_entry() { + let debug_log = AcpDebugLog::default(); + debug_log.record_line( + AcpDebugMessageDirection::Incoming, + r#"{"jsonrpc":"2.0","method":"legacy/update"}"#, + ); + debug_log.record_line( + AcpDebugMessageDirection::Incoming, + r#"[ + {"jsonrpc":"2.0","method":"session/update","params":{"value":1}}, + null, + [{"jsonrpc":"2.0","method":"nested/update"}], + {"jsonrpc":"2.0","id":1,"method":"session/one","params":{"value":2}}, + {"jsonrpc":"2.0","id":{"invalid":true},"method":"invalid/id"} + ]"#, + ); + debug_log.record_line( + AcpDebugMessageDirection::Outgoing, + r#"[ + {"jsonrpc":"2.0","id":1,"result":{"accepted":true}}, + {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}} + ]"#, + ); + + let (messages, _receiver) = debug_log.subscribe(); + let mut messages = messages.iter(); + + assert!(matches!( + messages.next(), + Some(AcpDebugMessage { + direction: AcpDebugMessageDirection::Incoming, + message: AcpDebugMessageContent::Notification { method, .. }, + }) if method.as_ref() == "legacy/update" + )); + assert!(matches!( + messages.next(), + Some(AcpDebugMessage { + direction: AcpDebugMessageDirection::Incoming, + message: AcpDebugMessageContent::Notification { method, .. }, + }) if method.as_ref() == "session/update" + )); + assert!(matches!( + messages.next(), + Some(AcpDebugMessage { + direction: AcpDebugMessageDirection::Incoming, + message: AcpDebugMessageContent::Request { id, method, .. }, + }) if id == &acp::RequestId::Number(1) && method.as_ref() == "session/one" + )); + assert!(matches!( + messages.next(), + Some(AcpDebugMessage { + direction: AcpDebugMessageDirection::Outgoing, + message: AcpDebugMessageContent::Response { + id, + result: Ok(Some(_)), + }, + }) if id == &acp::RequestId::Number(1) + )); + assert!(matches!( + messages.next(), + Some(AcpDebugMessage { + direction: AcpDebugMessageDirection::Outgoing, + message: AcpDebugMessageContent::Response { + id, + result: Err(_), + }, + }) if id == &acp::RequestId::Null + )); + assert!(messages.next().is_none()); + } + #[test] fn session_directories_use_ordered_paths_when_supported() { let work_dirs = PathList::new(&[ @@ -3077,7 +3703,7 @@ mod tests { default_mode: Some("manual".to_string()), default_config_options: HashMap::from_iter([( "mode".to_string(), - "manual".to_string(), + AgentConfigOptionValue::from("manual"), )]), favorite_config_option_values: HashMap::default(), } @@ -3093,8 +3719,13 @@ mod tests { Some(acp::SessionModeId::new("manual")) ); assert_eq!( - harness.connection.defaults.config_option("mode").as_deref(), - Some("manual") + harness + .connection + .defaults + .config_option("mode") + .as_ref() + .and_then(AgentConfigOptionValue::as_value_id), + Some("manual"), ); cx.update(|cx| { @@ -3109,6 +3740,119 @@ mod tests { assert_eq!(harness.connection.defaults.config_option("mode"), None); } + #[gpui::test] + async fn default_config_options_apply_boolean_defaults(cx: &mut gpui::TestAppContext) { + let (connection, set_config_requests) = connect_config_defaults_test_agent(cx).await; + connection.defaults.set( + None, + HashMap::from_iter([( + "web_search".to_string(), + AgentConfigOptionValue::Boolean(true), + )]), + ); + let config_options = Rc::new(RefCell::new(vec![acp::SessionConfigOption::boolean( + "web_search", + "Web Search", + false, + )])); + + let mut async_cx = cx.to_async(); + connection.apply_default_config_options( + &acp::SessionId::new("session-config-defaults"), + &config_options, + &mut async_cx, + ); + drop(async_cx); + cx.run_until_parked(); + + let requests = set_config_requests + .lock() + .expect("set config requests mutex poisoned"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].config_id, + acp::SessionConfigId::new("web_search") + ); + assert_eq!( + requests[0].value, + acp::SessionConfigOptionValue::boolean(true) + ); + + let options = config_options.borrow(); + assert!( + matches!(&options[0].kind, acp::SessionConfigKind::Boolean(boolean) if boolean.current_value) + ); + } + + async fn connect_config_defaults_test_agent( + cx: &mut gpui::TestAppContext, + ) -> ( + AcpConnection, + Arc>>, + ) { + let set_config_requests = Arc::new(Mutex::new(Vec::new())); + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + + cx.background_spawn( + Agent + .builder() + .name("config-defaults-test-agent") + .on_receive_request( + { + let set_config_requests = set_config_requests.clone(); + async move |req: acp::SetSessionConfigOptionRequest, responder, _cx| { + set_config_requests + .lock() + .expect("set config requests mutex poisoned") + .push(req); + + responder.respond(acp::SetSessionConfigOptionResponse::new(Vec::new())) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(agent_transport), + ) + .detach(); + + let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); + let client_io_task = cx.background_spawn(async move { + Client + .builder() + .name("config-defaults-test-client") + .connect_with( + client_transport, + move |connection: ConnectionTo| async move { + connection_tx.send(connection).ok(); + futures::future::pending::>().await + }, + ) + .await + .ok(); + }); + + let client_conn = connection_rx + .await + .expect("failed to receive ACP connection"); + let sessions = Rc::new(RefCell::new(HashMap::default())); + + let connection = cx.update(|cx| { + let request_elicitations = cx.new(|_| ElicitationStore::default()); + AcpConnection::new_for_test( + client_conn, + sessions, + acp::AgentCapabilities::default(), + request_elicitations, + WeakEntity::new_invalid(), + client_io_task, + Task::ready(()), + cx, + ) + }); + + (connection, set_config_requests) + } + #[gpui::test] async fn session_list_delete_sends_session_delete_when_supported( cx: &mut gpui::TestAppContext, @@ -3363,17 +4107,19 @@ mod tests { .await .expect("failed to receive ACP connection handle"); - let response = into_foreground_future( - client_conn.send_request(acp::InitializeRequest::new(acp::ProtocolVersion::V1)), - ) - .await - .expect("failed to initialize ACP connection"); + let response = client_conn + .send_request(acp::InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await + .expect("failed to initialize ACP connection"); let agent_capabilities = response.agent_capabilities; + let request_elicitations = cx.new(|_| ElicitationStore::default()); let dispatch_context = ClientContext { sessions: sessions.clone(), session_list: client_session_list.clone(), + request_elicitations: request_elicitations.clone(), }; // `TestAppContext::spawn` hands out an `AsyncApp` by value, whereas the // production path uses `Context::spawn` which hands out `&mut AsyncApp`. @@ -3397,6 +4143,7 @@ mod tests { client_conn, sessions, agent_capabilities, + request_elicitations, agent_server_store, client_io_task, dispatch_task, @@ -3561,6 +4308,7 @@ mod tests { acp_thread::AgentThreadEntry::UserMessage(_) => "user", acp_thread::AgentThreadEntry::AssistantMessage(_) => "assistant", acp_thread::AgentThreadEntry::ToolCall(_) => "tool_call", + acp_thread::AgentThreadEntry::Elicitation(_) => "elicitation", acp_thread::AgentThreadEntry::CompletedPlan(_) => "plan", acp_thread::AgentThreadEntry::ContextCompaction(_) => "compaction", }) @@ -3939,10 +4687,10 @@ impl acp_thread::AgentSessionModes for AcpSessionModes { }; let state = self.state.clone(); cx.foreground_executor().spawn(async move { - let result = into_foreground_future( - connection.send_request(acp::SetSessionModeRequest::new(session_id, mode_id)), - ) - .await; + let result = connection + .send_request(acp::SetSessionModeRequest::new(session_id, mode_id)) + .block_task() + .await; if result.is_err() { state.borrow_mut().current_mode_id = old_mode_id; @@ -3971,7 +4719,7 @@ impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions { fn set_config_option( &self, config_id: acp::SessionConfigId, - value: acp::SessionConfigValueId, + value: acp::SessionConfigOptionValue, cx: &mut App, ) -> Task>> { let connection = self.connection.clone(); @@ -3981,10 +4729,12 @@ impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions { let watch_tx = self.watch_tx.clone(); cx.foreground_executor().spawn(async move { - let response = into_foreground_future(connection.send_request( - acp::SetSessionConfigOptionRequest::new(session_id, config_id, value), - )) - .await?; + let response = connection + .send_request(acp::SetSessionConfigOptionRequest::new( + session_id, config_id, value, + )) + .block_task() + .await?; *state.borrow_mut() = response.config_options.clone(); watch_tx.borrow_mut().send(()).ok(); @@ -4024,6 +4774,15 @@ fn respond_err(responder: Responder, err: acp::Error) { responder.respond_with_error(err).log_err(); } +fn respond_result(responder: Responder, result: Result) { + match result { + Ok(response) => { + responder.respond(response).log_err(); + } + Err(err) => respond_err(responder, err), + } +} + fn handle_request_permission( args: acp::RequestPermissionRequest, responder: Responder, @@ -4035,6 +4794,8 @@ fn handle_request_permission( Err(e) => return respond_err(responder, e), }; + let cancellation = responder.cancellation(); + let tool_call_id = args.tool_call.tool_call_id.clone(); cx.spawn(async move |cx| { let result: Result<_, acp::Error> = async { let task = thread @@ -4047,7 +4808,9 @@ fn handle_request_permission( ) }) .flatten_acp()?; - Ok(task.await) + cancellation + .run_until_cancelled(async { Ok(task.await) }) + .await } .await; @@ -4057,8 +4820,135 @@ fn handle_request_permission( .respond(acp::RequestPermissionResponse::new(outcome.into())) .log_err(); } - Err(e) => respond_err(responder, e), + Err(e) => { + if e.code == ErrorCode::RequestCancelled { + thread + .update(cx, |thread, cx| { + thread.cancel_tool_call_authorization(&tool_call_id, cx) + }) + .log_err(); + } + respond_err(responder, e) + } + } + }) + .detach(); +} + +fn handle_create_elicitation( + args: acp::CreateElicitationRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + match args.scope() { + acp::ElicitationScope::Session(scope) => { + let thread = match session_thread(ctx, &scope.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; + + let (elicitation_id, task) = match thread + .update(cx, |thread, cx| { + thread.request_elicitation_with_id(args, cx) + }) + .flatten_acp() + { + Ok(task) => task, + Err(e) => return respond_err(responder, e), + }; + + let cancellation = responder.cancellation(); + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = cancellation + .run_until_cancelled(async { Ok(task.await) }) + .await; + + match result { + Ok(response) => { + responder.respond(response).log_err(); + } + Err(e) => { + if e.code == ErrorCode::RequestCancelled { + thread + .update(cx, |thread, cx| { + thread.cancel_elicitation(&elicitation_id, cx) + }) + .log_err(); + } + respond_err(responder, e); + } + } + }) + .detach(); + } + acp::ElicitationScope::Request(_) => { + let store = ctx.request_elicitations.clone(); + let (elicitation_id, task) = + match store.update(cx, |store, cx| store.request_elicitation_with_id(args, cx)) { + Ok(task) => task, + Err(e) => return respond_err(responder, e), + }; + let store = store.downgrade(); + + let cancellation = responder.cancellation(); + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = cancellation + .run_until_cancelled(async { Ok(task.await) }) + .await; + + match result { + Ok(response) => { + responder.respond(response).log_err(); + } + Err(e) => { + if e.code == ErrorCode::RequestCancelled { + store + .update(cx, |store, cx| { + store.cancel_elicitation(&elicitation_id, cx) + }) + .log_err(); + } + respond_err(responder, e); + } + } + }) + .detach(); + } + _ => { + respond_err( + responder, + acp::Error::invalid_params().data("unknown elicitation scope"), + ); + } + } +} + +fn handle_complete_elicitation( + args: acp::CompleteElicitationNotification, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let threads = ctx + .sessions + .borrow() + .values() + .map(|session| session.thread.clone()) + .collect::>(); + let request_elicitations = ctx.request_elicitations.clone(); + let elicitation_id = args.elicitation_id; + + cx.spawn(async move |cx| { + for thread in threads { + thread + .update(cx, |thread, cx| { + thread.complete_url_elicitation(&elicitation_id, cx); + }) + .ok(); } + request_elicitations.update(cx, |store, cx| { + store.complete_url_elicitation(&elicitation_id, cx); + }); }) .detach(); } @@ -4110,24 +5000,19 @@ fn handle_read_text_file( }; cx.spawn(async move |cx| { - let result: Result<_, acp::Error> = async { - thread - .update(cx, |thread, cx| { - thread.read_text_file(args.path, args.line, args.limit, false, cx) - }) - .map_err(acp::Error::from)? - .await - } - .await; + let cancellation = responder.cancellation(); + let result = cancellation + .run_until_cancelled(async { + thread + .update(cx, |thread, cx| { + thread.read_text_file(args.path, args.line, args.limit, false, cx) + }) + .map_err(acp::Error::from)? + .await + }) + .await; - match result { - Ok(content) => { - responder - .respond(acp::ReadTextFileResponse::new(content)) - .log_err(); - } - Err(e) => respond_err(responder, e), - } + respond_result(responder, result.map(acp::ReadTextFileResponse::new)); }) .detach(); } @@ -4438,25 +5323,20 @@ fn handle_wait_for_terminal_exit( }; cx.spawn(async move |cx| { - let result: Result<_, acp::Error> = async { - let exit_status = thread - .update(cx, |thread, cx| { - anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()) - }) - .flatten_acp()? - .await; - Ok(exit_status) - } - .await; + let cancellation = responder.cancellation(); + let result = cancellation + .run_until_cancelled(async { + let exit_status = thread + .update(cx, |thread, cx| { + anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()) + }) + .flatten_acp()? + .await; + Ok(exit_status) + }) + .await; - match result { - Ok(exit_status) => { - responder - .respond(acp::WaitForTerminalExitResponse::new(exit_status)) - .log_err(); - } - Err(e) => respond_err(responder, e), - } + respond_result(responder, result.map(acp::WaitForTerminalExitResponse::new)); }) .detach(); } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index f1d9373bace03b..7534f60e364116 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -15,10 +15,10 @@ use http_client::read_no_proxy_from_env; use project::{AgentId, Project, agent_server_store::AgentServerStore}; use acp_thread::AgentConnection; -use agent_client_protocol::schema as acp_schema; +use agent_client_protocol::schema::v1 as acp_schema; use anyhow::Result; use gpui::{App, AppContext, Entity, Task}; -use settings::SettingsStore; +use settings::{AgentConfigOptionValue, SettingsStore}; use std::{any::Any, rc::Rc, sync::Arc}; #[cfg(any(test, feature = "test-support"))] @@ -74,14 +74,14 @@ pub trait AgentServer: Send { ) { } - fn default_config_option(&self, _config_id: &str, _cx: &App) -> Option { + fn default_config_option(&self, _config_id: &str, _cx: &App) -> Option { None } fn set_default_config_option( &self, _config_id: &str, - _value_id: Option<&str>, + _value: Option, _fs: Arc, _cx: &mut App, ) { diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 376b5f1c2f9428..c79ebdc45c0bab 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,6 +1,6 @@ use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result}; use collections::HashSet; use fs::Fs; @@ -10,13 +10,14 @@ use project::{ Project, agent_server_store::{AgentId, AllAgentServersSettings}, }; -use settings::{SettingsStore, update_settings_file}; +use settings::{AgentConfigOptionValue, SettingsStore, update_settings_file}; use std::{rc::Rc, sync::Arc}; use ui::IconName; pub const GEMINI_ID: &str = "gemini"; pub const CLAUDE_AGENT_ID: &str = "claude-acp"; pub const CODEX_ID: &str = "codex-acp"; +pub const CURSOR_ID: &str = "cursor"; /// A generic agent server implementation for custom user-defined agents pub struct CustomAgentServer { @@ -141,7 +142,7 @@ impl AgentServer for CustomAgentServer { }); } - fn default_config_option(&self, config_id: &str, cx: &App) -> Option { + fn default_config_option(&self, config_id: &str, cx: &App) -> Option { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) @@ -151,19 +152,18 @@ impl AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_config_option(config_id).map(|s| s.to_string())) + .and_then(|s| s.default_config_option(config_id).cloned()) } fn set_default_config_option( &self, config_id: &str, - value_id: Option<&str>, + value: Option, fs: Arc, cx: &mut App, ) { let agent_id = self.agent_id(); let config_id = config_id.to_string(); - let value_id = value_id.map(|s| s.to_string()); update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers @@ -180,7 +180,7 @@ impl AgentServer for CustomAgentServer { default_config_options, .. } => { - if let Some(value) = value_id.clone() { + if let Some(value) = value { default_config_options.insert(config_id.clone(), value); } else { default_config_options.remove(&config_id); diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 0d26655555bb12..6fb97b915e77d5 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,6 +1,6 @@ use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use client::RefreshLlmTokenListener; use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::AppContext; @@ -379,7 +379,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::schema::PermissionOptionId::new($allow_option_id), + ::agent_client_protocol::schema::v1::PermissionOptionId::new($allow_option_id), cx, ) .await; diff --git a/crates/agent_settings/src/agent_profile.rs b/crates/agent_settings/src/agent_profile.rs index ca448ca85710aa..1283dbf1ea1508 100644 --- a/crates/agent_settings/src/agent_profile.rs +++ b/crates/agent_settings/src/agent_profile.rs @@ -7,7 +7,7 @@ use fs::Fs; use gpui::{App, SharedString}; use settings::{ AgentProfileContent, ContextServerPresetContent, LanguageModelSelection, Settings as _, - SettingsContent, update_settings_file, + SettingsContent, SettingsStore, update_settings_file, }; use util::ResultExt as _; @@ -116,6 +116,32 @@ impl AgentProfileSettings { self.tools.get(tool_name) == Some(&true) } + /// Whether the built-in profile with the given id still matches the shipped + /// default — i.e. the user has neither customized the built-in profile nor + /// shadowed it with a custom profile of the same id. Custom profile ids are + /// never considered unmodified defaults. + pub fn is_unmodified_default(profile_id: &AgentProfileId, cx: &App) -> bool { + if !builtin_profiles::is_builtin(profile_id) { + return false; + } + let store = cx.global::(); + let profile_in = |content: &SettingsContent| { + content + .agent + .as_ref() + .and_then(|agent| agent.profiles.as_ref()) + .and_then(|profiles| profiles.get(profile_id.as_str())) + .cloned() + }; + match ( + profile_in(store.merged_settings()), + profile_in(store.raw_default_settings()), + ) { + (Some(merged), Some(default)) => merged == default, + _ => false, + } + } + pub fn is_context_server_tool_enabled(&self, server_id: &str, tool_name: &str) -> bool { self.context_servers .get(server_id) @@ -247,4 +273,37 @@ mod tests { assert!(!profile.is_context_server_tool_enabled("server", "other_tool")); assert!(!profile.is_context_server_tool_enabled("other_server", "any_tool")); } + + #[gpui::test] + fn unmodified_default_detection(cx: &mut gpui::App) { + use gpui::UpdateGlobal as _; + + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + let write = AgentProfileId(builtin_profiles::WRITE.into()); + let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); + let custom = AgentProfileId("custom".into()); + + // Fresh defaults: the shipped built-in profiles are unmodified. + assert!(AgentProfileSettings::is_unmodified_default(&write, cx)); + assert!(AgentProfileSettings::is_unmodified_default(&minimal, cx)); + // Custom (non-built-in) ids are never considered unmodified defaults. + assert!(!AgentProfileSettings::is_unmodified_default(&custom, cx)); + + // The user customizes the `write` profile; `minimal` stays untouched. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ "agent": { "profiles": { "write": { "name": "Write", "tools": { "fetch": false } } } } }"#, + cx, + ) + .unwrap(); + }); + + assert!(!AgentProfileSettings::is_unmodified_default(&write, cx)); + assert!(AgentProfileSettings::is_unmodified_default(&minimal, cx)); + } } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 7b7aa394442d11..caca37e2668ff4 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -3,7 +3,7 @@ mod user_agents_md; use std::cmp::Ordering::{Equal, Greater, Less}; use std::fmt; -use std::path::{Component, Path, PathBuf}; +use std::path::{Component, Path}; use std::sync::{Arc, LazyLock}; use anyhow::Context as _; @@ -216,8 +216,10 @@ pub struct AgentSettings { pub inline_assistant_model: Option, pub inline_assistant_use_streaming_tools: bool, pub commit_message_model: Option, + pub commit_message_include_project_rules: bool, pub commit_message_instructions: Option, pub thread_summary_model: Option, + pub compaction_model: Option, pub inline_alternatives: Vec, pub favorite_models: Vec, pub default_profile: AgentProfileId, @@ -415,7 +417,7 @@ impl Default for AgentProfileId { /// combines them with the in-memory per-thread grants. `write_paths` are /// stored as minimal, lexically-normalized subtrees (see /// [`compile_sandbox_permissions`]). -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct SandboxPermissions { /// Allow sandboxed commands to reach any host over the network. pub allow_all_hosts: bool, @@ -424,13 +426,41 @@ pub struct SandboxPermissions { /// consumed (`agent::sandboxing`). pub network_hosts: Vec, pub allow_fs_write_all: bool, - /// Auto-approve commands that request `unsandboxed: true`. Unlike - /// `disabled`, the sandbox stays on for commands that don't ask. + /// Persistently run agent terminal commands outside the OS sandbox. This is + /// the model-facing "off switch": when set, the sandboxed terminal tool is + /// not exposed and the system prompt omits the sandbox section, so the + /// model uses the plain `terminal` tool (on Windows, WSL sandbox setup is + /// skipped). Distinct from the model-requested `unsandboxed: true` escape + /// approved "once" or "for this thread", which keeps the sandboxed + /// tool/prompt in place — see `agent::sandboxing`. pub allow_unsandboxed: bool, - /// Turn terminal sandboxing off entirely: the sandboxed terminal tool is - /// not exposed and every command runs outside the sandbox. - pub disabled: bool, - pub write_paths: Vec, + /// Directory subtree grants, each paired with the canonical + /// (symlink-resolved) target established when the grant was approved. + pub write_paths: Vec, + /// Whether sandbox escalation prompts warn about domains or write paths + /// that contain potentially confusable Unicode characters (homoglyphs, + /// invisible characters, or bidirectional overrides). Enabled by default. + pub warn_confusable_unicode: bool, + /// Whether to warn (Windows/WSL only) when a sandbox grant targets a file on + /// a Windows-hosted (DrvFs) filesystem, whose sandbox-integrity guarantees + /// are weaker than a distro-native filesystem. Enabled by default. + pub warn_ntfs_grants: bool, +} + +impl Default for SandboxPermissions { + fn default() -> Self { + Self { + allow_all_hosts: false, + network_hosts: Vec::new(), + allow_fs_write_all: false, + allow_unsandboxed: false, + write_paths: Vec::new(), + // The confusable-Unicode warning is a safety net, so it defaults on. + warn_confusable_unicode: true, + // The weaker-guarantee warning for Windows-hosted grants defaults on. + warn_ntfs_grants: true, + } + } } #[derive(Clone, Debug, Default)] @@ -745,9 +775,13 @@ impl Settings for AgentSettings { inline_assistant_use_streaming_tools: agent .inline_assistant_use_streaming_tools .unwrap_or(true), + commit_message_include_project_rules: agent + .commit_message_include_project_rules + .unwrap(), commit_message_model: agent.commit_message_model, commit_message_instructions: agent.commit_message_instructions, thread_summary_model: agent.thread_summary_model, + compaction_model: agent.compaction_model, inline_alternatives: agent.inline_alternatives.unwrap_or_default(), favorite_models: agent.favorite_models, default_profile: AgentProfileId(agent.default_profile.unwrap()), @@ -799,13 +833,24 @@ fn compile_sandbox_permissions( return SandboxPermissions::default(); }; - let mut write_paths = Vec::new(); - for path in content.write_paths.map(|paths| paths.0).unwrap_or_default() { + let mut write_paths: Vec = Vec::new(); + for entry in content.write_paths.map(|paths| paths.0).unwrap_or_default() { // Normalize away `..`/`.` before storing, since coverage checks are - // purely lexical; drop paths that escape the filesystem root. - if let Ok(normalized) = util::paths::normalize_lexically(&path) { - util::paths::insert_subtree(&mut write_paths, normalized); - } + // purely lexical; drop entries whose requested (or resolved) path + // escapes the filesystem root. + let Ok(requested) = util::paths::normalize_lexically(&entry.requested) else { + continue; + }; + let granted = match entry.resolved { + Some(resolved) => { + let Ok(resolved) = util::paths::normalize_lexically(&resolved) else { + continue; + }; + settings::GrantedWritePath::resolved_on_fs(requested, resolved, entry.on_windows_fs) + } + None => settings::GrantedWritePath::from_requested(requested), + }; + insert_granted_subtree(&mut write_paths, granted); } let network_hosts = content @@ -818,11 +863,39 @@ fn compile_sandbox_permissions( network_hosts, allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false), allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false), - disabled: content.disabled.unwrap_or(false), write_paths, + warn_confusable_unicode: content.warn_confusable_unicode.unwrap_or(true), + warn_ntfs_grants: content.warn_ntfs_grants.unwrap_or(true), } } +/// Subtree-insert mirroring [`util::paths::insert_subtree`], but over +/// [`settings::GrantedWritePath`] entries compared by their canonical +/// (symlink-resolved) grant path — the path actually enforced at write time. +/// +/// Insertion is a no-op when the new grant's canonical path is already covered +/// by an existing entry; otherwise the new grant is added and any existing +/// entries whose canonical path is a descendant of it are pruned. Containment +/// is purely lexical, so callers should normalize paths first. +fn insert_granted_subtree( + subtrees: &mut Vec, + granted: settings::GrantedWritePath, +) { + if subtrees.iter().any(|existing| { + granted + .canonical_or_requested() + .starts_with(existing.canonical_or_requested()) + }) { + return; + } + subtrees.retain(|existing| { + !existing + .canonical_or_requested() + .starts_with(granted.canonical_or_requested()) + }); + subtrees.push(granted); +} + fn compile_tool_permissions(content: Option) -> ToolPermissions { let Some(content) = content else { return ToolPermissions::default(); @@ -924,6 +997,7 @@ mod tests { use serde_json::json; use settings::ToolPermissionMode; use settings::ToolPermissionsContent; + use std::path::PathBuf; #[test] fn test_parse_auto_compact_threshold() { @@ -1097,6 +1171,22 @@ mod tests { fn test_sandbox_permissions_empty() { let permissions = compile_sandbox_permissions(None); assert_eq!(permissions, SandboxPermissions::default()); + // The confusable-Unicode warning is a safety net, so it's on by default. + assert!(permissions.warn_confusable_unicode); + } + + #[test] + fn test_sandbox_permissions_warn_confusable_unicode_can_be_disabled() { + let content: settings::SandboxPermissionsContent = + serde_json::from_value(json!({ "warn_confusable_unicode": false })).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + assert!(!permissions.warn_confusable_unicode); + + // Omitting the key keeps the warning enabled. + let content: settings::SandboxPermissionsContent = + serde_json::from_value(json!({})).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + assert!(permissions.warn_confusable_unicode); } #[test] @@ -1122,12 +1212,12 @@ mod tests { ); assert!(!permissions.allow_fs_write_all); assert!(permissions.allow_unsandboxed); - // `allow_unsandboxed` is a per-request grant; it must not imply that - // sandboxing is disabled. - assert!(!permissions.disabled); assert_eq!( permissions.write_paths, - vec![PathBuf::from("/tmp/build"), PathBuf::from("/var/log")] + vec![ + settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/build")), + settings::GrantedWritePath::from_requested(PathBuf::from("/var/log")), + ] ); } @@ -1145,7 +1235,77 @@ mod tests { // `/tmp/build/../build/cache` normalizes to `/tmp/build/cache`, which is // then pruned as a redundant child of `/tmp/build`. - assert_eq!(permissions.write_paths, vec![PathBuf::from("/tmp/build")]); + assert_eq!( + permissions.write_paths, + vec![settings::GrantedWritePath::from_requested(PathBuf::from( + "/tmp/build" + ))] + ); + } + + #[test] + fn test_sandbox_permissions_bare_string_has_no_resolved() { + let json = json!({ + "write_paths": ["/tmp/build"] + }); + + let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + + assert_eq!( + permissions.write_paths, + vec![settings::GrantedWritePath::from_requested(PathBuf::from( + "/tmp/build" + ))] + ); + assert_eq!(permissions.write_paths[0].resolved, None); + } + + #[test] + fn test_sandbox_permissions_object_preserves_resolved() { + let json = json!({ + "write_paths": [ + { "requested": "/tmp/link", "resolved": "/tmp/real" } + ] + }); + + let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + + assert_eq!( + permissions.write_paths, + vec![settings::GrantedWritePath::resolved( + PathBuf::from("/tmp/link"), + PathBuf::from("/tmp/real"), + )] + ); + assert_eq!( + permissions.write_paths[0].resolved, + Some(PathBuf::from("/tmp/real")) + ); + } + + #[test] + fn test_sandbox_permissions_dedup_keys_on_resolved_path() { + // The requested paths are unrelated, but the resolved (canonical) + // targets form a subtree, so dedup must prune by the resolved path. + let json = json!({ + "write_paths": [ + { "requested": "/tmp/link/cache", "resolved": "/tmp/real/cache" }, + { "requested": "/tmp/other", "resolved": "/tmp/real" }, + ] + }); + + let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + + assert_eq!( + permissions.write_paths, + vec![settings::GrantedWritePath::resolved( + PathBuf::from("/tmp/other"), + PathBuf::from("/tmp/real"), + )] + ); } #[test] diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index f95549f6d47016..0bd7cd9849cb10 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -64,6 +64,7 @@ gpui.workspace = true gpui_tokio.workspace = true html_to_markdown.workspace = true http_client.workspace = true +idna.workspace = true indoc.workspace = true itertools.workspace = true jsonschema.workspace = true @@ -91,6 +92,7 @@ release_channel.workspace = true remote.workspace = true remote_connection.workspace = true rope.workspace = true +sandbox.workspace = true schemars.workspace = true search.workspace = true serde.workspace = true @@ -109,7 +111,8 @@ time.workspace = true time_format.workspace = true tokio.workspace = true ui.workspace = true -ui_input.workspace = true +unicode-script.workspace = true +unicode-segmentation.workspace = true url.workspace = true util.workspace = true uuid.workspace = true @@ -147,6 +150,7 @@ remote_server = { workspace = true, features = ["test-support"] } search = { workspace = true, features = ["test-support"] } semver.workspace = true +shlex.workspace = true reqwest_client.workspace = true tempfile.workspace = true vim.workspace = true diff --git a/crates/agent_ui/src/acp/config_options.rs b/crates/agent_ui/src/acp/config_options.rs deleted file mode 100644 index c12f4df3d88a9c..00000000000000 --- a/crates/agent_ui/src/acp/config_options.rs +++ /dev/null @@ -1,913 +0,0 @@ -use std::{cmp::Reverse, rc::Rc, sync::Arc}; - -use acp_thread::AgentSessionConfigOptions; -use agent_client_protocol as acp; -use agent_servers::AgentServer; -use agent_settings::AgentSettings; -use collections::HashSet; -use fs::Fs; -use fuzzy::StringMatchCandidate; -use gpui::{ - App, BackgroundExecutor, Context, DismissEvent, Entity, Subscription, Task, Window, prelude::*, -}; -use ordered_float::OrderedFloat; -use picker::popover_menu::PickerPopoverMenu; -use picker::{Picker, PickerDelegate}; -use settings::{Settings, SettingsStore}; -use ui::{ - DocumentationSide, ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle, - Tooltip, prelude::*, -}; -use util::ResultExt as _; - -use crate::ui::HoldForDefault; - -const PICKER_THRESHOLD: usize = 5; - -pub struct ConfigOptionsView { - config_options: Rc, - selectors: Vec>, - agent_server: Rc, - fs: Arc, - config_option_ids: Vec, - _refresh_task: Task<()>, -} - -impl ConfigOptionsView { - pub fn new( - config_options: Rc, - agent_server: Rc, - fs: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let selectors = Self::build_selectors(&config_options, &agent_server, &fs, window, cx); - let config_option_ids = Self::config_option_ids(&config_options); - - let rx = config_options.watch(cx); - let refresh_task = cx.spawn_in(window, async move |this, cx| { - if let Some(mut rx) = rx { - while let Ok(()) = rx.recv().await { - this.update_in(cx, |this, window, cx| { - this.refresh_selectors_if_needed(window, cx); - cx.notify(); - }) - .log_err(); - } - } - }); - - Self { - config_options, - selectors, - agent_server, - fs, - config_option_ids, - _refresh_task: refresh_task, - } - } - - pub fn toggle_category_picker( - &mut self, - category: acp::SessionConfigOptionCategory, - window: &mut Window, - cx: &mut Context, - ) -> bool { - let Some(config_id) = self.first_config_option_id(category) else { - return false; - }; - - let Some(selector) = self.selector_for_config_id(&config_id, cx) else { - return false; - }; - - selector.update(cx, |selector, cx| { - selector.toggle_picker(window, cx); - }); - - true - } - - pub fn cycle_category_option( - &mut self, - category: acp::SessionConfigOptionCategory, - favorites_only: bool, - cx: &mut Context, - ) -> bool { - let Some(config_id) = self.first_config_option_id(category) else { - return false; - }; - - let Some(next_value) = self.next_value_for_config(&config_id, favorites_only, cx) else { - return false; - }; - - let task = self - .config_options - .set_config_option(config_id, next_value, cx); - - cx.spawn(async move |_, _| { - if let Err(err) = task.await { - log::error!("Failed to set config option: {:?}", err); - } - }) - .detach(); - - true - } - - /// Returns the current model value ID string for the Model config option, if one exists. - pub fn current_model_value(&self) -> Option { - let config_id = self.first_config_option_id(acp::SessionConfigOptionCategory::Model)?; - let option = self.config_options.config_options().into_iter().find(|opt| opt.id == config_id)?; - match &option.kind { - acp::SessionConfigKind::Select(select) => { - // Return the value ID (e.g. "claude-sonnet-4-5-latest"), falling back to name - Some(select.current_value.0.to_string()) - } - _ => None, - } - } - - fn first_config_option_id( - &self, - category: acp::SessionConfigOptionCategory, - ) -> Option { - self.config_options - .config_options() - .into_iter() - .find(|option| option.category.as_ref() == Some(&category)) - .map(|option| option.id) - } - - fn selector_for_config_id( - &self, - config_id: &acp::SessionConfigId, - cx: &App, - ) -> Option> { - self.selectors - .iter() - .find(|selector| selector.read(cx).config_id() == config_id) - .cloned() - } - - fn next_value_for_config( - &self, - config_id: &acp::SessionConfigId, - favorites_only: bool, - cx: &mut Context, - ) -> Option { - let mut options = extract_options(&self.config_options, config_id); - if options.is_empty() { - return None; - } - - if favorites_only { - let favorites = self - .agent_server - .favorite_config_option_value_ids(config_id, cx); - options.retain(|option| favorites.contains(&option.value)); - if options.is_empty() { - return None; - } - } - - let current_value = get_current_value(&self.config_options, config_id); - let current_index = current_value - .as_ref() - .and_then(|current| options.iter().position(|option| &option.value == current)) - .unwrap_or(usize::MAX); - - let next_index = if current_index == usize::MAX { - 0 - } else { - (current_index + 1) % options.len() - }; - - Some(options[next_index].value.clone()) - } - - fn config_option_ids( - config_options: &Rc, - ) -> Vec { - config_options - .config_options() - .into_iter() - .map(|option| option.id) - .collect() - } - - fn refresh_selectors_if_needed(&mut self, window: &mut Window, cx: &mut Context) { - let current_ids = Self::config_option_ids(&self.config_options); - if current_ids != self.config_option_ids { - self.config_option_ids = current_ids; - self.rebuild_selectors(window, cx); - } - } - - fn rebuild_selectors(&mut self, window: &mut Window, cx: &mut Context) { - self.selectors = Self::build_selectors( - &self.config_options, - &self.agent_server, - &self.fs, - window, - cx, - ); - cx.notify(); - } - - fn build_selectors( - config_options: &Rc, - agent_server: &Rc, - fs: &Arc, - window: &mut Window, - cx: &mut Context, - ) -> Vec> { - config_options - .config_options() - .into_iter() - .map(|option| { - let config_options = config_options.clone(); - let agent_server = agent_server.clone(); - let fs = fs.clone(); - cx.new(|cx| { - ConfigOptionSelector::new( - config_options, - option.id.clone(), - agent_server, - fs, - window, - cx, - ) - }) - }) - .collect() - } -} - -impl Render for ConfigOptionsView { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - if self.selectors.is_empty() { - return div().into_any_element(); - } - - h_flex() - .gap_1() - .children(self.selectors.iter().cloned()) - .into_any_element() - } -} - -struct ConfigOptionSelector { - config_options: Rc, - config_id: acp::SessionConfigId, - picker_handle: PopoverMenuHandle>, - picker: Entity>, - setting_value: bool, -} - -impl ConfigOptionSelector { - pub fn new( - config_options: Rc, - config_id: acp::SessionConfigId, - agent_server: Rc, - fs: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let option_count = config_options - .config_options() - .iter() - .find(|opt| opt.id == config_id) - .map(count_config_options) - .unwrap_or(0); - - let is_searchable = option_count >= PICKER_THRESHOLD; - - let picker = { - let config_options = config_options.clone(); - let config_id = config_id.clone(); - let agent_server = agent_server.clone(); - let fs = fs.clone(); - cx.new(move |picker_cx| { - let delegate = ConfigOptionPickerDelegate::new( - config_options, - config_id, - agent_server, - fs, - window, - picker_cx, - ); - - if is_searchable { - Picker::list(delegate, window, picker_cx) - } else { - Picker::nonsearchable_list(delegate, window, picker_cx) - } - .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) - }) - }; - - Self { - config_options, - config_id, - picker_handle: PopoverMenuHandle::default(), - picker, - setting_value: false, - } - } - - fn current_option(&self) -> Option { - self.config_options - .config_options() - .into_iter() - .find(|opt| opt.id == self.config_id) - } - - fn config_id(&self) -> &acp::SessionConfigId { - &self.config_id - } - - fn toggle_picker(&self, window: &mut Window, cx: &mut Context) { - self.picker_handle.toggle(window, cx); - } - - fn current_value_name(&self) -> String { - let Some(option) = self.current_option() else { - return "Unknown".to_string(); - }; - - match &option.kind { - acp::SessionConfigKind::Select(select) => { - find_option_name(&select.options, &select.current_value) - .unwrap_or_else(|| "Unknown".to_string()) - } - _ => "Unknown".to_string(), - } - } - - fn render_trigger_button(&self, _window: &mut Window, _cx: &mut Context) -> Button { - let Some(option) = self.current_option() else { - return Button::new("config-option-trigger", "Unknown") - .label_size(LabelSize::Small) - .color(Color::Muted) - .disabled(true); - }; - - let icon = if self.picker_handle.is_deployed() { - IconName::ChevronUp - } else { - IconName::ChevronDown - }; - - Button::new( - ElementId::Name(format!("config-option-{}", option.id.0).into()), - self.current_value_name(), - ) - .label_size(LabelSize::Small) - .color(Color::Muted) - .icon(icon) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .disabled(self.setting_value) - } -} - -impl Render for ConfigOptionSelector { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(option) = self.current_option() else { - return div().into_any_element(); - }; - - let trigger_button = self.render_trigger_button(window, cx); - - let option_name = option.name.clone(); - let option_description: Option = option.description.map(Into::into); - - let tooltip = Tooltip::element(move |_window, _cx| { - let mut content = v_flex().gap_1().child(Label::new(option_name.clone())); - if let Some(desc) = option_description.as_ref() { - content = content.child( - Label::new(desc.clone()) - .size(LabelSize::Small) - .color(Color::Muted), - ); - } - content.into_any() - }); - - PickerPopoverMenu::new( - self.picker.clone(), - trigger_button, - tooltip, - gpui::Corner::BottomRight, - cx, - ) - .with_handle(self.picker_handle.clone()) - .render(window, cx) - .into_any_element() - } -} - -#[derive(Clone)] -enum ConfigOptionPickerEntry { - Separator(SharedString), - Option(ConfigOptionValue), -} - -#[derive(Clone)] -struct ConfigOptionValue { - value: acp::SessionConfigValueId, - name: String, - description: Option, - group: Option, -} - -struct ConfigOptionPickerDelegate { - config_options: Rc, - config_id: acp::SessionConfigId, - agent_server: Rc, - fs: Arc, - filtered_entries: Vec, - all_options: Vec, - selected_index: usize, - selected_description: Option<(usize, SharedString, bool)>, - favorites: HashSet, - _settings_subscription: Subscription, -} - -impl ConfigOptionPickerDelegate { - fn new( - config_options: Rc, - config_id: acp::SessionConfigId, - agent_server: Rc, - fs: Arc, - window: &mut Window, - cx: &mut Context>, - ) -> Self { - let favorites = agent_server.favorite_config_option_value_ids(&config_id, cx); - - let all_options = extract_options(&config_options, &config_id); - let filtered_entries = options_to_picker_entries(&all_options, &favorites); - - let current_value = get_current_value(&config_options, &config_id); - let selected_index = current_value - .and_then(|current| { - filtered_entries.iter().position(|entry| { - matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current) - }) - }) - .unwrap_or(0); - - let agent_server_for_subscription = agent_server.clone(); - let config_id_for_subscription = config_id.clone(); - let settings_subscription = - cx.observe_global_in::(window, move |picker, window, cx| { - let new_favorites = agent_server_for_subscription - .favorite_config_option_value_ids(&config_id_for_subscription, cx); - if new_favorites != picker.delegate.favorites { - picker.delegate.favorites = new_favorites; - picker.refresh(window, cx); - } - }); - - cx.notify(); - - Self { - config_options, - config_id, - agent_server, - fs, - filtered_entries, - all_options, - selected_index, - selected_description: None, - favorites, - _settings_subscription: settings_subscription, - } - } - - fn current_value(&self) -> Option { - get_current_value(&self.config_options, &self.config_id) - } -} - -impl PickerDelegate for ConfigOptionPickerDelegate { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.filtered_entries.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); - cx.notify(); - } - - fn can_select( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) -> bool { - match self.filtered_entries.get(ix) { - Some(ConfigOptionPickerEntry::Option(_)) => true, - Some(ConfigOptionPickerEntry::Separator(_)) | None => false, - } - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select an option…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let all_options = self.all_options.clone(); - - cx.spawn_in(window, async move |this, cx| { - let filtered_options = match this - .read_with(cx, |_, cx| { - if query.is_empty() { - None - } else { - Some((all_options.clone(), query.clone(), cx.background_executor().clone())) - } - }) - .ok() - .flatten() - { - Some((options, q, executor)) => fuzzy_search_options(options, &q, executor).await, - None => all_options, - }; - - this.update_in(cx, |this, window, cx| { - this.delegate.filtered_entries = - options_to_picker_entries(&filtered_options, &this.delegate.favorites); - - let current_value = this.delegate.current_value(); - let new_index = current_value - .and_then(|current| { - this.delegate.filtered_entries.iter().position(|entry| { - matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current) - }) - }) - .unwrap_or(0); - - this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx); - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - if let Some(ConfigOptionPickerEntry::Option(option)) = - self.filtered_entries.get(self.selected_index) - { - if window.modifiers().secondary() { - let default_value = self - .agent_server - .default_config_option(self.config_id.0.as_ref(), cx); - let is_default = default_value.as_deref() == Some(&*option.value.0); - - self.agent_server.set_default_config_option( - self.config_id.0.as_ref(), - if is_default { - None - } else { - Some(option.value.0.as_ref()) - }, - self.fs.clone(), - cx, - ); - } - - let task = self.config_options.set_config_option( - self.config_id.clone(), - option.value.clone(), - cx, - ); - - cx.spawn(async move |_, _| { - if let Err(err) = task.await { - log::error!("Failed to set config option: {:?}", err); - } - }) - .detach(); - - cx.emit(DismissEvent); - } - } - - fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { - cx.defer_in(window, |picker, window, cx| { - picker.set_query("", window, cx); - }); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - match self.filtered_entries.get(ix)? { - ConfigOptionPickerEntry::Separator(title) => Some( - div() - .when(ix > 0, |this| this.mt_1()) - .child( - div() - .px_2() - .py_1() - .text_xs() - .text_color(cx.theme().colors().text_muted) - .child(title.clone()), - ) - .into_any_element(), - ), - ConfigOptionPickerEntry::Option(option) => { - let current_value = self.current_value(); - let is_selected = current_value.as_ref() == Some(&option.value); - - let default_value = self - .agent_server - .default_config_option(self.config_id.0.as_ref(), cx); - let is_default = default_value.as_deref() == Some(&*option.value.0); - - let is_favorite = self.favorites.contains(&option.value); - - let option_name = option.name.clone(); - let description = option.description.clone(); - - Some( - div() - .id(("config-option-picker-item", ix)) - .when_some(description, |this, desc| { - let desc: SharedString = desc.into(); - this.on_hover(cx.listener(move |menu, hovered, _, cx| { - if *hovered { - menu.delegate.selected_description = - Some((ix, desc.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().child(Label::new(option_name).truncate())) - .end_slot(div().pr_2().when(is_selected, |this| { - this.child(Icon::new(IconName::Check).color(Color::Accent)) - })) - .end_hover_slot(div().pr_1p5().child({ - let (icon, color, tooltip) = if is_favorite { - (IconName::StarFilled, Color::Accent, "Unfavorite") - } else { - (IconName::Star, Color::Default, "Favorite") - }; - - let config_id = self.config_id.clone(); - let value_id = option.value.clone(); - let agent_server = self.agent_server.clone(); - let fs = self.fs.clone(); - - IconButton::new(("toggle-favorite-config-option", ix), icon) - .layer(ElevationIndex::ElevatedSurface) - .icon_color(color) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text(tooltip)) - .on_click(move |_, _, cx| { - agent_server.toggle_favorite_config_option_value( - config_id.clone(), - value_id.clone(), - !is_favorite, - fs.clone(), - cx, - ); - }) - })), - ) - .into_any_element(), - ) - } - } - } - - fn documentation_aside( - &self, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - self.selected_description - .as_ref() - .map(|(_, description, is_default)| { - let description = description.clone(); - let is_default = *is_default; - - let settings = AgentSettings::get_global(cx); - let side = match settings.dock { - settings::DockPosition::Left => DocumentationSide::Right, - settings::DockPosition::Bottom | settings::DockPosition::Right => { - DocumentationSide::Left - } - }; - - ui::DocumentationAside::new( - side, - Rc::new(move |_| { - v_flex() - .gap_1() - .child(Label::new(description.clone())) - .child(HoldForDefault::new(is_default)) - .into_any_element() - }), - ) - }) - } - - fn documentation_aside_index(&self) -> Option { - self.selected_description.as_ref().map(|(ix, _, _)| *ix) - } -} - -fn extract_options( - config_options: &Rc, - config_id: &acp::SessionConfigId, -) -> Vec { - let Some(option) = config_options - .config_options() - .into_iter() - .find(|opt| &opt.id == config_id) - else { - return Vec::new(); - }; - - match &option.kind { - acp::SessionConfigKind::Select(select) => match &select.options { - acp::SessionConfigSelectOptions::Ungrouped(options) => options - .iter() - .map(|opt| ConfigOptionValue { - value: opt.value.clone(), - name: opt.name.clone(), - description: opt.description.clone(), - group: None, - }) - .collect(), - acp::SessionConfigSelectOptions::Grouped(groups) => groups - .iter() - .flat_map(|group| { - group.options.iter().map(|opt| ConfigOptionValue { - value: opt.value.clone(), - name: opt.name.clone(), - description: opt.description.clone(), - group: Some(group.name.clone()), - }) - }) - .collect(), - _ => Vec::new(), - }, - _ => Vec::new(), - } -} - -fn get_current_value( - config_options: &Rc, - config_id: &acp::SessionConfigId, -) -> Option { - config_options - .config_options() - .into_iter() - .find(|opt| &opt.id == config_id) - .and_then(|opt| match &opt.kind { - acp::SessionConfigKind::Select(select) => Some(select.current_value.clone()), - _ => None, - }) -} - -fn options_to_picker_entries( - options: &[ConfigOptionValue], - favorites: &HashSet, -) -> Vec { - let mut entries = Vec::new(); - - let mut favorite_options = Vec::new(); - - for option in options { - if favorites.contains(&option.value) { - favorite_options.push(option.clone()); - } - } - - if !favorite_options.is_empty() { - entries.push(ConfigOptionPickerEntry::Separator("Favorites".into())); - for option in favorite_options { - entries.push(ConfigOptionPickerEntry::Option(option)); - } - - // If the remaining list would start ungrouped (group == None), insert a separator so - // Favorites doesn't visually run into the main list. - if let Some(option) = options.first() - && option.group.is_none() - { - entries.push(ConfigOptionPickerEntry::Separator("All Options".into())); - } - } - - let mut current_group: Option = None; - for option in options { - if option.group != current_group { - if let Some(group_name) = &option.group { - entries.push(ConfigOptionPickerEntry::Separator( - group_name.clone().into(), - )); - } - current_group = option.group.clone(); - } - entries.push(ConfigOptionPickerEntry::Option(option.clone())); - } - - entries -} - -async fn fuzzy_search_options( - options: Vec, - query: &str, - executor: BackgroundExecutor, -) -> Vec { - let candidates = options - .iter() - .enumerate() - .map(|(ix, opt)| StringMatchCandidate::new(ix, &opt.name)) - .collect::>(); - - let mut matches = fuzzy::match_strings( - &candidates, - query, - false, - true, - 100, - &Default::default(), - executor, - ) - .await; - - matches.sort_unstable_by_key(|mat| { - let candidate = &candidates[mat.candidate_id]; - (Reverse(OrderedFloat(mat.score)), candidate.id) - }); - - matches - .into_iter() - .map(|mat| options[mat.candidate_id].clone()) - .collect() -} - -fn find_option_name( - options: &acp::SessionConfigSelectOptions, - value_id: &acp::SessionConfigValueId, -) -> Option { - match options { - acp::SessionConfigSelectOptions::Ungrouped(opts) => opts - .iter() - .find(|o| &o.value == value_id) - .map(|o| o.name.clone()), - acp::SessionConfigSelectOptions::Grouped(groups) => groups.iter().find_map(|group| { - group - .options - .iter() - .find(|o| &o.value == value_id) - .map(|o| o.name.clone()) - }), - _ => None, - } -} - -fn count_config_options(option: &acp::SessionConfigOption) -> usize { - match &option.kind { - acp::SessionConfigKind::Select(select) => match &select.options { - acp::SessionConfigSelectOptions::Ungrouped(options) => options.len(), - acp::SessionConfigSelectOptions::Grouped(groups) => { - groups.iter().map(|g| g.options.len()).sum() - } - _ => 0, - }, - _ => 0, - } -} diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs deleted file mode 100644 index 353e1168c8a685..00000000000000 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ /dev/null @@ -1,540 +0,0 @@ -use std::{cell::RefCell, ops::Range, rc::Rc}; - -use super::thread_history::AcpThreadHistory; -use acp_thread::{AcpThread, AgentThreadEntry}; -use agent::ThreadStore; -use agent_client_protocol::{self as acp, ToolCallId}; -use collections::HashMap; -use editor::{Editor, EditorMode, MinimapVisibility, SizingBehavior}; -use gpui::{ - AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window, -}; -use language::language_settings::SoftWrap; -use project::Project; -use prompt_store::PromptStore; -use settings::Settings as _; -use terminal_view::TerminalView; -use theme::ThemeSettings; -use ui::{Context, TextSize}; -use workspace::Workspace; - -use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; - -pub struct EntryViewState { - workspace: WeakEntity, - project: WeakEntity, - thread_store: Option>, - history: WeakEntity, - prompt_store: Option>, - entries: Vec, - prompt_capabilities: Rc>, - available_commands: Rc>>, - agent_name: SharedString, -} - -impl EntryViewState { - pub fn new( - workspace: WeakEntity, - project: WeakEntity, - thread_store: Option>, - history: WeakEntity, - prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, - agent_name: SharedString, - ) -> Self { - Self { - workspace, - project, - thread_store, - history, - prompt_store, - entries: Vec::new(), - prompt_capabilities, - available_commands, - agent_name, - } - } - - pub fn entry(&self, index: usize) -> Option<&Entry> { - self.entries.get(index) - } - - pub fn sync_entry( - &mut self, - index: usize, - thread: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(thread_entry) = thread.read(cx).entries().get(index) else { - return; - }; - - match thread_entry { - AgentThreadEntry::UserMessage(message) => { - let has_id = message.id.is_some(); - let is_subagent = thread.read(cx).parent_session_id().is_some(); - let chunks = message.chunks.clone(); - if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) { - if !editor.focus_handle(cx).is_focused(window) { - // Only update if we are not editing. - // If we are, cancelling the edit will set the message to the newest content. - editor.update(cx, |editor, cx| { - editor.set_message(chunks, window, cx); - }); - } - } else { - let message_editor = cx.new(|cx| { - let mut editor = MessageEditor::new( - self.workspace.clone(), - self.project.clone(), - self.thread_store.clone(), - self.history.clone(), - self.prompt_store.clone(), - self.prompt_capabilities.clone(), - self.available_commands.clone(), - self.agent_name.clone(), - "Edit message - @ to include context", - editor::EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ); - if !has_id || is_subagent { - editor.set_read_only(true, cx); - } - editor.set_message(chunks, window, cx); - editor - }); - cx.subscribe(&message_editor, move |_, editor, event, cx| { - cx.emit(EntryViewEvent { - entry_index: index, - view_event: ViewEvent::MessageEditorEvent(editor, *event), - }) - }) - .detach(); - self.set_entry(index, Entry::UserMessage(message_editor)); - } - } - AgentThreadEntry::ToolCall(tool_call) => { - let id = tool_call.id.clone(); - let terminals = tool_call.terminals().cloned().collect::>(); - let diffs = tool_call.diffs().cloned().collect::>(); - - let views = if let Some(Entry::Content(views)) = self.entries.get_mut(index) { - views - } else { - self.set_entry(index, Entry::empty()); - let Some(Entry::Content(views)) = self.entries.get_mut(index) else { - unreachable!() - }; - views - }; - - let is_tool_call_completed = - matches!(tool_call.status, acp_thread::ToolCallStatus::Completed); - - for terminal in terminals { - match views.entry(terminal.entity_id()) { - collections::hash_map::Entry::Vacant(entry) => { - let element = create_terminal( - self.workspace.clone(), - self.project.clone(), - terminal.clone(), - window, - cx, - ) - .into_any(); - cx.emit(EntryViewEvent { - entry_index: index, - view_event: ViewEvent::NewTerminal(id.clone()), - }); - entry.insert(element); - } - collections::hash_map::Entry::Occupied(_entry) => { - if is_tool_call_completed && terminal.read(cx).output().is_none() { - cx.emit(EntryViewEvent { - entry_index: index, - view_event: ViewEvent::TerminalMovedToBackground(id.clone()), - }); - } - } - } - } - - for diff in diffs { - views.entry(diff.entity_id()).or_insert_with(|| { - let element = create_editor_diff(diff.clone(), window, cx).into_any(); - cx.emit(EntryViewEvent { - entry_index: index, - view_event: ViewEvent::NewDiff(id.clone()), - }); - element - }); - } - } - AgentThreadEntry::AssistantMessage(message) => { - let entry = if let Some(Entry::AssistantMessage(entry)) = - self.entries.get_mut(index) - { - entry - } else { - self.set_entry( - index, - Entry::AssistantMessage(AssistantMessageEntry::default()), - ); - let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else { - unreachable!() - }; - entry - }; - entry.sync(message); - } - }; - } - - fn set_entry(&mut self, index: usize, entry: Entry) { - if index == self.entries.len() { - self.entries.push(entry); - } else { - self.entries[index] = entry; - } - } - - pub fn remove(&mut self, range: Range) { - self.entries.drain(range); - } - - pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) { - for entry in self.entries.iter() { - match entry { - Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} - Entry::Content(response_views) => { - for view in response_views.values() { - if let Ok(diff_editor) = view.clone().downcast::() { - diff_editor.update(cx, |diff_editor, cx| { - diff_editor.set_text_style_refinement( - diff_editor_text_style_refinement(cx), - ); - cx.notify(); - }) - } - } - } - } - } - } -} - -impl EventEmitter for EntryViewState {} - -pub struct EntryViewEvent { - pub entry_index: usize, - pub view_event: ViewEvent, -} - -pub enum ViewEvent { - NewDiff(ToolCallId), - NewTerminal(ToolCallId), - TerminalMovedToBackground(ToolCallId), - MessageEditorEvent(Entity, MessageEditorEvent), -} - -#[derive(Default, Debug)] -pub struct AssistantMessageEntry { - scroll_handles_by_chunk_index: HashMap, -} - -impl AssistantMessageEntry { - pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option { - self.scroll_handles_by_chunk_index.get(&ix).cloned() - } - - pub fn sync(&mut self, message: &acp_thread::AssistantMessage) { - if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() { - let ix = message.chunks.len() - 1; - let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default(); - handle.scroll_to_bottom(); - } - } -} - -#[derive(Debug)] -pub enum Entry { - UserMessage(Entity), - AssistantMessage(AssistantMessageEntry), - Content(HashMap), -} - -impl Entry { - pub fn focus_handle(&self, cx: &App) -> Option { - match self { - Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), - Self::AssistantMessage(_) | Self::Content(_) => None, - } - } - - pub fn message_editor(&self) -> Option<&Entity> { - match self { - Self::UserMessage(editor) => Some(editor), - Self::AssistantMessage(_) | Self::Content(_) => None, - } - } - - pub fn editor_for_diff(&self, diff: &Entity) -> Option> { - self.content_map()? - .get(&diff.entity_id()) - .cloned() - .map(|entity| entity.downcast::().unwrap()) - } - - pub fn terminal( - &self, - terminal: &Entity, - ) -> Option> { - self.content_map()? - .get(&terminal.entity_id()) - .cloned() - .map(|entity| entity.downcast::().unwrap()) - } - - pub fn scroll_handle_for_assistant_message_chunk( - &self, - chunk_ix: usize, - ) -> Option { - match self { - Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), - Self::UserMessage(_) | Self::Content(_) => None, - } - } - - fn content_map(&self) -> Option<&HashMap> { - match self { - Self::Content(map) => Some(map), - _ => None, - } - } - - fn empty() -> Self { - Self::Content(HashMap::default()) - } - - #[cfg(test)] - pub fn has_content(&self) -> bool { - match self { - Self::Content(map) => !map.is_empty(), - Self::UserMessage(_) | Self::AssistantMessage(_) => false, - } - } -} - -fn create_terminal( - workspace: WeakEntity, - project: WeakEntity, - terminal: Entity, - window: &mut Window, - cx: &mut App, -) -> Entity { - cx.new(|cx| { - let mut view = TerminalView::new( - terminal.read(cx).inner().clone(), - workspace, - None, - project, - window, - cx, - ); - view.set_embedded_mode(Some(1000), cx); - view - }) -} - -fn create_editor_diff( - diff: Entity, - window: &mut Window, - cx: &mut App, -) -> Entity { - cx.new(|cx| { - let mut editor = Editor::new( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::SizeByContent, - }, - diff.read(cx).multibuffer().clone(), - None, - window, - cx, - ); - editor.set_show_gutter(false, cx); - editor.disable_inline_diagnostics(); - editor.disable_expand_excerpt_buttons(cx); - editor.set_show_vertical_scrollbar(false, cx); - editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); - editor.set_soft_wrap_mode(SoftWrap::None, cx); - editor.scroll_manager.set_forbid_vertical_scroll(true); - editor.set_show_indent_guides(false, cx); - editor.set_read_only(true); - editor.set_show_breakpoints(false, cx); - editor.set_show_code_actions(false, cx); - editor.set_show_git_diff_gutter(false, cx); - editor.set_expand_all_diff_hunks(cx); - editor.set_text_style_refinement(diff_editor_text_style_refinement(cx)); - editor - }) -} - -fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement { - TextStyleRefinement { - font_size: Some( - TextSize::Small - .rems(cx) - .to_pixels(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) - .into(), - ), - ..Default::default() - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - use std::rc::Rc; - - use acp_thread::{AgentConnection, StubAgentConnection}; - use agent_client_protocol as acp; - use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; - use editor::RowInfo; - use fs::FakeFs; - use gpui::{AppContext as _, TestAppContext}; - - use crate::acp::entry_view_state::EntryViewState; - use multi_buffer::MultiBufferRow; - use pretty_assertions::assert_matches; - use project::Project; - use serde_json::json; - use settings::SettingsStore; - use util::path; - use workspace::MultiWorkspace; - - #[gpui::test] - async fn test_diff_sync(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "hello.txt": "hi world" - }), - ) - .await; - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - 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| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/project")), cx) - }) - .await - .unwrap(); - let session_id = thread.update(cx, |thread, _| thread.session_id().clone()); - - cx.update(|_, cx| { - connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx) - }); - - let thread_store = None; - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let view_state = cx.new(|_cx| { - EntryViewState::new( - workspace.downgrade(), - project.downgrade(), - thread_store, - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - ) - }); - - view_state.update_in(cx, |view_state, window, cx| { - view_state.sync_entry(0, &thread, window, cx) - }); - - let diff = thread.read_with(cx, |thread, _| { - thread - .entries() - .get(0) - .unwrap() - .diffs() - .next() - .unwrap() - .clone() - }); - - cx.run_until_parked(); - - let diff_editor = view_state.read_with(cx, |view_state, _cx| { - view_state.entry(0).unwrap().editor_for_diff(&diff).unwrap() - }); - assert_eq!( - diff_editor.read_with(cx, |editor, cx| editor.text(cx)), - "hi world\nhello world" - ); - let row_infos = diff_editor.read_with(cx, |editor, cx| { - let multibuffer = editor.buffer().read(cx); - multibuffer - .snapshot(cx) - .row_infos(MultiBufferRow(0)) - .collect::>() - }); - assert_matches!( - row_infos.as_slice(), - [ - RowInfo { - multibuffer_row: Some(MultiBufferRow(0)), - diff_status: Some(DiffHunkStatus { - kind: DiffHunkStatusKind::Deleted, - .. - }), - .. - }, - RowInfo { - multibuffer_row: Some(MultiBufferRow(1)), - diff_status: Some(DiffHunkStatus { - kind: DiffHunkStatusKind::Added, - .. - }), - .. - } - ] - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - release_channel::init(semver::Version::new(0, 0, 0), cx); - }); - } -} diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs deleted file mode 100644 index 6710969bd89b3e..00000000000000 --- a/crates/agent_ui/src/acp/message_editor.rs +++ /dev/null @@ -1,3460 +0,0 @@ -use crate::SendImmediately; -use crate::acp::AcpThreadHistory; -use crate::{ - ChatWithFollow, - completion_provider::{ - PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, - PromptContextType, SlashCommandCompletion, - }, - mention_set::{ - Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, - }, -}; -use acp_thread::{AgentSessionInfo, MentionUri}; -use agent::ThreadStore; -use agent_client_protocol as acp; -use anyhow::{Result, anyhow}; -use collections::HashSet; -use editor::{ - Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, - EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, - MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu, - scroll::Autoscroll, -}; -use futures::{FutureExt as _, future::join_all}; -use gpui::{ - 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}; -use prompt_store::PromptStore; -use rope::Point; -use settings::Settings; -use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc}; -use theme::ThemeSettings; -use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*}; -use util::paths::PathStyle; -use util::{ResultExt, debug_panic}; -use workspace::{CollaboratorId, Workspace}; -use zed_actions::agent::{Chat, PasteRaw}; - -pub struct MessageEditor { - mention_set: Entity, - editor: Entity, - workspace: WeakEntity, - prompt_capabilities: Rc>, - available_commands: Rc>>, - agent_name: SharedString, - thread_store: Option>, - _subscriptions: Vec, - _parse_slash_command_task: Task<()>, -} - -#[derive(Clone, Copy, Debug)] -pub enum MessageEditorEvent { - Send, - SendImmediately, - Cancel, - Focus, - LostFocus, -} - -impl EventEmitter for MessageEditor {} - -const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0); - -impl PromptCompletionProviderDelegate for Entity { - fn supports_images(&self, cx: &App) -> bool { - self.read(cx).prompt_capabilities.borrow().image - } - - fn supported_modes(&self, cx: &App) -> Vec { - let mut supported = vec![PromptContextType::File, PromptContextType::Symbol]; - if self.read(cx).prompt_capabilities.borrow().embedded_context { - if self.read(cx).thread_store.is_some() { - supported.push(PromptContextType::Thread); - } - supported.extend(&[ - PromptContextType::Diagnostics, - PromptContextType::Fetch, - PromptContextType::Rules, - ]); - } - supported - } - - fn available_commands(&self, cx: &App) -> Vec { - self.read(cx) - .available_commands - .borrow() - .iter() - .map(|cmd| crate::completion_provider::AvailableCommand { - name: cmd.name.clone().into(), - description: cmd.description.clone().into(), - requires_argument: cmd.input.is_some(), - }) - .collect() - } - - fn confirm_command(&self, cx: &mut App) { - self.update(cx, |this, cx| this.send(cx)); - } -} - -impl MessageEditor { - pub fn new( - workspace: WeakEntity, - project: WeakEntity, - thread_store: Option>, - history: WeakEntity, - prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, - agent_name: SharedString, - placeholder: &str, - mode: EditorMode, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let language = Language::new( - language::LanguageConfig { - completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']), - ..Default::default() - }, - None, - ); - - let editor = cx.new(|cx| { - let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - - 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 { - min_entries_visible: 12, - max_entries_visible: 12, - placement: Some(ContextMenuPlacement::Above), - }); - editor.register_addon(MessageEditorAddon::new()); - - editor.set_custom_context_menu(|editor, _point, window, cx| { - let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx)); - - Some(ContextMenu::build(window, cx, |menu, _, _| { - menu.action("Cut", Box::new(editor::actions::Cut)) - .action_disabled_when( - !has_selection, - "Copy", - Box::new(editor::actions::Copy), - ) - .action("Paste", Box::new(editor::actions::Paste)) - })) - }); - - editor - }); - let mention_set = - cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone())); - let completion_provider = Rc::new(PromptCompletionProvider::new( - cx.entity(), - editor.downgrade(), - mention_set.clone(), - history, - prompt_store.clone(), - workspace.clone(), - )); - editor.update(cx, |editor, _cx| { - editor.set_completion_provider(Some(completion_provider.clone())) - }); - - cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| { - cx.emit(MessageEditorEvent::Focus) - }) - .detach(); - cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| { - cx.emit(MessageEditorEvent::LostFocus) - }) - .detach(); - - let mut has_hint = false; - let mut subscriptions = Vec::new(); - - subscriptions.push(cx.subscribe_in(&editor, window, { - move |this, editor, event, window, cx| { - if let EditorEvent::Edited { .. } = event - && !editor.read(cx).read_only(cx) - { - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - this.mention_set - .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot)); - - let new_hints = this - .command_hint(snapshot.buffer()) - .into_iter() - .collect::>(); - let has_new_hint = !new_hints.is_empty(); - editor.splice_inlays( - if has_hint { - &[COMMAND_HINT_INLAY_ID] - } else { - &[] - }, - new_hints, - cx, - ); - has_hint = has_new_hint; - }); - cx.notify(); - } - } - })); - - Self { - editor, - mention_set, - workspace, - prompt_capabilities, - available_commands, - agent_name, - thread_store, - _subscriptions: subscriptions, - _parse_slash_command_task: Task::ready(()), - } - } - - pub fn set_command_state( - &mut self, - prompt_capabilities: Rc>, - available_commands: Rc>>, - _cx: &mut Context, - ) { - self.prompt_capabilities = prompt_capabilities; - self.available_commands = available_commands; - } - - fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option { - let available_commands = self.available_commands.borrow(); - if available_commands.is_empty() { - return None; - } - - let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?; - if parsed_command.argument.is_some() { - return None; - } - - let command_name = parsed_command.command?; - let available_command = available_commands - .iter() - .find(|command| command.name == command_name)?; - - 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() { - hint_pos = snapshot.len(); - hint.insert(0, ' '); - } - - let hint_pos = snapshot.anchor_after(hint_pos); - - Some(Inlay::hint( - COMMAND_HINT_INLAY_ID, - hint_pos, - &InlayHint { - position: hint_pos.text_anchor, - label: InlayHintLabel::String(hint), - kind: Some(InlayHintKind::Parameter), - padding_left: false, - padding_right: false, - tooltip: None, - resolve_state: project::ResolveState::Resolved, - }, - )) - } - - pub fn insert_thread_summary( - &mut self, - thread: AgentSessionInfo, - window: &mut Window, - cx: &mut Context, - ) { - if self.thread_store.is_none() { - return; - } - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - let thread_title = thread - .title - .clone() - .filter(|title| !title.is_empty()) - .unwrap_or_else(|| SharedString::new_static("New Thread")); - let uri = MentionUri::Thread { - id: thread.session_id, - name: thread_title.to_string(), - }; - let content = format!("{}\n", uri.as_link()); - - let content_len = content.len() - 1; - - let start = self.editor.update(cx, |editor, cx| { - editor.set_text(content, window, cx); - editor - .buffer() - .read(cx) - .snapshot(cx) - .anchor_before(Point::zero()) - .text_anchor - }); - - let supports_images = self.prompt_capabilities.borrow().image; - - self.mention_set - .update(cx, |mention_set, cx| { - mention_set.confirm_mention_completion( - thread_title, - start, - content_len, - uri, - supports_images, - self.editor.clone(), - &workspace, - window, - cx, - ) - }) - .detach(); - } - - #[cfg(test)] - pub(crate) fn editor(&self) -> &Entity { - &self.editor - } - - pub fn is_empty(&self, cx: &App) -> bool { - self.editor.read(cx).is_empty(cx) - } - - pub fn is_completions_menu_visible(&self, cx: &App) -> bool { - self.editor - .read(cx) - .context_menu() - .borrow() - .as_ref() - .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()) - } - - #[cfg(test)] - pub fn mention_set(&self) -> &Entity { - &self.mention_set - } - - fn validate_slash_commands( - text: &str, - available_commands: &[acp::AvailableCommand], - agent_name: &str, - ) -> Result<()> { - if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) { - if let Some(command_name) = parsed_command.command { - // Check if this command is in the list of available commands from the server - let is_supported = available_commands - .iter() - .any(|cmd| cmd.name == command_name); - - if !is_supported { - return Err(anyhow!( - "The /{} command is not supported by {}.\n\nAvailable commands: {}", - command_name, - agent_name, - if available_commands.is_empty() { - "none".to_string() - } else { - available_commands - .iter() - .map(|cmd| format!("/{}", cmd.name)) - .collect::>() - .join(", ") - } - )); - } - } - } - Ok(()) - } - - pub fn contents( - &self, - full_mention_content: bool, - cx: &mut Context, - ) -> Task, Vec>)>> { - let text = self.editor.read(cx).text(cx); - let available_commands = self.available_commands.borrow().clone(); - let agent_name = self.agent_name.clone(); - - let contents = self - .mention_set - .update(cx, |store, cx| store.contents(full_mention_content, cx)); - let editor = self.editor.clone(); - let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context; - - cx.spawn(async move |_, cx| { - Self::validate_slash_commands(&text, &available_commands, &agent_name)?; - - let contents = contents.await?; - let mut all_tracked_buffers = Vec::new(); - - let result = editor.update(cx, |editor, cx| { - let (mut ix, _) = text - .char_indices() - .find(|(_, c)| !c.is_whitespace()) - .unwrap_or((0, '\0')); - let mut chunks: Vec = Vec::new(); - let text = editor.text(cx); - editor.display_map.update(cx, |map, cx| { - let snapshot = map.snapshot(cx); - for (crease_id, crease) in snapshot.crease_snapshot.creases() { - let Some((uri, mention)) = contents.get(&crease_id) else { - continue; - }; - - let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot()); - if crease_range.start.0 > ix { - let chunk = text[ix..crease_range.start.0].into(); - chunks.push(chunk); - } - let chunk = match mention { - Mention::Text { - content, - tracked_buffers, - } => { - all_tracked_buffers.extend(tracked_buffers.iter().cloned()); - if supports_embedded_context { - acp::ContentBlock::Resource(acp::EmbeddedResource::new( - acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents::new( - content.clone(), - uri.to_uri().to_string(), - ), - ), - )) - } else { - acp::ContentBlock::ResourceLink(acp::ResourceLink::new( - uri.name(), - uri.to_uri().to_string(), - )) - } - } - 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 => { - debug_panic!( - "unexpected mention uri for image: {:?}", - other - ); - None - } - }), - ), - Mention::Link => acp::ContentBlock::ResourceLink( - acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()), - ), - }; - chunks.push(chunk); - ix = crease_range.end.0; - } - - if ix < text.len() { - let last_chunk = text[ix..].trim_end().to_owned(); - if !last_chunk.is_empty() { - chunks.push(last_chunk.into()); - } - } - }); - anyhow::Ok((chunks, all_tracked_buffers)) - })?; - Ok(result) - }) - } - - pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.clear(window, cx); - editor.remove_creases( - self.mention_set.update(cx, |mention_set, _cx| { - mention_set - .clear() - .map(|(crease_id, _)| crease_id) - .collect::>() - }), - cx, - ) - }); - } - - pub fn send(&mut self, cx: &mut Context) { - if !self.is_empty(cx) { - self.editor.update(cx, |editor, cx| { - editor.clear_inlay_hints(cx); - }); - } - cx.emit(MessageEditorEvent::Send) - } - - pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context) { - self.insert_context_prefix("@", window, cx); - } - - pub fn insert_context_type( - &mut self, - context_keyword: &str, - window: &mut Window, - cx: &mut Context, - ) { - let prefix = format!("@{}", context_keyword); - self.insert_context_prefix(&prefix, window, cx); - } - - fn insert_context_prefix(&mut self, prefix: &str, window: &mut Window, cx: &mut Context) { - let editor = self.editor.clone(); - let prefix = prefix.to_string(); - - cx.spawn_in(window, async move |_, cx| { - editor - .update_in(cx, |editor, window, cx| { - let menu_is_open = - editor.context_menu().borrow().as_ref().is_some_and(|menu| { - matches!(menu, CodeContextMenu::Completions(_)) && menu.visible() - }); - - let has_prefix = { - let snapshot = editor.display_snapshot(cx); - let cursor = editor.selections.newest::(&snapshot).head(); - let offset = cursor.to_offset(&snapshot); - let buffer_snapshot = snapshot.buffer_snapshot(); - let prefix_char_count = prefix.chars().count(); - buffer_snapshot - .reversed_chars_at(offset) - .take(prefix_char_count) - .eq(prefix.chars().rev()) - }; - - if menu_is_open && has_prefix { - return; - } - - editor.insert(&prefix, window, cx); - editor.show_completions(&editor::actions::ShowCompletions, window, cx); - }) - .log_err(); - }) - .detach(); - } - - fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context) { - self.send(cx); - } - - fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context) { - if self.is_empty(cx) { - return; - } - - self.editor.update(cx, |editor, cx| { - editor.clear_inlay_hints(cx); - }); - - cx.emit(MessageEditorEvent::SendImmediately) - } - - fn chat_with_follow( - &mut self, - _: &ChatWithFollow, - window: &mut Window, - cx: &mut Context, - ) { - self.workspace - .update(cx, |this, cx| { - this.follow(CollaboratorId::Agent, window, cx) - }) - .log_err(); - - self.send(cx); - } - - fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(MessageEditorEvent::Cancel) - } - - 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) - }) - .ok_or_else(|| "project path not found".to_string())?; - - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await - .map_err(|e| e.to_string())?; - - Ok(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()], - } - })) - } - }) - .shared(); - - self.mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task) - }); - } - } - return; - } - // Handle text paste with potential markdown mention links. - // This must be checked BEFORE paste_images_as_context because that function - // returns a task even when there are no images in the clipboard. - if let Some(clipboard_text) = cx - .read_from_clipboard() - .and_then(|item| item.entries().first().cloned()) - .and_then(|entry| match entry { - ClipboardEntry::String(text) => Some(text.text().to_string()), - _ => None, - }) - { - if clipboard_text.contains("[@") { - cx.stop_propagation(); - let selections_before = self.editor.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - editor - .selections - .disjoint_anchors() - .iter() - .map(|selection| { - ( - selection.start.bias_left(&snapshot), - selection.end.bias_right(&snapshot), - ) - }) - .collect::>() - }); - - self.editor.update(cx, |editor, cx| { - editor.insert(&clipboard_text, window, cx); - }); - - let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); - let path_style = workspace.read(cx).project().read(cx).path_style(cx); - - let mut all_mentions = Vec::new(); - for (start_anchor, end_anchor) in selections_before { - let start_offset = start_anchor.to_offset(&snapshot); - let end_offset = end_anchor.to_offset(&snapshot); - - // Get the actual inserted text from the buffer (may differ due to auto-indent) - let inserted_text: String = - snapshot.text_for_range(start_offset..end_offset).collect(); - - let parsed_mentions = parse_mention_links(&inserted_text, path_style); - for (range, mention_uri) in parsed_mentions { - let mention_start_offset = MultiBufferOffset(start_offset.0 + range.start); - let anchor = snapshot.anchor_before(mention_start_offset); - let content_len = range.end - range.start; - all_mentions.push((anchor, content_len, mention_uri)); - } - } - - if !all_mentions.is_empty() { - let supports_images = self.prompt_capabilities.borrow().image; - let http_client = workspace.read(cx).client().http_client(); - - for (anchor, content_len, mention_uri) in all_mentions { - let Some((crease_id, tx)) = insert_crease_for_mention( - anchor.excerpt_id, - anchor.text_anchor, - content_len, - mention_uri.name().into(), - mention_uri.icon_path(cx), - None, - self.editor.clone(), - window, - cx, - ) else { - continue; - }; - - // Create the confirmation task based on the mention URI type. - // This properly loads file content, fetches URLs, etc. - let task = self.mention_set.update(cx, |mention_set, cx| { - mention_set.confirm_mention_for_uri( - mention_uri.clone(), - supports_images, - http_client.clone(), - cx, - ) - }); - let task = cx - .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) - .shared(); - - self.mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone()) - }); - - // Drop the tx after inserting to signal the crease is ready - drop(tx); - } - return; - } - } - } - - if self.prompt_capabilities.borrow().image - && let Some(task) = paste_images_as_context( - self.editor.clone(), - self.mention_set.clone(), - self.workspace.clone(), - window, - cx, - ) - { - task.detach(); - return; - } - - // Fall through to default editor paste - cx.propagate(); - } - - 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, - added_worktrees: Vec>, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - 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) = project.read(cx).entry_for_path(&path, cx) else { - continue; - }; - let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else { - continue; - }; - let abs_path = worktree.read(cx).absolutize(&path.path); - let (file_name, _) = crate::completion_provider::extract_file_name_and_directory( - &path.path, - worktree.read(cx).root_name(), - path_style, - ); - - let uri = if entry.is_dir() { - MentionUri::Directory { abs_path } - } else { - MentionUri::File { abs_path } - }; - - let new_text = format!("{} ", uri.as_link()); - let content_len = new_text.len() - 1; - - let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); - - self.editor.update(cx, |message_editor, cx| { - message_editor.edit( - [( - multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), - new_text, - )], - cx, - ); - }); - let supports_images = self.prompt_capabilities.borrow().image; - tasks.push(self.mention_set.update(cx, |mention_set, cx| { - mention_set.confirm_mention_completion( - file_name, - anchor, - content_len, - uri, - supports_images, - self.editor.clone(), - &workspace, - window, - cx, - ) - })); - } - cx.spawn(async move |_, _| { - join_all(tasks).await; - drop(added_worktrees); - }) - .detach(); - } - - /// Inserts code snippets as creases into the editor. - /// Each tuple contains (code_text, crease_title). - pub fn insert_code_creases( - &mut self, - creases: Vec<(String, String)>, - window: &mut Window, - cx: &mut Context, - ) { - self.editor.update(cx, |editor, cx| { - editor.insert("\n", window, cx); - }); - for (text, crease_title) in creases { - self.insert_crease_impl(text, crease_title, IconName::TextSnippet, true, window, cx); - } - } - - pub fn insert_terminal_crease( - &mut self, - text: String, - window: &mut Window, - cx: &mut Context, - ) { - let line_count = text.lines().count() as u32; - let mention_uri = MentionUri::TerminalSelection { line_count }; - 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 = editor - .selections - .newest_anchor() - .start - .text_anchor - .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, - mention_uri.name().into(), - mention_uri.icon_path(cx), - None, - self.editor.clone(), - window, - cx, - ) else { - return; - }; - drop(tx); - - let mention_task = Task::ready(Ok(Mention::Text { - content: text, - tracked_buffers: vec![], - })) - .shared(); - - self.mention_set.update(cx, |mention_set, _| { - mention_set.insert_mention(crease_id, mention_uri, mention_task); - }); - } - - fn insert_crease_impl( - &mut self, - text: String, - title: String, - icon: IconName, - add_trailing_newline: bool, - window: &mut Window, - cx: &mut Context, - ) { - use editor::display_map::{Crease, FoldPlaceholder}; - use multi_buffer::MultiBufferRow; - use rope::Point; - - self.editor.update(cx, |editor, cx| { - let point = editor - .selections - .newest::(&editor.display_snapshot(cx)) - .head(); - let start_row = MultiBufferRow(point.row); - - editor.insert(&text, window, cx); - - let snapshot = editor.buffer().read(cx).snapshot(cx); - let anchor_before = snapshot.anchor_after(point); - let anchor_after = editor - .selections - .newest_anchor() - .head() - .bias_left(&snapshot); - - if add_trailing_newline { - editor.insert("\n", window, cx); - } - - let fold_placeholder = FoldPlaceholder { - render: Arc::new({ - let title = title.clone(); - move |_fold_id, _fold_range, _cx| { - ButtonLike::new("crease") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ElevatedSurface) - .child(Icon::new(icon)) - .child(Label::new(title.clone()).single_line()) - .into_any_element() - } - }), - merge_adjacent: false, - ..Default::default() - }; - - let crease = Crease::inline( - anchor_before..anchor_after, - fold_placeholder, - |row, is_folded, fold, _window, _cx| { - Disclosure::new(("crease-toggle", row.0 as u64), !is_folded) - .toggle_state(is_folded) - .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) - .into_any_element() - }, - |_, _, _, _| gpui::Empty.into_any(), - ); - editor.insert_creases(vec![crease], cx); - editor.fold_at(start_row, window, cx); - }); - } - - pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context) { - let editor = self.editor.read(cx); - let editor_buffer = editor.buffer().read(cx); - let Some(buffer) = editor_buffer.as_singleton() else { - return; - }; - let cursor_anchor = editor.selections.newest_anchor().head(); - let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx)); - let anchor = buffer.update(cx, |buffer, _cx| { - buffer.anchor_before(cursor_offset.0.min(buffer.len())) - }); - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - let Some(completion) = - PromptCompletionProvider::>::completion_for_action( - PromptContextAction::AddSelections, - anchor..anchor, - self.editor.downgrade(), - self.mention_set.downgrade(), - &workspace, - cx, - ) - else { - return; - }; - - self.editor.update(cx, |message_editor, cx| { - message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx); - message_editor.request_autoscroll(Autoscroll::fit(), cx); - }); - if let Some(confirm) = completion.confirm { - confirm(CompletionIntent::Complete, window, cx); - } - } - - pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context) { - if !self.prompt_capabilities.borrow().image { - return; - } - - let editor = self.editor.clone(); - let mention_set = self.mention_set.clone(); - let workspace = self.workspace.clone(); - - let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions { - files: true, - directories: false, - multiple: true, - prompt: Some("Select Images".into()), - }); - - window - .spawn(cx, async move |cx| { - let paths = match paths_receiver.await { - Ok(Ok(Some(paths))) => paths, - _ => return Ok::<(), anyhow::Error>(()), - }; - - let supported_formats = [ - ("png", gpui::ImageFormat::Png), - ("jpg", gpui::ImageFormat::Jpeg), - ("jpeg", gpui::ImageFormat::Jpeg), - ("webp", gpui::ImageFormat::Webp), - ("gif", gpui::ImageFormat::Gif), - ("bmp", gpui::ImageFormat::Bmp), - ("tiff", gpui::ImageFormat::Tiff), - ("tif", gpui::ImageFormat::Tiff), - ("ico", gpui::ImageFormat::Ico), - ]; - - let mut images = Vec::new(); - for path in paths { - let extension = path - .extension() - .and_then(|ext| ext.to_str()) - .map(|s| s.to_lowercase()); - - let Some(format) = extension.and_then(|ext| { - supported_formats - .iter() - .find(|(e, _)| *e == ext) - .map(|(_, f)| *f) - }) else { - continue; - }; - - let Ok(content) = async_fs::read(&path).await else { - continue; - }; - - images.push(gpui::Image::from_bytes(format, content)); - } - - crate::mention_set::insert_images_as_context( - images, - editor, - mention_set, - workspace, - cx, - ) - .await; - Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context) { - self.editor.update(cx, |message_editor, cx| { - message_editor.set_read_only(read_only); - cx.notify() - }) - } - - pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.set_mode(mode); - cx.notify() - }); - } - - pub fn set_message( - &mut self, - message: Vec, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - self.clear(window, cx); - - let path_style = workspace.read(cx).project().read(cx).path_style(cx); - let mut text = String::new(); - let mut mentions = Vec::new(); - - for chunk in message { - match chunk { - acp::ContentBlock::Text(text_content) => { - text.push_str(&text_content.text); - } - acp::ContentBlock::Resource(acp::EmbeddedResource { - resource: acp::EmbeddedResourceResource::TextResourceContents(resource), - .. - }) => { - let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err() - else { - continue; - }; - let start = text.len(); - write!(&mut text, "{}", mention_uri.as_link()).ok(); - let end = text.len(); - mentions.push(( - start..end, - mention_uri, - Mention::Text { - content: resource.text, - tracked_buffers: Vec::new(), - }, - )); - } - acp::ContentBlock::ResourceLink(resource) => { - if let Some(mention_uri) = - MentionUri::parse(&resource.uri, path_style).log_err() - { - let start = text.len(); - write!(&mut text, "{}", mention_uri.as_link()).ok(); - let end = text.len(); - mentions.push((start..end, mention_uri, Mention::Link)); - } - } - acp::ContentBlock::Image(acp::ImageContent { - uri, - data, - mime_type, - .. - }) => { - let mention_uri = if let Some(uri) = uri { - MentionUri::parse(&uri, path_style) - } else { - Ok(MentionUri::PastedImage) - }; - let Some(mention_uri) = mention_uri.log_err() else { - continue; - }; - let Some(format) = ImageFormat::from_mime_type(&mime_type) else { - log::error!("failed to parse MIME type for image: {mime_type:?}"); - continue; - }; - let start = text.len(); - write!(&mut text, "{}", mention_uri.as_link()).ok(); - let end = text.len(); - mentions.push(( - start..end, - mention_uri, - Mention::Image(MentionImage { - data: data.into(), - format, - }), - )); - } - _ => {} - } - } - - let snapshot = self.editor.update(cx, |editor, cx| { - editor.set_text(text, window, cx); - editor.buffer().read(cx).snapshot(cx) - }); - - for (range, mention_uri, mention) in mentions { - let anchor = snapshot.anchor_before(MultiBufferOffset(range.start)); - let Some((crease_id, tx)) = insert_crease_for_mention( - anchor.excerpt_id, - anchor.text_anchor, - range.end - range.start, - mention_uri.name().into(), - mention_uri.icon_path(cx), - None, - self.editor.clone(), - window, - cx, - ) else { - continue; - }; - drop(tx); - - self.mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention( - crease_id, - mention_uri.clone(), - Task::ready(Ok(mention)).shared(), - ) - }); - } - cx.notify(); - } - - pub fn text(&self, cx: &App) -> String { - self.editor.read(cx).text(cx) - } - - pub fn set_placeholder_text( - &mut self, - placeholder: &str, - window: &mut Window, - cx: &mut Context, - ) { - self.editor.update(cx, |editor, cx| { - editor.set_placeholder_text(placeholder, window, cx); - }); - } - - #[cfg(test)] - pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.set_text(text, window, cx); - }); - } -} - -impl Focusable for MessageEditor { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.editor.focus_handle(cx) - } -} - -impl Render for MessageEditor { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .key_context("MessageEditor") - .on_action(cx.listener(Self::chat)) - .on_action(cx.listener(Self::send_immediately)) - .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({ - let settings = ThemeSettings::get_global(cx); - - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: settings.agent_buffer_font_size(cx).into(), - font_weight: settings.buffer_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }; - - EditorElement::new( - &self.editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - syntax: cx.theme().syntax().clone(), - inlay_hints_style: editor::make_inlay_hints_style(cx), - ..Default::default() - }, - ) - }) - } -} - -pub struct MessageEditorAddon {} - -impl MessageEditorAddon { - pub fn new() -> Self { - Self {} - } -} - -impl Addon for MessageEditorAddon { - fn to_any(&self) -> &dyn std::any::Any { - self - } - - fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { - Some(self) - } - - fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) { - let settings = agent_settings::AgentSettings::get_global(cx); - if settings.use_modifier_to_send { - key_context.add("use_modifier_to_send"); - } - } -} - -/// Parses markdown mention links in the format `[@name](uri)` from text. -/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text. -fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range, MentionUri)> { - let mut mentions = Vec::new(); - let mut search_start = 0; - - while let Some(link_start) = text[search_start..].find("[@") { - let absolute_start = search_start + link_start; - - // Find the matching closing bracket for the name, handling nested brackets. - // Start at the '[' character so find_matching_bracket can track depth correctly. - let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else { - search_start = absolute_start + 2; - continue; - }; - let name_end = absolute_start + name_end; - - // Check for opening parenthesis immediately after - if text.get(name_end + 1..name_end + 2) != Some("(") { - search_start = name_end + 1; - continue; - } - - // Find the matching closing parenthesis for the URI, handling nested parens - let uri_start = name_end + 2; - let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else { - search_start = uri_start; - continue; - }; - let uri_end = name_end + 1 + uri_end_relative; - let link_end = uri_end + 1; - - let uri_str = &text[uri_start..uri_end]; - - // Try to parse the URI as a MentionUri - if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) { - mentions.push((absolute_start..link_end, mention_uri)); - } - - search_start = link_end; - } - - mentions -} - -/// Finds the position of the matching closing bracket, handling nested brackets. -/// The input `text` should start with the opening bracket. -/// Returns the index of the matching closing bracket relative to `text`. -fn find_matching_bracket(text: &str, open: char, close: char) -> Option { - let mut depth = 0; - for (index, character) in text.char_indices() { - if character == open { - depth += 1; - } else if character == close { - depth -= 1; - if depth == 0 { - return Some(index); - } - } - } - None -} - -#[cfg(test)] -mod tests { - use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc}; - - use acp_thread::{AgentSessionInfo, MentionUri}; - use agent::{ThreadStore, outline}; - use agent_client_protocol as acp; - use editor::{ - AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, - actions::Paste, - }; - - use fs::FakeFs; - use futures::StreamExt as _; - use gpui::{ - AppContext, ClipboardItem, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, - VisualTestContext, - }; - use language_model::LanguageModelRegistry; - use lsp::{CompletionContext, CompletionTriggerKind}; - use project::{CompletionIntent, Project, ProjectPath}; - use serde_json::json; - - use text::Point; - use ui::{App, Context, IntoElement, Render, SharedString, Window}; - use util::{path, paths::PathStyle, rel_path::rel_path}; - use workspace::{AppState, Item, MultiWorkspace}; - - use crate::acp::{ - message_editor::{Mention, MessageEditor, parse_mention_links}, - thread_view::tests::init_test, - }; - use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType}; - - #[test] - fn test_parse_mention_links() { - // Single file mention - let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 1); - assert_eq!(mentions[0].0, 0..text.len()); - assert!(matches!(mentions[0].1, MentionUri::File { .. })); - - // Multiple mentions - let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 2); - - // Text without mentions - let text = "Just some regular text without mentions"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 0); - - // Malformed mentions (should be skipped) - let text = "[@incomplete](invalid://uri) and [@missing]("; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 0); - - // Mixed content with valid mention - let text = "Before [@valid](file:///path/to/file) after"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 1); - assert_eq!(mentions[0].0.start, 7); - - // HTTP URL mention (Fetch) - let text = "Check out [@docs](https://example.com/docs) for more info"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 1); - assert!(matches!(mentions[0].1, MentionUri::Fetch { .. })); - - // Directory mention (trailing slash) - let text = "[@src](file:///path/to/src/)"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 1); - assert!(matches!(mentions[0].1, MentionUri::Directory { .. })); - - // Multiple different mention types - let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 3); - assert!(matches!(mentions[0].1, MentionUri::File { .. })); - assert!(matches!(mentions[1].1, MentionUri::Fetch { .. })); - assert!(matches!(mentions[2].1, MentionUri::Directory { .. })); - - // Adjacent mentions without separator - let text = "[@a](file:///a)[@b](file:///b)"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 2); - - // Regular markdown link (not a mention) should be ignored - let text = "[regular link](https://example.com)"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 0); - - // Incomplete mention link patterns - let text = "[@name] without url and [@name( malformed"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 0); - - // Nested brackets in name portion - let text = "[@name [with brackets]](file:///path/to/file)"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 1); - assert_eq!(mentions[0].0, 0..text.len()); - - // Deeply nested brackets - let text = "[@outer [inner [deep]]](file:///path)"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 1); - - // Unbalanced brackets should fail gracefully - let text = "[@unbalanced [bracket](file:///path)"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 0); - - // Nested parentheses in URI (common in URLs with query params) - let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"; - let mentions = parse_mention_links(text, PathStyle::local()); - assert_eq!(mentions.len(), 1); - if let MentionUri::Fetch { url } = &mentions[0].1 { - assert!(url.as_str().contains("Rust_(programming_language)")); - } else { - panic!("Expected Fetch URI"); - } - } - - #[gpui::test] - async fn test_at_mention_removal(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({"file": ""})).await; - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = None; - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let message_editor = cx.update(|window, cx| { - cx.new(|cx| { - MessageEditor::new( - workspace.downgrade(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ) - }) - }); - let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone()); - - cx.run_until_parked(); - - let excerpt_id = editor.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .excerpt_ids() - .into_iter() - .next() - .unwrap() - }); - let completions = editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello @file ", window, cx); - let buffer = editor.buffer().read(cx).as_singleton().unwrap(); - let completion_provider = editor.completion_provider().unwrap(); - completion_provider.completions( - excerpt_id, - &buffer, - text::Anchor::MAX, - CompletionContext { - trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, - trigger_character: Some("@".into()), - }, - window, - cx, - ) - }); - let [_, completion]: [_; 2] = completions - .await - .unwrap() - .into_iter() - .flat_map(|response| response.completions) - .collect::>() - .try_into() - .unwrap(); - - editor.update_in(cx, |editor, window, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - let range = snapshot - .anchor_range_in_excerpt(excerpt_id, completion.replace_range) - .unwrap(); - editor.edit([(range, completion.new_text)], cx); - (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx); - }); - - cx.run_until_parked(); - - // Backspace over the inserted crease (and the following space). - editor.update_in(cx, |editor, window, cx| { - editor.backspace(&Default::default(), window, cx); - editor.backspace(&Default::default(), window, cx); - }); - - let (content, _) = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) - .await - .unwrap(); - - // We don't send a resource link for the deleted crease. - pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]); - } - - #[gpui::test] - async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/test", - json!({ - ".zed": { - "tasks.json": r#"[{"label": "test", "command": "echo"}]"# - }, - "src": { - "main.rs": "fn main() {}", - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let thread_store = None; - let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); - // Start with no available commands - simulating Claude which doesn't support slash commands - let available_commands = Rc::new(RefCell::new(vec![])); - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - let workspace_handle = workspace.downgrade(); - let message_editor = workspace.update_in(cx, |_, window, cx| { - cx.new(|cx| { - MessageEditor::new( - workspace_handle.clone(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - prompt_capabilities.clone(), - available_commands.clone(), - "Claude Agent".into(), - "Test", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ) - }) - }); - let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone()); - - // Test that slash commands fail when no available_commands are set (empty list means no commands supported) - editor.update_in(cx, |editor, window, cx| { - editor.set_text("/file test.txt", window, cx); - }); - - let contents_result = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) - .await; - - // Should fail because available_commands is empty (no commands supported) - assert!(contents_result.is_err()); - let error_message = contents_result.unwrap_err().to_string(); - assert!(error_message.contains("not supported by Claude Agent")); - 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::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| { - editor.set_text("/file test.txt", window, cx); - }); - - let contents_result = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) - .await; - - assert!(contents_result.is_err()); - let error_message = contents_result.unwrap_err().to_string(); - assert!(error_message.contains("not supported by Claude Agent")); - assert!(error_message.contains("/file")); - assert!(error_message.contains("Available commands: /help")); - - // Test that supported commands work fine - editor.update_in(cx, |editor, window, cx| { - editor.set_text("/help", window, cx); - }); - - let contents_result = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) - .await; - - // Should succeed because /help is in available_commands - assert!(contents_result.is_ok()); - - // Test that regular text works fine - editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello Claude!", window, cx); - }); - - let (content, _) = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) - .await - .unwrap(); - - assert_eq!(content.len(), 1); - if let acp::ContentBlock::Text(text) = &content[0] { - assert_eq!(text.text, "Hello Claude!"); - } else { - panic!("Expected ContentBlock::Text"); - } - - // Test that @ mentions still work - editor.update_in(cx, |editor, window, cx| { - editor.set_text("Check this @", window, cx); - }); - - // The @ mention functionality should not be affected - let (content, _) = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) - .await - .unwrap(); - - assert_eq!(content.len(), 1); - if let acp::ContentBlock::Text(text) = &content[0] { - assert_eq!(text.text, "Check this @"); - } else { - panic!("Expected ContentBlock::Text"); - } - } - - struct MessageEditorItem(Entity); - - impl Item for MessageEditorItem { - type Event = (); - - fn include_in_nav_history() -> bool { - false - } - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Test".into() - } - } - - impl EventEmitter<()> for MessageEditorItem {} - - impl Focusable for MessageEditorItem { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.0.read(cx).focus_handle(cx) - } - } - - impl Render for MessageEditorItem { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.0.clone().into_any_element() - } - } - - #[gpui::test] - async fn test_completion_provider_commands(cx: &mut TestAppContext) { - init_test(cx); - - let app_state = cx.update(AppState::test); - - cx.update(|cx| { - editor::init(cx); - workspace::init(app_state.clone(), cx); - }); - - let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = window - .read_with(cx, |mw, _| mw.workspace().clone()) - .unwrap(); - - let mut cx = VisualTestContext::from_window(window.into(), cx); - - let thread_store = None; - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); - let available_commands = Rc::new(RefCell::new(vec![ - 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| { - let workspace_handle = cx.weak_entity(); - let message_editor = cx.new(|cx| { - MessageEditor::new( - workspace_handle, - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - prompt_capabilities.clone(), - available_commands.clone(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - max_lines: None, - min_lines: 1, - }, - window, - cx, - ) - }); - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), - true, - true, - None, - window, - cx, - ); - }); - message_editor.read(cx).focus_handle(cx).focus(window, cx); - message_editor.read(cx).editor().clone() - }); - - cx.simulate_input("/"); - - editor.update_in(&mut cx, |editor, window, cx| { - assert_eq!(editor.text(cx), "/"); - assert!(editor.has_visible_completions_menu()); - - assert_eq!( - current_completion_labels_with_documentation(editor), - &[ - ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()), - ("say-hello".into(), "Say hello to whoever you want".into()) - ] - ); - editor.set_text("", window, cx); - }); - - cx.simulate_input("/qui"); - - editor.update_in(&mut cx, |editor, window, cx| { - assert_eq!(editor.text(cx), "/qui"); - assert!(editor.has_visible_completions_menu()); - - assert_eq!( - current_completion_labels_with_documentation(editor), - &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())] - ); - editor.set_text("", window, cx); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - assert!(editor.has_visible_completions_menu()); - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - cx.run_until_parked(); - - editor.update_in(&mut cx, |editor, window, cx| { - assert_eq!(editor.display_text(cx), "/quick-math "); - assert!(!editor.has_visible_completions_menu()); - editor.set_text("", window, cx); - }); - - cx.simulate_input("/say"); - - editor.update_in(&mut cx, |editor, _window, cx| { - assert_eq!(editor.display_text(cx), "/say"); - assert!(editor.has_visible_completions_menu()); - - assert_eq!( - current_completion_labels_with_documentation(editor), - &[("say-hello".into(), "Say hello to whoever you want".into())] - ); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - assert!(editor.has_visible_completions_menu()); - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - cx.run_until_parked(); - - editor.update_in(&mut cx, |editor, _window, cx| { - assert_eq!(editor.text(cx), "/say-hello "); - assert_eq!(editor.display_text(cx), "/say-hello "); - assert!(!editor.has_visible_completions_menu()); - }); - - cx.simulate_input("GPT5"); - - cx.run_until_parked(); - - editor.update_in(&mut cx, |editor, window, cx| { - assert_eq!(editor.text(cx), "/say-hello GPT5"); - assert_eq!(editor.display_text(cx), "/say-hello GPT5"); - assert!(!editor.has_visible_completions_menu()); - - // Delete argument - for _ in 0..5 { - editor.backspace(&editor::actions::Backspace, window, cx); - } - }); - - cx.run_until_parked(); - - editor.update_in(&mut cx, |editor, window, cx| { - assert_eq!(editor.text(cx), "/say-hello"); - // Hint is visible because argument was deleted - assert_eq!(editor.display_text(cx), "/say-hello "); - - // Delete last command letter - editor.backspace(&editor::actions::Backspace, window, cx); - }); - - cx.run_until_parked(); - - editor.update_in(&mut cx, |editor, _window, cx| { - // Hint goes away once command no longer matches an available one - assert_eq!(editor.text(cx), "/say-hell"); - assert_eq!(editor.display_text(cx), "/say-hell"); - assert!(!editor.has_visible_completions_menu()); - }); - } - - #[gpui::test] - async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) { - init_test(cx); - - let app_state = cx.update(AppState::test); - - cx.update(|cx| { - editor::init(cx); - workspace::init(app_state.clone(), cx); - }); - - app_state - .fs - .as_fake() - .insert_tree( - path!("/dir"), - json!({ - "editor": "", - "a": { - "one.txt": "1", - "two.txt": "2", - "three.txt": "3", - "four.txt": "4" - }, - "b": { - "five.txt": "5", - "six.txt": "6", - "seven.txt": "7", - "eight.txt": "8", - }, - "x.png": "", - }), - ) - .await; - - let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = window - .read_with(cx, |mw, _| mw.workspace().clone()) - .unwrap(); - - let worktree = project.update(cx, |project, cx| { - let mut worktrees = project.worktrees(cx).collect::>(); - assert_eq!(worktrees.len(), 1); - worktrees.pop().unwrap() - }); - let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); - - let mut cx = VisualTestContext::from_window(window.into(), cx); - - let paths = vec![ - rel_path("a/one.txt"), - rel_path("a/two.txt"), - rel_path("a/three.txt"), - rel_path("a/four.txt"), - rel_path("b/five.txt"), - rel_path("b/six.txt"), - rel_path("b/seven.txt"), - rel_path("b/eight.txt"), - ]; - - let slash = PathStyle::local().primary_separator(); - - let mut opened_editors = Vec::new(); - for path in paths { - let buffer = workspace - .update_in(&mut cx, |workspace, window, cx| { - workspace.open_path( - ProjectPath { - worktree_id, - path: path.into(), - }, - None, - false, - window, - cx, - ) - }) - .await - .unwrap(); - opened_editors.push(buffer); - } - - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); - - let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { - let workspace_handle = cx.weak_entity(); - let message_editor = cx.new(|cx| { - MessageEditor::new( - workspace_handle, - project.downgrade(), - Some(thread_store), - history.downgrade(), - None, - prompt_capabilities.clone(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - max_lines: None, - min_lines: 1, - }, - window, - cx, - ) - }); - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), - true, - true, - None, - window, - cx, - ); - }); - message_editor.read(cx).focus_handle(cx).focus(window, cx); - let editor = message_editor.read(cx).editor().clone(); - (message_editor, editor) - }); - - cx.simulate_input("Lorem @"); - - editor.update_in(&mut cx, |editor, window, cx| { - assert_eq!(editor.text(cx), "Lorem @"); - assert!(editor.has_visible_completions_menu()); - - assert_eq!( - current_completion_labels(editor), - &[ - format!("eight.txt b{slash}"), - format!("seven.txt b{slash}"), - format!("six.txt b{slash}"), - format!("five.txt b{slash}"), - "Files & Directories".into(), - "Symbols".into() - ] - ); - editor.set_text("", window, cx); - }); - - prompt_capabilities.replace( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ); - - cx.simulate_input("Lorem "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem "); - assert!(!editor.has_visible_completions_menu()); - }); - - cx.simulate_input("@"); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @"); - assert!(editor.has_visible_completions_menu()); - assert_eq!( - current_completion_labels(editor), - &[ - format!("eight.txt b{slash}"), - format!("seven.txt b{slash}"), - format!("six.txt b{slash}"), - format!("five.txt b{slash}"), - "Files & Directories".into(), - "Symbols".into(), - "Threads".into(), - "Fetch".into() - ] - ); - }); - - // Select and confirm "File" - editor.update_in(&mut cx, |editor, window, cx| { - assert!(editor.has_visible_completions_menu()); - editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); - editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); - editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); - editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - cx.run_until_parked(); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file "); - assert!(editor.has_visible_completions_menu()); - }); - - cx.simulate_input("one"); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file one"); - assert!(editor.has_visible_completions_menu()); - assert_eq!( - current_completion_labels(editor), - vec![format!("one.txt a{slash}")] - ); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - assert!(editor.has_visible_completions_menu()); - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - let url_one = MentionUri::File { - abs_path: path!("/dir/a/one.txt").into(), - } - .to_uri() - .to_string(); - editor.update(&mut cx, |editor, cx| { - let text = editor.text(cx); - assert_eq!(text, format!("Lorem [@one.txt]({url_one}) ")); - assert!(!editor.has_visible_completions_menu()); - assert_eq!(fold_ranges(editor, cx).len(), 1); - }); - - let contents = message_editor - .update(&mut cx, |message_editor, cx| { - message_editor - .mention_set() - .update(cx, |mention_set, cx| mention_set.contents(false, cx)) - }) - .await - .unwrap() - .into_values() - .collect::>(); - - { - let [(uri, Mention::Text { content, .. })] = contents.as_slice() else { - panic!("Unexpected mentions"); - }; - pretty_assertions::assert_eq!(content, "1"); - pretty_assertions::assert_eq!( - uri, - &MentionUri::parse(&url_one, PathStyle::local()).unwrap() - ); - } - - cx.simulate_input(" "); - - editor.update(&mut cx, |editor, cx| { - let text = editor.text(cx); - assert_eq!(text, format!("Lorem [@one.txt]({url_one}) ")); - assert!(!editor.has_visible_completions_menu()); - assert_eq!(fold_ranges(editor, cx).len(), 1); - }); - - cx.simulate_input("Ipsum "); - - editor.update(&mut cx, |editor, cx| { - let text = editor.text(cx); - assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),); - assert!(!editor.has_visible_completions_menu()); - assert_eq!(fold_ranges(editor, cx).len(), 1); - }); - - cx.simulate_input("@file "); - - editor.update(&mut cx, |editor, cx| { - let text = editor.text(cx); - assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),); - assert!(editor.has_visible_completions_menu()); - assert_eq!(fold_ranges(editor, cx).len(), 1); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - cx.run_until_parked(); - - let contents = message_editor - .update(&mut cx, |message_editor, cx| { - message_editor - .mention_set() - .update(cx, |mention_set, cx| mention_set.contents(false, cx)) - }) - .await - .unwrap() - .into_values() - .collect::>(); - - let url_eight = MentionUri::File { - abs_path: path!("/dir/b/eight.txt").into(), - } - .to_uri() - .to_string(); - - { - let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else { - panic!("Unexpected mentions"); - }; - pretty_assertions::assert_eq!(content, "8"); - pretty_assertions::assert_eq!( - uri, - &MentionUri::parse(&url_eight, PathStyle::local()).unwrap() - ); - } - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ") - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!(fold_ranges(editor, cx).len(), 2); - }); - - let plain_text_language = Arc::new(language::Language::new( - language::LanguageConfig { - name: "Plain Text".into(), - matcher: language::LanguageMatcher { - path_suffixes: vec!["txt".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - )); - - // Register the language and fake LSP - let language_registry = project.read_with(&cx, |project, _| project.languages().clone()); - language_registry.add(plain_text_language); - - let mut fake_language_servers = language_registry.register_fake_lsp( - "Plain Text", - language::FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - workspace_symbol_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - // Open the buffer to trigger LSP initialization - let buffer = project - .update(&mut cx, |project, cx| { - project.open_local_buffer(path!("/dir/a/one.txt"), cx) - }) - .await - .unwrap(); - - // Register the buffer with language servers - let _handle = project.update(&mut cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - - cx.run_until_parked(); - - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.set_request_handler::( - move |_, _| async move { - Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![ - #[allow(deprecated)] - lsp::SymbolInformation { - name: "MySymbol".into(), - location: lsp::Location { - uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(), - range: lsp::Range::new( - lsp::Position::new(0, 0), - lsp::Position::new(0, 1), - ), - }, - kind: lsp::SymbolKind::CONSTANT, - tags: None, - container_name: None, - deprecated: None, - }, - ]))) - }, - ); - - cx.simulate_input("@symbol "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ") - ); - assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - let symbol = MentionUri::Symbol { - abs_path: path!("/dir/a/one.txt").into(), - name: "MySymbol".into(), - line_range: 0..=0, - }; - - let contents = message_editor - .update(&mut cx, |message_editor, cx| { - message_editor - .mention_set() - .update(cx, |mention_set, cx| mention_set.contents(false, cx)) - }) - .await - .unwrap() - .into_values() - .collect::>(); - - { - let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else { - panic!("Unexpected mentions"); - }; - pretty_assertions::assert_eq!(content, "1"); - pretty_assertions::assert_eq!(uri, &symbol); - } - - cx.run_until_parked(); - - editor.read_with(&cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!( - "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ", - symbol.to_uri(), - ) - ); - }); - - // Try to mention an "image" file that will fail to load - cx.simulate_input("@file x.png"); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri()) - ); - assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), &["x.png "]); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - // Getting the message contents fails - message_editor - .update(&mut cx, |message_editor, cx| { - message_editor - .mention_set() - .update(cx, |mention_set, cx| mention_set.contents(false, cx)) - }) - .await - .expect_err("Should fail to load x.png"); - - cx.run_until_parked(); - - // Mention was removed - editor.read_with(&cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!( - "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ", - symbol.to_uri() - ) - ); - }); - - // Once more - cx.simulate_input("@file x.png"); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri()) - ); - assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), &["x.png "]); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - // This time don't immediately get the contents, just let the confirmed completion settle - cx.run_until_parked(); - - // Mention was removed - editor.read_with(&cx, |editor, cx| { - assert_eq!( - editor.text(cx), - format!( - "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ", - symbol.to_uri() - ) - ); - }); - - // Now getting the contents succeeds, because the invalid mention was removed - let contents = message_editor - .update(&mut cx, |message_editor, cx| { - message_editor - .mention_set() - .update(cx, |mention_set, cx| mention_set.contents(false, cx)) - }) - .await - .unwrap(); - assert_eq!(contents.len(), 3); - } - - fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec> { - let snapshot = editor.buffer().read(cx).snapshot(cx); - editor.display_map.update(cx, |display_map, cx| { - display_map - .snapshot(cx) - .folds_in_range(MultiBufferOffset(0)..snapshot.len()) - .map(|fold| fold.range.to_point(&snapshot)) - .collect() - }) - } - - fn current_completion_labels(editor: &Editor) -> Vec { - let completions = editor.current_completions().expect("Missing completions"); - completions - .into_iter() - .map(|completion| completion.label.text) - .collect::>() - } - - fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> { - let completions = editor.current_completions().expect("Missing completions"); - completions - .into_iter() - .map(|completion| { - ( - completion.label.text, - completion - .documentation - .map(|d| d.text().to_string()) - .unwrap_or_default(), - ) - }) - .collect::>() - } - - #[gpui::test] - async fn test_large_file_mention_fallback(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - // Create a large file that exceeds AUTO_OUTLINE_SIZE - // Using plain text without a configured language, so no outline is available - const LINE: &str = "This is a line of text in the file\n"; - let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len())); - assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE); - - // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE - let small_content = "fn small_function() { /* small */ }\n"; - assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE); - - fs.insert_tree( - "/project", - json!({ - "large_file.txt": large_content.clone(), - "small_file.txt": small_content, - }), - ) - .await; - - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let message_editor = cx.update(|window, cx| { - cx.new(|cx| { - let editor = MessageEditor::new( - workspace.downgrade(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ); - // Enable embedded context so files are actually included - editor - .prompt_capabilities - .replace(acp::PromptCapabilities::new().embedded_context(true)); - editor - }) - }); - - // Test large file mention - // Get the absolute path using the project's worktree - let large_file_abs_path = project.read_with(cx, |project, cx| { - let worktree = project.worktrees(cx).next().unwrap(); - let worktree_root = worktree.read(cx).abs_path(); - worktree_root.join("large_file.txt") - }); - let large_file_task = message_editor.update(cx, |editor, cx| { - editor.mention_set().update(cx, |set, cx| { - set.confirm_mention_for_file(large_file_abs_path, true, cx) - }) - }); - - let large_file_mention = large_file_task.await.unwrap(); - match large_file_mention { - Mention::Text { content, .. } => { - // Should contain some of the content but not all of it - assert!( - content.contains(LINE), - "Should contain some of the file content" - ); - assert!( - !content.contains(&LINE.repeat(100)), - "Should not contain the full file" - ); - // Should be much smaller than original - assert!( - content.len() < large_content.len() / 10, - "Should be significantly truncated" - ); - } - _ => panic!("Expected Text mention for large file"), - } - - // Test small file mention - // Get the absolute path using the project's worktree - let small_file_abs_path = project.read_with(cx, |project, cx| { - let worktree = project.worktrees(cx).next().unwrap(); - let worktree_root = worktree.read(cx).abs_path(); - worktree_root.join("small_file.txt") - }); - let small_file_task = message_editor.update(cx, |editor, cx| { - editor.mention_set().update(cx, |set, cx| { - set.confirm_mention_for_file(small_file_abs_path, true, cx) - }) - }); - - let small_file_mention = small_file_task.await.unwrap(); - match small_file_mention { - Mention::Text { content, .. } => { - // Should contain the full actual content - assert_eq!(content, small_content); - } - _ => panic!("Expected Text mention for small file"), - } - } - - #[gpui::test] - async fn test_insert_thread_summary(cx: &mut TestAppContext) { - init_test(cx); - cx.update(LanguageModelRegistry::test); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({"file": ""})).await; - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - // Create a thread metadata to insert as summary - let thread_metadata = AgentSessionInfo { - session_id: acp::SessionId::new("thread-123"), - cwd: None, - title: Some("Previous Conversation".into()), - updated_at: Some(chrono::Utc::now()), - meta: None, - }; - - let message_editor = cx.update(|window, cx| { - cx.new(|cx| { - let mut editor = MessageEditor::new( - workspace.downgrade(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ); - editor.insert_thread_summary(thread_metadata.clone(), window, cx); - editor - }) - }); - - // Construct expected values for verification - let expected_uri = MentionUri::Thread { - id: thread_metadata.session_id.clone(), - name: thread_metadata.title.as_ref().unwrap().to_string(), - }; - let expected_title = thread_metadata.title.as_ref().unwrap(); - let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri()); - - message_editor.read_with(cx, |editor, cx| { - let text = editor.text(cx); - - assert!( - text.contains(&expected_link), - "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}", - expected_link, - text - ); - - let mentions = editor.mention_set().read(cx).mentions(); - assert_eq!( - mentions.len(), - 1, - "Expected exactly one mention after inserting thread summary" - ); - - assert!( - mentions.contains(&expected_uri), - "Expected mentions to contain the thread URI" - ); - }); - } - - #[gpui::test] - async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) { - init_test(cx); - cx.update(LanguageModelRegistry::test); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({"file": ""})).await; - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = None; - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let thread_metadata = AgentSessionInfo { - session_id: acp::SessionId::new("thread-123"), - cwd: None, - title: Some("Previous Conversation".into()), - updated_at: Some(chrono::Utc::now()), - meta: None, - }; - - let message_editor = cx.update(|window, cx| { - cx.new(|cx| { - let mut editor = MessageEditor::new( - workspace.downgrade(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ); - editor.insert_thread_summary(thread_metadata, window, cx); - editor - }) - }); - - message_editor.read_with(cx, |editor, cx| { - assert!( - editor.text(cx).is_empty(), - "Expected thread summary to be skipped for external agents" - ); - assert!( - editor.mention_set().read(cx).mentions().is_empty(), - "Expected no mentions when thread summary is skipped" - ); - }); - } - - #[gpui::test] - async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({"file": ""})).await; - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = None; - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let message_editor = cx.update(|window, cx| { - cx.new(|cx| { - MessageEditor::new( - workspace.downgrade(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ) - }) - }); - - message_editor.update(cx, |editor, _cx| { - editor - .prompt_capabilities - .replace(acp::PromptCapabilities::new().embedded_context(true)); - }); - - let supported_modes = { - let app = cx.app.borrow(); - message_editor.supported_modes(&app) - }; - - assert!( - !supported_modes.contains(&PromptContextType::Thread), - "Expected thread mode to be hidden when thread mentions are disabled" - ); - } - - #[gpui::test] - async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({"file": ""})).await; - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let message_editor = cx.update(|window, cx| { - cx.new(|cx| { - MessageEditor::new( - workspace.downgrade(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ) - }) - }); - - message_editor.update(cx, |editor, _cx| { - editor - .prompt_capabilities - .replace(acp::PromptCapabilities::new().embedded_context(true)); - }); - - let supported_modes = { - let app = cx.app.borrow(); - message_editor.supported_modes(&app) - }; - - assert!( - supported_modes.contains(&PromptContextType::Thread), - "Expected thread mode to be visible when enabled" - ); - } - - #[gpui::test] - async fn test_whitespace_trimming(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({"file.rs": "fn main() {}"})) - .await; - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let message_editor = cx.update(|window, cx| { - cx.new(|cx| { - MessageEditor::new( - workspace.downgrade(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ) - }) - }); - let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone()); - - cx.run_until_parked(); - - editor.update_in(cx, |editor, window, cx| { - editor.set_text(" \u{A0}してhello world ", window, cx); - }); - - let (content, _) = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) - .await - .unwrap(); - - assert_eq!(content, vec!["してhello world".into()]); - } - - #[gpui::test] - async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - let file_content = "fn main() { println!(\"Hello, world!\"); }\n"; - - fs.insert_tree( - "/project", - json!({ - "src": { - "main.rs": file_content, - } - }), - ) - .await; - - let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { - let workspace_handle = cx.weak_entity(); - let message_editor = cx.new(|cx| { - MessageEditor::new( - workspace_handle, - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - max_lines: None, - min_lines: 1, - }, - window, - cx, - ) - }); - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), - true, - true, - None, - window, - cx, - ); - }); - message_editor.read(cx).focus_handle(cx).focus(window, cx); - let editor = message_editor.read(cx).editor().clone(); - (message_editor, editor) - }); - - cx.simulate_input("What is in @file main"); - - editor.update_in(cx, |editor, window, cx| { - assert!(editor.has_visible_completions_menu()); - assert_eq!(editor.text(cx), "What is in @file main"); - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - let content = message_editor - .update(cx, |editor, cx| editor.contents(false, cx)) - .await - .unwrap() - .0; - - let main_rs_uri = if cfg!(windows) { - "file:///C:/project/src/main.rs" - } else { - "file:///project/src/main.rs" - }; - - // When embedded context is `false` we should get a resource link - pretty_assertions::assert_eq!( - content, - vec![ - "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::new().embedded_context(true)) - }); - - let content = message_editor - .update(cx, |editor, cx| editor.contents(false, cx)) - .await - .unwrap() - .0; - - // When embedded context is `true` we should get a resource - pretty_assertions::assert_eq!( - content, - vec![ - "What is in ".into(), - acp::ContentBlock::Resource(acp::EmbeddedResource::new( - acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents::new(file_content, main_rs_uri) - ) - )) - ] - ); - } - - #[gpui::test] - async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) { - init_test(cx); - - let app_state = cx.update(AppState::test); - - cx.update(|cx| { - editor::init(cx); - workspace::init(app_state.clone(), cx); - }); - - app_state - .fs - .as_fake() - .insert_tree( - path!("/dir"), - json!({ - "test.txt": "line1\nline2\nline3\nline4\nline5\n", - }), - ) - .await; - - let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = window - .read_with(cx, |mw, _| mw.workspace().clone()) - .unwrap(); - - let worktree = project.update(cx, |project, cx| { - let mut worktrees = project.worktrees(cx).collect::>(); - assert_eq!(worktrees.len(), 1); - worktrees.pop().unwrap() - }); - let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); - - let mut cx = VisualTestContext::from_window(window.into(), cx); - - // Open a regular editor with the created file, and select a portion of - // the text that will be used for the selections that are meant to be - // inserted in the agent panel. - let editor = workspace - .update_in(&mut cx, |workspace, window, cx| { - workspace.open_path( - ProjectPath { - worktree_id, - path: rel_path("test.txt").into(), - }, - None, - false, - window, - cx, - ) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]); - }); - }); - - let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - // Create a new `MessageEditor`. The `EditorMode::full()` has to be used - // to ensure we have a fixed viewport, so we can eventually actually - // place the cursor outside of the visible area. - let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| { - let workspace_handle = cx.weak_entity(); - let message_editor = cx.new(|cx| { - MessageEditor::new( - workspace_handle, - project.downgrade(), - thread_store.clone(), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::full(), - window, - cx, - ) - }); - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), - true, - true, - None, - window, - cx, - ); - }); - - message_editor - }); - - message_editor.update_in(&mut cx, |message_editor, window, cx| { - message_editor.editor.update(cx, |editor, cx| { - // Update the Agent Panel's Message Editor text to have 100 - // lines, ensuring that the cursor is set at line 90 and that we - // then scroll all the way to the top, so the cursor's position - // remains off screen. - let mut lines = String::new(); - for _ in 1..=100 { - lines.push_str(&"Another line in the agent panel's message editor\n"); - } - editor.set_text(lines.as_str(), window, cx); - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]); - }); - editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx); - }); - }); - - cx.run_until_parked(); - - // Before proceeding, let's assert that the cursor is indeed off screen, - // otherwise the rest of the test doesn't make sense. - message_editor.update_in(&mut cx, |message_editor, window, cx| { - message_editor.editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let cursor_row = editor.selections.newest::(&snapshot).head().row; - let scroll_top = snapshot.scroll_position().y as u32; - let visible_lines = editor.visible_line_count().unwrap() as u32; - let visible_range = scroll_top..(scroll_top + visible_lines); - - assert!(!visible_range.contains(&cursor_row)); - }) - }); - - // Now let's insert the selection in the Agent Panel's editor and - // confirm that, after the insertion, the cursor is now in the visible - // range. - message_editor.update_in(&mut cx, |message_editor, window, cx| { - message_editor.insert_selections(window, cx); - }); - - cx.run_until_parked(); - - message_editor.update_in(&mut cx, |message_editor, window, cx| { - message_editor.editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let cursor_row = editor.selections.newest::(&snapshot).head().row; - let scroll_top = snapshot.scroll_position().y as u32; - let visible_lines = editor.visible_line_count().unwrap() as u32; - let visible_range = scroll_top..(scroll_top + visible_lines); - - assert!(visible_range.contains(&cursor_row)); - }) - }); - } - - #[gpui::test] - async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) { - init_test(cx); - - let app_state = cx.update(AppState::test); - - cx.update(|cx| { - editor::init(cx); - workspace::init(app_state.clone(), cx); - }); - - app_state - .fs - .as_fake() - .insert_tree(path!("/dir"), json!({})) - .await; - - let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = window - .read_with(cx, |mw, _| mw.workspace().clone()) - .unwrap(); - - let mut cx = VisualTestContext::from_window(window.into(), cx); - - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { - let workspace_handle = cx.weak_entity(); - let message_editor = cx.new(|cx| { - MessageEditor::new( - workspace_handle, - project.downgrade(), - Some(thread_store), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - max_lines: None, - min_lines: 1, - }, - window, - cx, - ) - }); - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), - true, - true, - None, - window, - cx, - ); - }); - message_editor.read(cx).focus_handle(cx).focus(window, cx); - let editor = message_editor.read(cx).editor().clone(); - (message_editor, editor) - }); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.set_text("😄😄", window, cx); - }); - - cx.run_until_parked(); - - message_editor.update_in(&mut cx, |message_editor, window, cx| { - message_editor.insert_context_type("file", window, cx); - }); - - cx.run_until_parked(); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "😄😄@file"); - }); - } - - #[gpui::test] - async fn test_paste_mention_link_with_multiple_selections(cx: &mut TestAppContext) { - init_test(cx); - - let app_state = cx.update(AppState::test); - - cx.update(|cx| { - editor::init(cx); - workspace::init(app_state.clone(), cx); - }); - - app_state - .fs - .as_fake() - .insert_tree(path!("/project"), json!({"file.txt": "content"})) - .await; - - let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await; - let window = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = window - .read_with(cx, |mw, _| mw.workspace().clone()) - .unwrap(); - - let mut cx = VisualTestContext::from_window(window.into(), cx); - - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = cx - .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); - - let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { - let workspace_handle = cx.weak_entity(); - let message_editor = cx.new(|cx| { - MessageEditor::new( - workspace_handle, - project.downgrade(), - Some(thread_store), - history.downgrade(), - None, - Default::default(), - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - max_lines: None, - min_lines: 1, - }, - window, - cx, - ) - }); - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), - true, - true, - None, - window, - cx, - ); - }); - message_editor.read(cx).focus_handle(cx).focus(window, cx); - let editor = message_editor.read(cx).editor().clone(); - (message_editor, editor) - }); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.set_text( - "AAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAA", - window, - cx, - ); - }); - - cx.run_until_parked(); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([ - MultiBufferOffset(0)..MultiBufferOffset(25), // First selection (large) - MultiBufferOffset(30)..MultiBufferOffset(55), // Second selection (newest) - ]); - }); - }); - - let mention_link = "[@f](file:///test.txt)"; - cx.write_to_clipboard(ClipboardItem::new_string(mention_link.into())); - - message_editor.update_in(&mut cx, |message_editor, window, cx| { - message_editor.paste(&Paste, window, cx); - }); - - let text = editor.update(&mut cx, |editor, cx| editor.text(cx)); - assert!( - text.contains("[@f](file:///test.txt)"), - "Expected mention link to be pasted, got: {}", - text - ); - } -} diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs deleted file mode 100644 index 9ec25d6d2a1e11..00000000000000 --- a/crates/agent_ui/src/acp/mode_selector.rs +++ /dev/null @@ -1,217 +0,0 @@ -use acp_thread::AgentSessionModes; -use agent_client_protocol as acp; -use agent_servers::AgentServer; -use agent_settings::AgentSettings; -use fs::Fs; -use gpui::{Context, Entity, WeakEntity, Window, prelude::*}; -use settings::Settings as _; -use std::{rc::Rc, sync::Arc}; -use ui::{ - Button, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, - PopoverMenuHandle, Tooltip, prelude::*, -}; - -use crate::{CycleModeSelector, ToggleProfileSelector, ui::HoldForDefault}; - -pub struct ModeSelector { - connection: Rc, - agent_server: Rc, - menu_handle: PopoverMenuHandle, - fs: Arc, - setting_mode: bool, -} - -impl ModeSelector { - pub fn new( - session_modes: Rc, - agent_server: Rc, - fs: Arc, - ) -> Self { - Self { - connection: session_modes, - agent_server, - menu_handle: PopoverMenuHandle::default(), - fs, - setting_mode: false, - } - } - - pub fn menu_handle(&self) -> PopoverMenuHandle { - self.menu_handle.clone() - } - - pub fn cycle_mode(&mut self, _window: &mut Window, cx: &mut Context) { - let all_modes = self.connection.all_modes(); - let current_mode = self.connection.current_mode(); - - let current_index = all_modes - .iter() - .position(|mode| mode.id.0 == current_mode.0) - .unwrap_or(0); - - let next_index = (current_index + 1) % all_modes.len(); - self.set_mode(all_modes[next_index].id.clone(), cx); - } - - pub fn mode(&self) -> acp::SessionModeId { - self.connection.current_mode() - } - - pub fn set_mode(&mut self, mode: acp::SessionModeId, cx: &mut Context) { - let task = self.connection.set_mode(mode, cx); - self.setting_mode = true; - cx.notify(); - - cx.spawn(async move |this: WeakEntity, cx| { - if let Err(err) = task.await { - log::error!("Failed to set session mode: {:?}", err); - } - this.update(cx, |this, cx| { - this.setting_mode = false; - cx.notify(); - }) - .ok(); - }) - .detach(); - } - - fn build_context_menu( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - let weak_self = cx.weak_entity(); - - ContextMenu::build(window, cx, move |mut menu, _window, cx| { - let all_modes = self.connection.all_modes(); - let current_mode = self.connection.current_mode(); - let default_mode = self.agent_server.default_mode(cx); - - let settings = AgentSettings::get_global(cx); - let side = match settings.dock { - settings::DockPosition::Left => DocumentationSide::Right, - settings::DockPosition::Bottom | settings::DockPosition::Right => { - DocumentationSide::Left - } - }; - - for mode in all_modes { - let is_selected = &mode.id == ¤t_mode; - let is_default = Some(&mode.id) == default_mode.as_ref(); - let entry = ContextMenuEntry::new(mode.name.clone()) - .toggleable(IconPosition::End, is_selected); - - let entry = if let Some(description) = &mode.description { - entry.documentation_aside(side, { - let description = description.clone(); - - move |_| { - v_flex() - .gap_1() - .child(Label::new(description.clone())) - .child(HoldForDefault::new(is_default)) - .into_any_element() - } - }) - } else { - entry - }; - - menu.push_item(entry.handler({ - let mode_id = mode.id.clone(); - let weak_self = weak_self.clone(); - move |window, cx| { - weak_self - .update(cx, |this, cx| { - if window.modifiers().secondary() { - this.agent_server.set_default_mode( - if is_default { - None - } else { - Some(mode_id.clone()) - }, - this.fs.clone(), - cx, - ); - } - - this.set_mode(mode_id.clone(), cx); - }) - .ok(); - } - })); - } - - menu.key_context("ModeSelector") - }) - } -} - -impl Render for ModeSelector { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let current_mode_id = self.connection.current_mode(); - let current_mode_name = self - .connection - .all_modes() - .iter() - .find(|mode| mode.id == current_mode_id) - .map(|mode| mode.name.clone()) - .unwrap_or_else(|| "Unknown".into()); - - let this = cx.weak_entity(); - - let icon = if self.menu_handle.is_deployed() { - IconName::ChevronUp - } else { - IconName::ChevronDown - }; - - let trigger_button = Button::new("mode-selector-trigger", current_mode_name) - .label_size(LabelSize::Small) - .color(Color::Muted) - .icon(icon) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .disabled(self.setting_mode); - - PopoverMenu::new("mode-selector") - .trigger_with_tooltip( - trigger_button, - Tooltip::element({ - move |_window, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Change Mode")) - .child(KeyBinding::for_action(&ToggleProfileSelector, cx)), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .justify_between() - .child(Label::new("Cycle Through Modes")) - .child(KeyBinding::for_action(&CycleModeSelector, cx)), - ) - .into_any() - } - }), - ) - .anchor(gpui::Corner::BottomRight) - .with_handle(self.menu_handle.clone()) - .offset(gpui::Point { - x: px(0.0), - y: px(-2.0), - }) - .menu(move |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 deleted file mode 100644 index 43a39e61088219..00000000000000 --- a/crates/agent_ui/src/acp/model_selector.rs +++ /dev/null @@ -1,850 +0,0 @@ -use std::{cmp::Reverse, rc::Rc, sync::Arc}; - -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::{HashSet, IndexMap}; -use fs::Fs; -use futures::FutureExt; -use fuzzy::{StringMatchCandidate, match_strings}; -use gpui::{ - Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task, - WeakEntity, -}; -use itertools::Itertools; -use ordered_float::OrderedFloat; -use picker::{Picker, PickerDelegate}; -use settings::{Settings, SettingsStore}; -use ui::{DocumentationAside, DocumentationSide, IntoElement, prelude::*}; -use util::ResultExt; -use zed_actions::agent::OpenSettings; - -use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}; - -pub type AcpModelSelector = Picker; - -pub fn acp_model_selector( - selector: Rc, - agent_server: Rc, - fs: Arc, - focus_handle: FocusHandle, - window: &mut Window, - cx: &mut Context, -) -> AcpModelSelector { - let delegate = - AcpModelPickerDelegate::new(selector, agent_server, fs, focus_handle, window, cx); - Picker::list(delegate, window, cx) - .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) -} - -enum AcpModelPickerEntry { - Separator(SharedString), - Model(AgentModelInfo, bool), -} - -pub struct AcpModelPickerDelegate { - selector: Rc, - agent_server: Rc, - fs: Arc, - filtered_entries: Vec, - models: Option, - selected_index: usize, - selected_description: Option<(usize, SharedString, bool)>, - selected_model: Option, - favorites: HashSet, - _refresh_models_task: Task<()>, - _settings_subscription: Subscription, - focus_handle: FocusHandle, -} - -impl AcpModelPickerDelegate { - fn new( - selector: Rc, - agent_server: Rc, - fs: Arc, - focus_handle: FocusHandle, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let rx = selector.watch(cx); - let refresh_models_task = { - cx.spawn_in(window, { - async move |this, cx| { - async fn refresh( - this: &WeakEntity>, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let (models_task, selected_model_task) = this.update(cx, |this, cx| { - ( - this.delegate.selector.list_models(cx), - this.delegate.selector.selected_model(cx), - ) - })?; - - let (models, selected_model) = - futures::join!(models_task, selected_model_task); - - this.update_in(cx, |this, window, cx| { - this.delegate.models = models.ok(); - this.delegate.selected_model = selected_model.ok(); - this.refresh(window, cx) - }) - } - - refresh(&this, cx).await.log_err(); - if let Some(mut rx) = rx { - while let Ok(()) = rx.recv().await { - refresh(&this, cx).await.log_err(); - } - } - } - }) - }; - - let agent_server_for_subscription = agent_server.clone(); - let settings_subscription = - cx.observe_global_in::(window, move |picker, window, cx| { - // Only refresh if the favorites actually changed to avoid redundant work - // when other settings are modified (e.g., user editing settings.json) - let new_favorites = agent_server_for_subscription.favorite_model_ids(cx); - if new_favorites != picker.delegate.favorites { - picker.delegate.favorites = new_favorites; - picker.refresh(window, cx); - } - }); - let favorites = agent_server.favorite_model_ids(cx); - - Self { - selector, - agent_server, - fs, - filtered_entries: Vec::new(), - models: None, - selected_model: None, - selected_index: 0, - selected_description: None, - favorites, - _refresh_models_task: refresh_models_task, - _settings_subscription: settings_subscription, - focus_handle, - } - } - - pub fn active_model(&self) -> Option<&AgentModelInfo> { - self.selected_model.as_ref() - } - - pub fn favorites_count(&self) -> usize { - self.favorites.len() - } - - pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context>) { - if self.favorites.is_empty() { - return; - } - - let Some(models) = &self.models else { - return; - }; - - let all_models: Vec<&AgentModelInfo> = match models { - AgentModelList::Flat(list) => list.iter().collect(), - AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(), - }; - - let favorite_models: Vec<_> = all_models - .into_iter() - .filter(|model| self.favorites.contains(&model.id)) - .unique_by(|model| &model.id) - .collect(); - - if favorite_models.is_empty() { - return; - } - - let current_id = self.selected_model.as_ref().map(|m| &m.id); - - let current_index_in_favorites = current_id - .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 { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.filtered_entries.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); - cx.notify(); - } - - fn can_select( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) -> bool { - match self.filtered_entries.get(ix) { - Some(AcpModelPickerEntry::Model(_, _)) => true, - Some(AcpModelPickerEntry::Separator(_)) | None => false, - } - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select a model…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let favorites = self.favorites.clone(); - - cx.spawn_in(window, async move |this, cx| { - let filtered_models = match this - .read_with(cx, |this, cx| { - this.delegate.models.clone().map(move |models| { - fuzzy_search(models, query, cx.background_executor().clone()) - }) - }) - .ok() - .flatten() - { - Some(task) => task.await, - None => AgentModelList::Flat(vec![]), - }; - - this.update_in(cx, |this, window, cx| { - this.delegate.filtered_entries = - info_list_to_picker_entries(filtered_models, &favorites); - // Finds the currently selected model in the list - let new_index = this - .delegate - .selected_model - .as_ref() - .and_then(|selected| { - this.delegate.filtered_entries.iter().position(|entry| { - if let AcpModelPickerEntry::Model(model_info, _) = entry { - model_info.id == selected.id - } else { - false - } - }) - }) - .unwrap_or(0); - this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx); - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - if let Some(AcpModelPickerEntry::Model(model_info, _)) = - self.filtered_entries.get(self.selected_index) - { - if window.modifiers().secondary() { - let default_model = self.agent_server.default_model(cx); - let is_default = default_model.as_ref() == Some(&model_info.id); - - self.agent_server.set_default_model( - if is_default { - None - } else { - Some(model_info.id.clone()) - }, - self.fs.clone(), - cx, - ); - } - - self.selector - .select_model(model_info.id.clone(), cx) - .detach_and_log_err(cx); - self.selected_model = Some(model_info.clone()); - let current_index = self.selected_index; - self.set_selected_index(current_index, window, cx); - - cx.emit(DismissEvent); - } - } - - fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { - cx.defer_in(window, |picker, window, cx| { - picker.set_query("", window, cx); - }); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - match self.filtered_entries.get(ix)? { - 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 is_favorite = *is_favorite; - let handle_action_click = { - let model_id = model_info.id.clone(); - let fs = self.fs.clone(); - let agent_server = self.agent_server.clone(); - - cx.listener(move |_, _, _, cx| { - agent_server.toggle_favorite_model( - model_id.clone(), - !is_favorite, - fs.clone(), - cx, - ); - }) - }; - - let model_cost = model_info.cost.clone(); - - 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(); - })) - }) - .child( - 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) - .is_latest(model_info.is_latest) - .is_favorite(is_favorite) - .on_toggle_favorite(handle_action_click) - .cost_info(model_cost) - ) - .into_any_element(), - ) - } - } - } - - fn documentation_aside( - &self, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - self.selected_description - .as_ref() - .map(|(_, description, is_default)| { - let description = description.clone(); - let is_default = *is_default; - - let settings = AgentSettings::get_global(cx); - let side = match settings.dock { - settings::DockPosition::Left => DocumentationSide::Right, - settings::DockPosition::Bottom | settings::DockPosition::Right => { - DocumentationSide::Left - } - }; - - DocumentationAside::new( - side, - Rc::new(move |_| { - v_flex() - .gap_1() - .child(Label::new(description.clone())) - .child(HoldForDefault::new(is_default)) - .into_any_element() - }), - ) - }) - } - - fn documentation_aside_index(&self) -> Option { - self.selected_description.as_ref().map(|(ix, _, _)| *ix) - } - - fn render_footer( - &self, - _window: &mut Window, - _cx: &mut Context>, - ) -> Option { - let focus_handle = self.focus_handle.clone(); - - if !self.selector.should_render_footer() { - return None; - } - - Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element()) - } -} - -fn info_list_to_picker_entries( - model_list: AgentModelList, - 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) => { - 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) => { - 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( - model_list: AgentModelList, - query: String, - executor: BackgroundExecutor, -) -> AgentModelList { - async fn fuzzy_search_list( - model_list: Vec, - query: &str, - executor: BackgroundExecutor, - ) -> Vec { - let candidates = model_list - .iter() - .enumerate() - .map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref())) - .collect::>(); - let mut matches = match_strings( - &candidates, - query, - false, - true, - 100, - &Default::default(), - executor, - ) - .await; - - matches.sort_unstable_by_key(|mat| { - let candidate = &candidates[mat.candidate_id]; - (Reverse(OrderedFloat(mat.score)), candidate.id) - }); - - matches - .into_iter() - .map(|mat| model_list[mat.candidate_id].clone()) - .collect() - } - - match model_list { - AgentModelList::Flat(model_list) => { - AgentModelList::Flat(fuzzy_search_list(model_list, &query, executor).await) - } - AgentModelList::Grouped(index_map) => { - let groups = - futures::future::join_all(index_map.into_iter().map(|(group_name, models)| { - fuzzy_search_list(models, &query, executor.clone()) - .map(|results| (group_name, results)) - })) - .await; - AgentModelList::Grouped(IndexMap::from_iter( - groups - .into_iter() - .filter(|(_, results)| !results.is_empty()), - )) - } - } -} - -#[cfg(test)] -mod tests { - use agent_client_protocol as acp; - use gpui::TestAppContext; - - use super::*; - - fn create_model_list(grouped_models: Vec<(&str, Vec<&str>)>) -> AgentModelList { - AgentModelList::Grouped(IndexMap::from_iter(grouped_models.into_iter().map( - |(group, models)| { - ( - acp_thread::AgentModelGroupName(group.to_string().into()), - models - .into_iter() - .map(|model| acp_thread::AgentModelInfo { - id: acp::ModelId::new(model.to_string()), - name: model.to_string().into(), - description: None, - icon: None, - is_latest: false, - cost: None, - }) - .collect::>(), - ) - }, - ))) - } - - fn assert_models_eq(result: AgentModelList, expected: Vec<(&str, Vec<&str>)>) { - let AgentModelList::Grouped(groups) = result else { - panic!("Expected LanguageModelInfoList::Grouped, got {:?}", result); - }; - - assert_eq!( - groups.len(), - expected.len(), - "Number of groups doesn't match" - ); - - for (i, (expected_group, expected_models)) in expected.iter().enumerate() { - let (actual_group, actual_models) = groups.get_index(i).unwrap(); - assert_eq!( - actual_group.0.as_ref(), - *expected_group, - "Group at position {} doesn't match expected group", - i - ); - assert_eq!( - actual_models.len(), - expected_models.len(), - "Number of models in group {} doesn't match", - expected_group - ); - - for (j, expected_model_name) in expected_models.iter().enumerate() { - assert_eq!( - actual_models[j].name, *expected_model_name, - "Model at position {} in group {} doesn't match expected model", - j, expected_group - ); - } - } - } - - 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] - async fn test_fuzzy_match(cx: &mut TestAppContext) { - let models = create_model_list(vec![ - ( - "zed", - vec![ - "Claude 3.7 Sonnet", - "Claude 3.7 Sonnet Thinking", - "gpt-5", - "gpt-5-mini", - ], - ), - ("openai", vec!["gpt-3.5-turbo", "gpt-5", "gpt-5-mini"]), - ("ollama", vec!["mistral", "deepseek"]), - ]); - - // Results should preserve models order whenever possible. - // In the case below, `zed/gpt-5-mini` and `openai/gpt-5-mini` have identical - // similarity scores, but `zed/gpt-5-mini` was higher in the models list, - // so it should appear first in the results. - let results = fuzzy_search(models.clone(), "mini".into(), cx.executor()).await; - assert_models_eq( - results, - vec![("zed", vec!["gpt-5-mini"]), ("openai", vec!["gpt-5-mini"])], - ); - - // Fuzzy search - test with specific model name - let results = fuzzy_search(models.clone(), "mistral".into(), cx.executor()).await; - assert_models_eq(results, vec![("ollama", vec!["mistral"])]); - } - - #[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, - is_latest: false, - cost: None, - }, - acp_thread::AgentModelInfo { - id: acp::ModelId::new("zed/gemini".to_string()), - name: "Gemini".into(), - description: None, - icon: None, - is_latest: false, - cost: 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] - fn test_favorites_count_returns_correct_count(_cx: &mut TestAppContext) { - let empty_favorites: HashSet = HashSet::default(); - assert_eq!(empty_favorites.len(), 0); - - let one_favorite = create_favorites(vec!["model-a"]); - assert_eq!(one_favorite.len(), 1); - - let multiple_favorites = create_favorites(vec!["model-a", "model-b", "model-c"]); - assert_eq!(multiple_favorites.len(), 3); - - let with_duplicates = create_favorites(vec!["model-a", "model-a", "model-b"]); - assert_eq!(with_duplicates.len(), 2); - } - - #[gpui::test] - fn test_is_favorite_flag_set_correctly_in_entries(_cx: &mut TestAppContext) { - let models = AgentModelList::Flat(vec![ - acp_thread::AgentModelInfo { - id: acp::ModelId::new("favorite-model".to_string()), - name: "Favorite".into(), - description: None, - icon: None, - is_latest: false, - cost: None, - }, - acp_thread::AgentModelInfo { - id: acp::ModelId::new("regular-model".to_string()), - name: "Regular".into(), - description: None, - icon: None, - is_latest: false, - cost: None, - }, - ]); - let favorites = create_favorites(vec!["favorite-model"]); - - 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() == "favorite-model" { - assert!(*is_favorite, "favorite-model should have is_favorite=true"); - } else if info.id.0.as_ref() == "regular-model" { - assert!(!*is_favorite, "regular-model should have is_favorite=false"); - } - } - } - } -} diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs deleted file mode 100644 index 941a84faa87826..00000000000000 --- a/crates/agent_ui/src/acp/model_selector_popover.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::rc::Rc; -use std::sync::Arc; - -use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector}; -use fs::Fs; -use gpui::{Entity, FocusHandle}; -use picker::popover_menu::PickerPopoverMenu; -use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; - -use crate::acp::{AcpModelSelector, model_selector::acp_model_selector}; -use crate::ui::ModelSelectorTooltip; - -pub struct AcpModelSelectorPopover { - selector: Entity, - menu_handle: PopoverMenuHandle, -} - -impl AcpModelSelectorPopover { - pub(crate) fn new( - selector: Rc, - agent_server: Rc, - fs: Arc, - menu_handle: PopoverMenuHandle, - focus_handle: FocusHandle, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - selector: cx.new(move |cx| { - acp_model_selector(selector, agent_server, fs, focus_handle.clone(), window, cx) - }), - menu_handle, - } - } - - pub fn toggle(&self, window: &mut Window, cx: &mut Context) { - self.menu_handle.toggle(window, cx); - } - - 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 { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let selector = self.selector.read(cx); - let model = selector.delegate.active_model(); - let model_name = model - .as_ref() - .map(|model| model.name.clone()) - .unwrap_or_else(|| SharedString::from("Select a Model")); - - let model_icon = model.as_ref().and_then(|model| model.icon.clone()); - - let (color, icon) = if self.menu_handle.is_deployed() { - (Color::Accent, IconName::ChevronUp) - } else { - (Color::Muted, IconName::ChevronDown) - }; - - let show_cycle_row = selector.delegate.favorites_count() > 1; - - let tooltip = Tooltip::element({ - move |_, _cx| { - ModelSelectorTooltip::new() - .show_cycle_row(show_cycle_row) - .into_any_element() - } - }); - - PickerPopoverMenu::new( - self.selector.clone(), - ButtonLike::new("active-model") - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .when_some(model_icon, |this, icon| { - 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) - .color(color) - .size(LabelSize::Small) - .ml_0p5(), - ) - .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), - tooltip, - gpui::Corner::BottomRight, - cx, - ) - .with_handle(self.menu_handle.clone()) - .render(window, cx) - } -} diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs deleted file mode 100644 index 01573e3b55166b..00000000000000 --- a/crates/agent_ui/src/acp/thread_history.rs +++ /dev/null @@ -1,1686 +0,0 @@ -use crate::acp::AcpServerView; -use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; -use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate}; -use agent_client_protocol as acp; -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, WeakEntity, Window, uniform_list, -}; -use std::{fmt::Display, ops::Range, rc::Rc}; -use text::Bias; -use time::{OffsetDateTime, UtcOffset}; -use ui::{ - ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, - WithScrollbar, prelude::*, -}; - -const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); - -fn thread_title(entry: &AgentSessionInfo) -> &SharedString { - entry - .title - .as_ref() - .filter(|title| !title.is_empty()) - .unwrap_or(DEFAULT_TITLE) -} - -pub struct AcpThreadHistory { - session_list: Option>, - sessions: Vec, - 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, - _visible_items_task: Task<()>, - _refresh_task: Task<()>, - _watch_task: Option>, - _subscriptions: Vec, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - entry: AgentSessionInfo, - format: EntryTimeFormat, - }, - SearchResult { - entry: AgentSessionInfo, - positions: Vec, - }, -} - -impl ListItemType { - fn history_entry(&self) -> Option<&AgentSessionInfo> { - match self { - ListItemType::Entry { entry, .. } => Some(entry), - ListItemType::SearchResult { entry, .. } => Some(entry), - _ => None, - } - } -} - -pub enum ThreadHistoryEvent { - Open(AgentSessionInfo), -} - -impl EventEmitter for AcpThreadHistory {} - -impl AcpThreadHistory { - pub fn new( - session_list: Option>, - 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 scroll_handle = UniformListScrollHandle::default(); - - let mut this = Self { - session_list: None, - sessions: Vec::new(), - 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], - _visible_items_task: Task::ready(()), - _refresh_task: Task::ready(()), - _watch_task: None, - }; - this.set_session_list(session_list, cx); - this - } - - fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self.sessions.clone(); - 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._visible_items_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 { - new_visible_items - .iter() - .position(|visible_entry| { - visible_entry - .history_entry() - .is_some_and(|entry| entry.session_id == history_entry.session_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(); - }); - } - - pub fn set_session_list( - &mut self, - session_list: Option>, - cx: &mut Context, - ) { - if let (Some(current), Some(next)) = (&self.session_list, &session_list) - && Rc::ptr_eq(current, next) - { - return; - } - - self.session_list = session_list; - self.sessions.clear(); - self.visible_items.clear(); - self.selected_index = 0; - self._visible_items_task = Task::ready(()); - self._refresh_task = Task::ready(()); - - let Some(session_list) = self.session_list.as_ref() else { - self._watch_task = None; - cx.notify(); - return; - }; - let Some(rx) = session_list.watch(cx) else { - // No watch support - do a one-time refresh - self._watch_task = None; - self.refresh_sessions(false, false, cx); - return; - }; - session_list.notify_refresh(); - - self._watch_task = Some(cx.spawn(async move |this, cx| { - while let Ok(first_update) = rx.recv().await { - let mut updates = vec![first_update]; - // Collect any additional updates that are already in the channel - while let Ok(update) = rx.try_recv() { - updates.push(update); - } - - this.update(cx, |this, cx| { - let needs_refresh = updates - .iter() - .any(|u| matches!(u, SessionListUpdate::Refresh)); - - if needs_refresh { - this.refresh_sessions(true, false, cx); - } else { - for update in updates { - if let SessionListUpdate::SessionInfo { session_id, update } = update { - this.apply_info_update(session_id, update, cx); - } - } - } - }) - .ok(); - } - })); - } - - pub(crate) fn refresh_full_history(&mut self, cx: &mut Context) { - self.refresh_sessions(true, true, cx); - } - - fn apply_info_update( - &mut self, - session_id: acp::SessionId, - info_update: acp::SessionInfoUpdate, - cx: &mut Context, - ) { - let Some(session) = self - .sessions - .iter_mut() - .find(|s| s.session_id == session_id) - else { - return; - }; - - match info_update.title { - acp::MaybeUndefined::Value(title) => { - session.title = Some(title.into()); - } - acp::MaybeUndefined::Null => { - session.title = None; - } - acp::MaybeUndefined::Undefined => {} - } - match info_update.updated_at { - acp::MaybeUndefined::Value(date_str) => { - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) { - session.updated_at = Some(dt.with_timezone(&chrono::Utc)); - } - } - acp::MaybeUndefined::Null => { - session.updated_at = None; - } - acp::MaybeUndefined::Undefined => {} - } - if let Some(meta) = info_update.meta { - session.meta = Some(meta); - } - - self.update_visible_items(true, cx); - } - - fn refresh_sessions( - &mut self, - preserve_selected_item: bool, - load_all_pages: bool, - cx: &mut Context, - ) { - let Some(session_list) = self.session_list.clone() else { - self.update_visible_items(preserve_selected_item, cx); - return; - }; - - // If a new refresh arrives while pagination is in progress, the previous - // `_refresh_task` is cancelled. This is intentional (latest refresh wins), - // but means sessions may be in a partial state until the new refresh completes. - self._refresh_task = cx.spawn(async move |this, cx| { - let mut cursor: Option = None; - let mut is_first_page = true; - - loop { - let request = AgentSessionListRequest { - cursor: cursor.clone(), - ..Default::default() - }; - let task = cx.update(|cx| session_list.list_sessions(request, cx)); - let response = match task.await { - Ok(response) => response, - Err(error) => { - log::error!("Failed to load session history: {error:#}"); - return; - } - }; - - let acp_thread::AgentSessionListResponse { - sessions: page_sessions, - next_cursor, - .. - } = response; - - this.update(cx, |this, cx| { - if is_first_page { - this.sessions = page_sessions; - } else { - this.sessions.extend(page_sessions); - } - this.update_visible_items(preserve_selected_item, cx); - }) - .ok(); - - is_first_page = false; - if !load_all_pages { - break; - } - - match next_cursor { - Some(next_cursor) => { - if cursor.as_ref() == Some(&next_cursor) { - log::warn!( - "Session list pagination returned the same cursor; stopping to avoid a loop." - ); - break; - } - cursor = Some(next_cursor); - } - None => break, - } - } - }); - } - - pub(crate) fn is_empty(&self) -> bool { - self.sessions.is_empty() - } - - pub fn has_session_list(&self) -> bool { - self.session_list.is_some() - } - - pub fn refresh(&mut self, _cx: &mut Context) { - if let Some(session_list) = &self.session_list { - session_list.notify_refresh(); - } - } - - pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option { - self.sessions - .iter() - .find(|entry| &entry.session_id == session_id) - .cloned() - } - - pub(crate) fn sessions(&self) -> &[AgentSessionInfo] { - &self.sessions - } - - pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec { - self.sessions.iter().take(limit).cloned().collect() - } - - pub fn supports_delete(&self, cx: &App) -> bool { - self.session_list - .as_ref() - .map(|sl| sl.supports_delete(cx)) - .unwrap_or(false) - } - - pub(crate) fn delete_session( - &self, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - if let Some(session_list) = self.session_list.as_ref() { - session_list.delete_session(session_id, cx) - } else { - Task::ready(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_bucket = entry - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::All); - - 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, thread_title(entry))); - } - - 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<&AgentSessionInfo> { - self.get_history_entry(self.selected_index) - } - - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { - 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.len() == 0 { - 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 Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete(cx) { - return; - } - let task = session_list.delete_session(&entry.session_id, cx); - task.detach_and_log_err(cx); - } - - fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete(cx) { - return; - } - session_list.delete_sessions(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: &AgentSessionInfo, - format: EntryTimeFormat, - ix: usize, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { - let selected = ix == self.selected_index; - let hovered = Some(ix) == self.hovered_index; - let entry_time = entry.updated_at; - let display_text = match (format, entry_time) { - (EntryTimeFormat::DateAndTime, Some(entry_time)) => { - let now = Utc::now(); - let duration = now.signed_duration_since(entry_time); - let days = duration.num_days(); - - format!("{}d", days) - } - (EntryTimeFormat::TimeOnly, Some(entry_time)) => { - format.format_timestamp(entry_time.timestamp(), self.local_timezone) - } - (_, None) => "—".to_string(), - }; - - let title = thread_title(entry).clone(); - let full_date = entry_time - .map(|time| { - EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) - }) - .unwrap_or_else(|| "Unknown".to_string()); - - 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(thread_title(entry), 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 && self.supports_delete(cx) { - 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.is_empty(); - - 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 && self.supports_delete(cx), |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(IntoElement)] -pub struct AcpHistoryEntryElement { - entry: AgentSessionInfo, - thread_view: WeakEntity, - selected: bool, - hovered: bool, - supports_delete: bool, - on_hover: Box, -} - -impl AcpHistoryEntryElement { - pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { - Self { - entry, - thread_view, - selected: false, - hovered: false, - supports_delete: false, - on_hover: Box::new(|_, _, _| {}), - } - } - - pub fn supports_delete(mut self, supports_delete: bool) -> Self { - self.supports_delete = supports_delete; - self - } - - pub fn hovered(mut self, hovered: bool) -> Self { - self.hovered = hovered; - self - } - - pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - self.on_hover = Box::new(on_hover); - self - } -} - -impl RenderOnce for AcpHistoryEntryElement { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let id = ElementId::Name(self.entry.session_id.0.clone().into()); - let title = thread_title(&self.entry).clone(); - let formatted_time = self - .entry - .updated_at - .map(|timestamp| { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(timestamp); - - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m ago", duration.num_minutes()) - } else { - "Just now".to_string() - } - }) - .unwrap_or_else(|| "Unknown".to_string()); - - ListItem::new(id) - .rounded() - .toggle_state(self.selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child(Label::new(title).size(LabelSize::Small).truncate()) - .child( - Label::new(formatted_time) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .on_hover(self.on_hover) - .end_slot::(if (self.hovered || self.selected) && self.supports_delete { - 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({ - let thread_view = self.thread_view.clone(); - let entry = self.entry.clone(); - - move |_event, _window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.update(cx, |thread_view, cx| { - thread_view.delete_history_entry(entry.clone(), cx); - }); - } - } - }), - ) - } else { - None - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let entry = self.entry; - - move |_event, window, cx| { - if let Some(workspace) = thread_view - .upgrade() - .and_then(|view| view.read(cx).workspace().upgrade()) - { - if let Some(panel) = workspace.read(cx).panel::(cx) { - panel.update(cx, |panel, cx| { - panel.load_agent_thread(entry.clone(), window, 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 acp_thread::AgentSessionListResponse; - use chrono::NaiveDate; - use gpui::TestAppContext; - use std::{ - any::Any, - sync::{Arc, Mutex}, - }; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = settings::SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - }); - } - - #[derive(Clone)] - struct TestSessionList { - sessions: Vec, - updates_tx: smol::channel::Sender, - updates_rx: smol::channel::Receiver, - } - - impl TestSessionList { - fn new(sessions: Vec) -> Self { - let (tx, rx) = smol::channel::unbounded(); - Self { - sessions, - updates_tx: tx, - updates_rx: rx, - } - } - - fn send_update(&self, update: SessionListUpdate) { - self.updates_tx.try_send(update).ok(); - } - } - - impl AgentSessionList for TestSessionList { - fn list_sessions( - &self, - _request: AgentSessionListRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) - } - - fn watch(&self, _cx: &mut App) -> Option> { - Some(self.updates_rx.clone()) - } - - fn notify_refresh(&self) { - self.send_update(SessionListUpdate::Refresh); - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - #[derive(Clone)] - struct PaginatedTestSessionList { - first_page_sessions: Vec, - second_page_sessions: Vec, - requested_cursors: Arc>>>, - async_responses: bool, - updates_tx: smol::channel::Sender, - updates_rx: smol::channel::Receiver, - } - - impl PaginatedTestSessionList { - fn new( - first_page_sessions: Vec, - second_page_sessions: Vec, - ) -> Self { - let (tx, rx) = smol::channel::unbounded(); - Self { - first_page_sessions, - second_page_sessions, - requested_cursors: Arc::new(Mutex::new(Vec::new())), - async_responses: false, - updates_tx: tx, - updates_rx: rx, - } - } - - fn with_async_responses(mut self) -> Self { - self.async_responses = true; - self - } - - fn requested_cursors(&self) -> Vec> { - self.requested_cursors.lock().unwrap().clone() - } - - fn clear_requested_cursors(&self) { - self.requested_cursors.lock().unwrap().clear() - } - - fn send_update(&self, update: SessionListUpdate) { - self.updates_tx.try_send(update).ok(); - } - } - - impl AgentSessionList for PaginatedTestSessionList { - fn list_sessions( - &self, - request: AgentSessionListRequest, - cx: &mut App, - ) -> Task> { - let requested_cursors = self.requested_cursors.clone(); - let first_page_sessions = self.first_page_sessions.clone(); - let second_page_sessions = self.second_page_sessions.clone(); - - let respond = move || { - requested_cursors - .lock() - .unwrap() - .push(request.cursor.clone()); - - match request.cursor.as_deref() { - None => AgentSessionListResponse { - sessions: first_page_sessions, - next_cursor: Some("page-2".to_string()), - meta: None, - }, - Some("page-2") => AgentSessionListResponse::new(second_page_sessions), - _ => AgentSessionListResponse::new(Vec::new()), - } - }; - - if self.async_responses { - cx.foreground_executor().spawn(async move { - smol::future::yield_now().await; - Ok(respond()) - }) - } else { - Task::ready(Ok(respond())) - } - } - - fn watch(&self, _cx: &mut App) -> Option> { - Some(self.updates_rx.clone()) - } - - fn notify_refresh(&self) { - self.send_update(SessionListUpdate::Refresh); - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - fn test_session(session_id: &str, title: &str) -> AgentSessionInfo { - AgentSessionInfo { - session_id: acp::SessionId::new(session_id), - cwd: None, - title: Some(title.to_string().into()), - updated_at: None, - meta: None, - } - } - - #[gpui::test] - async fn test_refresh_only_loads_first_page_by_default(cx: &mut TestAppContext) { - init_test(cx); - - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 1); - assert_eq!( - history.sessions[0].session_id, - acp::SessionId::new("session-1") - ); - }); - assert_eq!(session_list.requested_cursors(), vec![None]); - } - - #[gpui::test] - async fn test_enabling_full_pagination_loads_all_pages(cx: &mut TestAppContext) { - init_test(cx); - - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - session_list.clear_requested_cursors(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 2); - assert_eq!( - history.sessions[0].session_id, - acp::SessionId::new("session-1") - ); - assert_eq!( - history.sessions[1].session_id, - acp::SessionId::new("session-2") - ); - }); - assert_eq!( - session_list.requested_cursors(), - vec![None, Some("page-2".to_string())] - ); - } - - #[gpui::test] - async fn test_standard_refresh_replaces_with_first_page_after_full_history_refresh( - cx: &mut TestAppContext, - ) { - init_test(cx); - - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - session_list.clear_requested_cursors(); - - history.update(cx, |history, cx| { - history.refresh(cx); - }); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 1); - assert_eq!( - history.sessions[0].session_id, - acp::SessionId::new("session-1") - ); - }); - assert_eq!(session_list.requested_cursors(), vec![None]); - } - - #[gpui::test] - async fn test_re_entering_full_pagination_reloads_all_pages(cx: &mut TestAppContext) { - init_test(cx); - - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - session_list.clear_requested_cursors(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 2); - }); - assert_eq!( - session_list.requested_cursors(), - vec![None, Some("page-2".to_string())] - ); - } - - #[gpui::test] - async fn test_partial_refresh_batch_drops_non_first_page_sessions(cx: &mut TestAppContext) { - init_test(cx); - - let second_page_session_id = acp::SessionId::new("session-2"); - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - - session_list.clear_requested_cursors(); - - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: second_page_session_id.clone(), - update: acp::SessionInfoUpdate::new().title("Updated Second"), - }); - session_list.send_update(SessionListUpdate::Refresh); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 1); - assert_eq!( - history.sessions[0].session_id, - acp::SessionId::new("session-1") - ); - assert!( - history - .sessions - .iter() - .all(|session| session.session_id != second_page_session_id) - ); - }); - assert_eq!(session_list.requested_cursors(), vec![None]); - } - - #[gpui::test] - async fn test_full_pagination_works_with_async_page_fetches(cx: &mut TestAppContext) { - init_test(cx); - - let session_list = Rc::new( - PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - ) - .with_async_responses(), - ); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - session_list.clear_requested_cursors(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 2); - }); - assert_eq!( - session_list.requested_cursors(), - vec![None, Some("page-2".to_string())] - ); - } - - #[gpui::test] - async fn test_apply_info_update_title(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - cwd: None, - title: Some("Original Title".into()), - updated_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - // Send a title update - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title("New Title"), - }); - cx.run_until_parked(); - - // Check that the title was updated - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!( - session.unwrap().title.as_ref().map(|s| s.as_ref()), - Some("New Title") - ); - }); - } - - #[gpui::test] - async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - cwd: None, - title: Some("Original Title".into()), - updated_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - // Send an update that clears the title (null) - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title(None::), - }); - cx.run_until_parked(); - - // Check that the title was cleared - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!(session.unwrap().title, None); - }); - } - - #[gpui::test] - async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - cwd: None, - title: Some("Original Title".into()), - updated_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - // Send an update with no fields set (all undefined) - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new(), - }); - cx.run_until_parked(); - - // Check that the title is unchanged - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!( - session.unwrap().title.as_ref().map(|s| s.as_ref()), - Some("Original Title") - ); - }); - } - - #[gpui::test] - async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - cwd: None, - title: None, - updated_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - // Send multiple updates before the executor runs - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title("First Title"), - }); - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title("Second Title"), - }); - cx.run_until_parked(); - - // Check that the final title is "Second Title" (both applied in order) - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!( - session.unwrap().title.as_ref().map(|s| s.as_ref()), - Some("Second Title") - ); - }); - } - - #[gpui::test] - async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - cwd: None, - title: Some("Server Title".into()), - updated_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - // Send an info update followed by a refresh - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title("Local Update"), - }); - session_list.send_update(SessionListUpdate::Refresh); - cx.run_until_parked(); - - // The refresh should have fetched from server, getting "Server Title" - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!( - session.unwrap().title.as_ref().map(|s| s.as_ref()), - Some("Server Title") - ); - }); - } - - #[gpui::test] - async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("known-session"); - let sessions = vec![AgentSessionInfo { - session_id, - cwd: None, - title: Some("Original".into()), - updated_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let (history, cx) = cx.add_window_view(|window, cx| { - AcpThreadHistory::new(Some(session_list.clone()), window, cx) - }); - cx.run_until_parked(); - - // Send an update for an unknown session - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: acp::SessionId::new("unknown-session"), - update: acp::SessionInfoUpdate::new().title("Should Be Ignored"), - }); - cx.run_until_parked(); - - // Check that the known session is unchanged and no crash occurred - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 1); - assert_eq!( - history.sessions[0].title.as_ref().map(|s| s.as_ref()), - Some("Original") - ); - }); - } - - #[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/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs deleted file mode 100644 index e0146a17299488..00000000000000 --- a/crates/agent_ui/src/acp/thread_view.rs +++ /dev/null @@ -1,6640 +0,0 @@ -use acp_thread::{ - AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage, - AssistantMessageChunk, AuthRequired, LoadError, MentionUri, PermissionOptionChoice, - PermissionOptions, RetryStatus, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, - UserMessageId, -}; -use acp_thread::{AgentConnection, Plan}; -#[cfg(feature = "external_websocket_sync")] -use external_websocket_sync_dep as external_websocket_sync; -use action_log::{ActionLog, ActionLogTelemetry}; -use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; -use agent_client_protocol::{self as acp, PromptCapabilities}; -use agent_servers::{AgentServer, AgentServerDelegate}; -use agent_settings::{AgentProfileId, AgentSettings}; -use anyhow::{Result, anyhow}; -use arrayvec::ArrayVec; -use audio::{Audio, Sound}; -use buffer_diff::BufferDiff; -use client::zed_urls; -use collections::{HashMap, HashSet, IndexMap}; -use editor::scroll::Autoscroll; -use editor::{ - Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior, -}; -use feature_flags::{AgentSharingFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _}; -use file_icons::FileIcons; -use fs::Fs; -use futures::FutureExt as _; -use gpui::{ - Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle, - ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit, - PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle, WeakEntity, Window, - WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, point, - pulsating_between, -}; -use language::Buffer; -use language_model::LanguageModelRegistry; -use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; -use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId}; -use prompt_store::{PromptId, PromptStore}; -use rope::Point; -use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; -use std::cell::RefCell; -use std::path::Path; -use std::sync::Arc; -use std::time::Instant; -use std::{collections::BTreeMap, rc::Rc, time::Duration}; -use terminal_view::terminal_panel::TerminalPanel; -use text::{Anchor, ToPoint as _}; -use theme::AgentFontSize; -use ui::{ - Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, - DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind, - KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, - prelude::*, right_click_menu, -}; -use util::{ResultExt, size::format_file_size, time::duration_alt_display}; -use util::{debug_panic, defer}; -use workspace::{ - CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId, -}; -use zed_actions::agent::{Chat, ToggleModelSelector}; -use zed_actions::assistant::OpenRulesLibrary; - -use super::config_options::ConfigOptionsView; -use super::entry_view_state::EntryViewState; -use super::thread_history::AcpThreadHistory; -use crate::acp::AcpModelSelectorPopover; -use crate::acp::ModeSelector; -use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent}; -use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; -use crate::agent_diff::AgentDiff; -use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::ui::{AgentNotification, AgentNotificationEvent}; -use crate::{ - AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, - ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, - EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu, - OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, - SelectPermissionGranularity, SendImmediately, SendNextQueuedMessage, ToggleProfileSelector, - ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, -}; - -const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30); -const TOKEN_THRESHOLD: u64 = 250; - -mod active_thread; -pub use active_thread::*; - -pub struct QueuedMessage { - pub content: Vec, - pub tracked_buffers: Vec>, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum ThreadFeedback { - Positive, - Negative, -} - -#[derive(Debug)] -pub(crate) enum ThreadError { - PaymentRequired, - Refusal, - AuthenticationRequired(SharedString), - Other { - message: SharedString, - acp_error_code: Option, - }, -} - -impl ThreadError { - fn from_err(error: anyhow::Error, agent_name: &str) -> Self { - if error.is::() { - Self::PaymentRequired - } else if let Some(acp_error) = error.downcast_ref::() - && acp_error.code == acp::ErrorCode::AuthRequired - { - Self::AuthenticationRequired(acp_error.message.clone().into()) - } else { - let message: SharedString = format!("{:#}", error).into(); - - // Extract ACP error code if available - let acp_error_code = error - .downcast_ref::() - .map(|acp_error| SharedString::from(acp_error.code.to_string())); - - // TODO: we should have Gemini return better errors here. - if agent_name == "Gemini CLI" - && message.contains("Could not load the default credentials") - || message.contains("API key not valid") - || message.contains("Request had invalid authentication credentials") - { - Self::AuthenticationRequired(message) - } else { - Self::Other { - message, - acp_error_code, - } - } - } - } -} - -impl ProfileProvider for Entity { - fn profile_id(&self, cx: &App) -> AgentProfileId { - self.read(cx).profile().clone() - } - - fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) { - self.update(cx, |thread, cx| { - // Apply the profile and let the thread swap to its default model. - thread.set_profile(profile_id, cx); - }); - } - - fn profiles_supported(&self, cx: &App) -> bool { - self.read(cx) - .model() - .is_some_and(|model| model.supports_tools()) - } -} - -/// No-op AgentConnection for headless threads created by thread_service. -/// These threads are driven entirely by the WebSocket sync system, not by -/// user interaction through the ACP connection. -#[cfg(feature = "external_websocket_sync")] -struct HeadlessConnection; - -#[cfg(feature = "external_websocket_sync")] -impl AgentConnection for HeadlessConnection { - fn telemetry_id(&self) -> SharedString { - "headless".into() - } - - fn new_session( - self: Rc, - _project: Entity, - _cwd: &std::path::Path, - _cx: &mut gpui::App, - ) -> Task>> { - Task::ready(Err(anyhow!("headless connection cannot create sessions"))) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut gpui::App) -> Task> { - Task::ready(Ok(())) - } - - fn prompt( - &self, - _user_message_id: Option, - _params: acp::PromptRequest, - _cx: &mut gpui::App, - ) -> Task> { - Task::ready(Err(anyhow!("headless connection cannot prompt"))) - } - - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut gpui::App) {} - - fn into_any(self: Rc) -> Rc { - self - } -} - -#[derive(Default)] -pub(crate) struct Conversation { - threads: HashMap>, - permission_requests: IndexMap>, - subscriptions: Vec, -} - -impl Conversation { - pub fn register_thread(&mut self, thread: Entity, cx: &mut Context) { - let session_id = thread.read(cx).session_id().clone(); - let subscription = cx.subscribe(&thread, move |this, _thread, event, _cx| match event { - AcpThreadEvent::ToolAuthorizationRequested(id) => { - this.permission_requests - .entry(session_id.clone()) - .or_default() - .push(id.clone()); - } - AcpThreadEvent::ToolAuthorizationReceived(id) => { - if let Some(tool_calls) = this.permission_requests.get_mut(&session_id) { - tool_calls.retain(|tool_call_id| tool_call_id != id); - if tool_calls.is_empty() { - this.permission_requests.shift_remove(&session_id); - } - } - } - AcpThreadEvent::NewEntry - | AcpThreadEvent::TitleUpdated - | AcpThreadEvent::TokenUsageUpdated - | AcpThreadEvent::EntryUpdated(_) - | AcpThreadEvent::EntriesRemoved(_) - | AcpThreadEvent::Retry(_) - | AcpThreadEvent::SubagentSpawned(_) - | AcpThreadEvent::Stopped - | AcpThreadEvent::Error - | AcpThreadEvent::LoadError(_) - | AcpThreadEvent::PromptCapabilitiesUpdated - | AcpThreadEvent::Refusal - | AcpThreadEvent::AvailableCommandsUpdated(_) - | AcpThreadEvent::ModeUpdated(_) - | AcpThreadEvent::ConfigOptionsUpdated(_) => {} - }); - self.subscriptions.push(subscription); - self.threads - .insert(thread.read(cx).session_id().clone(), thread); - } - - pub fn pending_tool_call<'a>( - &'a self, - session_id: &acp::SessionId, - cx: &'a App, - ) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> { - let thread = self.threads.get(session_id)?; - let is_subagent = thread.read(cx).parent_session_id().is_some(); - let (thread, tool_id) = if is_subagent { - let id = self.permission_requests.get(session_id)?.iter().next()?; - (thread, id) - } else { - let (id, tool_calls) = self.permission_requests.first()?; - let thread = self.threads.get(id)?; - let id = tool_calls.iter().next()?; - (thread, id) - }; - let (_, tool_call) = thread.read(cx).tool_call(tool_id)?; - - let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else { - return None; - }; - Some(( - thread.read(cx).session_id().clone(), - tool_id.clone(), - options, - )) - } - - pub fn authorize_pending_tool_call( - &mut self, - session_id: &acp::SessionId, - kind: acp::PermissionOptionKind, - cx: &mut Context, - ) -> Option<()> { - let (_, tool_call_id, options) = self.pending_tool_call(session_id, cx)?; - let option = options.first_option_of_kind(kind)?; - self.authorize_tool_call( - session_id.clone(), - tool_call_id, - option.option_id.clone(), - option.kind, - cx, - ); - Some(()) - } - - pub fn authorize_tool_call( - &mut self, - session_id: acp::SessionId, - tool_call_id: acp::ToolCallId, - option_id: acp::PermissionOptionId, - option_kind: acp::PermissionOptionKind, - cx: &mut Context, - ) { - let Some(thread) = self.threads.get(&session_id) else { - return; - }; - let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); - - telemetry::event!( - "Agent Tool Call Authorized", - agent = agent_telemetry_id, - session = session_id, - option = option_kind - ); - - thread.update(cx, |thread, cx| { - thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); - }); - cx.notify(); - } -} - -pub struct AcpServerView { - agent: Rc, - agent_server_store: Entity, - workspace: WeakEntity, - project: Entity, - thread_store: Option>, - prompt_store: Option>, - server_state: ServerState, - login: Option, // is some <=> Active | Unauthenticated - history: Entity, - focus_handle: FocusHandle, - notifications: Vec>, - notification_subscriptions: HashMap, Vec>, - auth_task: Option>, - _subscriptions: Vec, -} - -impl AcpServerView { - pub fn active_thread(&self) -> Option<&Entity> { - match &self.server_state { - ServerState::Connected(connected) => connected.active_view(), - _ => None, - } - } - - pub fn pending_tool_call<'a>( - &'a self, - cx: &'a App, - ) -> Option<(acp::SessionId, acp::ToolCallId, &'a PermissionOptions)> { - let id = &self.active_thread()?.read(cx).id; - self.as_connected()? - .conversation - .read(cx) - .pending_tool_call(id, cx) - } - - pub fn parent_thread(&self, cx: &App) -> Option> { - match &self.server_state { - ServerState::Connected(connected) => { - let mut current = connected.active_view()?; - while let Some(parent_id) = current.read(cx).parent_id.clone() { - if let Some(parent) = connected.threads.get(&parent_id) { - current = parent; - } else { - break; - } - } - Some(current.clone()) - } - _ => None, - } - } - - pub fn thread_view(&self, session_id: &acp::SessionId) -> Option> { - let connected = self.as_connected()?; - connected.threads.get(session_id).cloned() - } - - pub fn as_connected(&self) -> Option<&ConnectedServerState> { - match &self.server_state { - ServerState::Connected(connected) => Some(connected), - _ => None, - } - } - - pub fn as_connected_mut(&mut self) -> Option<&mut ConnectedServerState> { - match &mut self.server_state { - ServerState::Connected(connected) => Some(connected), - _ => None, - } - } - - pub fn navigate_to_session( - &mut self, - session_id: acp::SessionId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(connected) = self.as_connected_mut() else { - return; - }; - - connected.navigate_to_session(session_id); - if let Some(view) = self.active_thread() { - view.focus_handle(cx).focus(window, cx); - } - cx.notify(); - } -} - -enum ServerState { - Loading(Entity), - LoadError(LoadError), - Connected(ConnectedServerState), -} - -// current -> Entity -// hashmap of threads, current becomes session_id -pub struct ConnectedServerState { - auth_state: AuthState, - active_id: Option, - threads: HashMap>, - connection: Rc, - conversation: Entity, -} - -enum AuthState { - Ok, - Unauthenticated { - description: Option>, - configuration_view: Option, - pending_auth_method: Option, - _subscription: Option, - }, -} - -impl AuthState { - pub fn is_ok(&self) -> bool { - matches!(self, Self::Ok) - } -} - -struct LoadingView { - title: SharedString, - _load_task: Task<()>, - _update_title_task: Task>, -} - -impl ConnectedServerState { - pub fn active_view(&self) -> Option<&Entity> { - self.active_id.as_ref().and_then(|id| self.threads.get(id)) - } - - pub fn has_thread_error(&self, cx: &App) -> bool { - self.active_view() - .map_or(false, |view| view.read(cx).thread_error.is_some()) - } - - pub fn navigate_to_session(&mut self, session_id: acp::SessionId) { - if self.threads.contains_key(&session_id) { - self.active_id = Some(session_id); - } - } - - pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> { - let tasks = self - .threads - .keys() - .map(|id| self.connection.close_session(id, cx)); - let task = futures::future::join_all(tasks); - cx.background_spawn(async move { - task.await; - }) - } -} - -impl AcpServerView { - pub fn new( - agent: Rc, - resume_thread: Option, - initial_content: Option, - workspace: WeakEntity, - project: Entity, - thread_store: Option>, - prompt_store: Option>, - history: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let agent_server_store = project.read(cx).agent_server_store().clone(); - let subscriptions = vec![ - cx.observe_global_in::(window, Self::agent_ui_font_size_changed), - cx.observe_global_in::(window, Self::agent_ui_font_size_changed), - cx.subscribe_in( - &agent_server_store, - window, - Self::handle_agent_servers_updated, - ), - ]; - - cx.on_release(|this, cx| { - if let Some(connected) = this.as_connected() { - connected.close_all_sessions(cx).detach(); - } - for window in this.notifications.drain(..) { - window - .update(cx, |_, window, _| { - window.remove_window(); - }) - .ok(); - } - }) - .detach(); - - Self { - agent: agent.clone(), - agent_server_store, - workspace, - project: project.clone(), - thread_store, - prompt_store, - server_state: Self::initial_state( - agent.clone(), - resume_thread, - project, - initial_content, - window, - cx, - ), - login: None, - notifications: Vec::new(), - notification_subscriptions: HashMap::default(), - auth_task: None, - history, - _subscriptions: subscriptions, - focus_handle: cx.focus_handle(), - } - } - - /// Create an AcpServerView wrapping an existing headless thread entity. - /// - /// This bypasses the normal connection/loading path for threads created - /// by thread_service (WebSocket-created threads). Each server.connect() - /// creates a new NativeAgent instance, so the thread exists in one agent's - /// sessions but the UI would use another — we can't look it up. - /// Instead, we wrap the existing Entity directly. - #[cfg(feature = "external_websocket_sync")] - pub fn from_existing_thread( - thread: gpui::Entity, - agent: Rc, - workspace: WeakEntity, - project: Entity, - thread_store: Option>, - prompt_store: Option>, - history: Entity, - agent_name: Option, - 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 agent_server_store = project.read(cx).agent_server_store().clone(); - let subscriptions = vec![ - cx.observe_global_in::(window, Self::agent_ui_font_size_changed), - cx.observe_global_in::(window, Self::agent_ui_font_size_changed), - cx.subscribe_in( - &agent_server_store, - window, - Self::handle_agent_servers_updated, - ), - ]; - - cx.on_release(|this, cx| { - // Unregister thread from THREAD_REGISTRY so stale entries don't - // cause split-brain when the same session is loaded via the UI path. - // - // Only remove the entry if it still points at the thread entity this - // view was holding. After a Zed restart, `load_session_async` may - // have replaced the registration with a fresh entity (Y) while the - // AgentPanel rebound to display Y — dropping this older view (X). - // A blind unregister at that moment would clobber Y's registration - // and leave subsequent dispatches and subscription bookkeeping in an - // inconsistent state. - if let ServerState::Connected(connected) = &this.server_state { - if let Some(id) = &connected.active_id { - let session_id_str = id.to_string(); - if let Some(active_view) = connected.active_view() { - let thread_entity_id = active_view.read(cx).thread.entity_id(); - external_websocket_sync::unregister_thread_if_matches( - &session_id_str, - thread_entity_id, - ); - } else { - external_websocket_sync::unregister_thread(&session_id_str); - } - } - } - for window in this.notifications.drain(..) { - window - .update(cx, |_, window, _| { - window.remove_window(); - }) - .ok(); - } - }) - .detach(); - - // Build the entry view state and sync existing entries - let agent_name_str = agent_name.unwrap_or_else(|| agent.name().to_string()); - let display_name: SharedString = agent_name_str.clone().into(); - let agent_shared_name: SharedString = agent_name_str.into(); - - let entry_view_state = cx.new(|_cx| { - EntryViewState::new( - workspace.clone(), - project.downgrade(), - thread_store.clone(), - history.downgrade(), - prompt_store.clone(), - prompt_capabilities.clone(), - available_commands.clone(), - agent_shared_name.clone(), - ) - }); - - let list_state = ListState::new(0, gpui::ListAlignment::Bottom, gpui::px(2048.0)); - - // Sync existing entries from the thread - let count = thread.read(cx).entries().len(); - entry_view_state.update(cx, |view_state, cx| { - for ix in 0..count { - view_state.sync_entry(ix, &thread, window, cx); - } - }); - list_state.splice_focusable( - 0..0, - (0..count).map(|ix| entry_view_state.read(cx).entry(ix).and_then(|e| e.focus_handle(cx))), - ); - - let action_log = thread.read(cx).action_log().clone(); - - // Subscribe to thread events for UI updates. - // CRITICAL: The NativeAgentConnection bridge emits AcpThreadEvent from - // cx.spawn() without window context. GPUI's subscribe_in silently drops - // those events. We use a windowless cx.subscribe to catch ALL events, - // forward them through a channel, and process them in a cx.spawn_in task - // that has window context. - let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); - let thread_for_sub = thread.clone(); - let thread_sub = cx.subscribe(&thread_for_sub, move |_this, _thread, _event, _cx| { - let _ = event_tx.send(()); - }); - - let thread_for_spawn = thread.downgrade(); - let initial_entry_count = count; // entries already synced above - cx.spawn_in(window, async move |this, cx| { - let mut synced_count = initial_entry_count; - while event_rx.recv().await.is_some() { - // Drain any additional queued events (batch) - while event_rx.try_recv().is_ok() {} - - this.update_in(cx, |this, window, cx| { - if let Some(thread) = thread_for_spawn.upgrade() { - // Sync new entries to the UI - if let Some(active) = this.active_thread() { - let total = thread.read(cx).entries().len(); - if total > synced_count { - let entry_view_state = active.read(cx).entry_view_state.clone(); - let list_state = active.read(cx).list_state.clone(); - entry_view_state.update(cx, |view_state, cx| { - for ix in synced_count..total { - view_state.sync_entry(ix, &thread, window, cx); - } - }); - list_state.splice_focusable( - synced_count..synced_count, - (synced_count..total).map(|ix| { - entry_view_state.read(cx).entry(ix).and_then(|e| e.focus_handle(cx)) - }), - ); - synced_count = total; - } - } - cx.notify(); - } - }).ok(); - } - anyhow::Ok(()) - }).detach(); - - let thread_subscriptions = vec![ - thread_sub, - cx.observe(&action_log, |_, _, cx| cx.notify()), - ]; - - // Create a no-op connection for headless threads - let connection: Rc = Rc::new(HeadlessConnection); - - let weak = cx.weak_entity(); - let agent_icon = agent.logo(); - let conversation_entity = cx.new(|cx| { - let mut conv = Conversation::default(); - conv.register_thread(thread.clone(), cx); - conv - }); - let current = cx.new(|cx| { - AcpThreadView::new( - None, // parent_id - thread.clone(), - conversation_entity.clone(), - None, // login - weak, - agent_icon, - agent_shared_name.clone(), - display_name, - workspace.clone(), - entry_view_state, - None, // config_options_view - None, // mode_selector - None, // model_selector - None, // profile_selector - list_state, - prompt_capabilities, - available_commands, - false, // resumed_without_history - None, // resume_thread_metadata - project.downgrade(), - thread_store.clone(), - history.clone(), - prompt_store.clone(), - None, // initial_content - thread_subscriptions, - window, - cx, - ) - }); - - let id = thread.read(cx).session_id().clone(); - - // Register thread in THREAD_REGISTRY - { - let session_id_str = id.to_string(); - external_websocket_sync::register_thread(session_id_str, thread); - } - - Self { - agent, - agent_server_store, - workspace, - project, - thread_store, - prompt_store, - server_state: ServerState::Connected(ConnectedServerState { - connection, - auth_state: AuthState::Ok, - active_id: Some(id.clone()), - threads: HashMap::from_iter([(id, current)]), - conversation: conversation_entity, - }), - login: None, - notifications: Vec::new(), - notification_subscriptions: HashMap::default(), - auth_task: None, - history, - _subscriptions: subscriptions, - focus_handle: cx.focus_handle(), - } - } - - fn set_server_state(&mut self, state: ServerState, cx: &mut Context) { - if let Some(connected) = self.as_connected() { - connected.close_all_sessions(cx).detach(); - } - - self.server_state = state; - cx.notify(); - } - - fn reset(&mut self, window: &mut Window, cx: &mut Context) { - let resume_thread_metadata = self - .active_thread() - .and_then(|thread| thread.read(cx).resume_thread_metadata.clone()); - - let state = Self::initial_state( - self.agent.clone(), - resume_thread_metadata, - self.project.clone(), - None, - window, - cx, - ); - self.set_server_state(state, cx); - - if let Some(view) = self.active_thread() { - view.update(cx, |this, cx| { - this.message_editor.update(cx, |editor, cx| { - editor.set_command_state( - this.prompt_capabilities.clone(), - this.available_commands.clone(), - cx, - ); - }); - }); - } - cx.notify(); - } - - fn initial_state( - agent: Rc, - resume_thread: Option, - project: Entity, - initial_content: Option, - window: &mut Window, - cx: &mut Context, - ) -> ServerState { - if project.read(cx).is_via_collab() - && agent.clone().downcast::().is_none() - { - return ServerState::LoadError(LoadError::Other( - "External agents are not yet supported in shared projects.".into(), - )); - } - let mut worktrees = project.read(cx).visible_worktrees(cx).collect::>(); - // Pick the first non-single-file worktree for the root directory if there are any, - // and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees. - worktrees.sort_by(|l, r| { - l.read(cx) - .is_single_file() - .cmp(&r.read(cx).is_single_file()) - }); - let worktree_roots: Vec> = worktrees - .iter() - .filter_map(|worktree| { - let worktree = worktree.read(cx); - if worktree.is_single_file() { - Some(worktree.abs_path().parent()?.into()) - } else { - Some(worktree.abs_path()) - } - }) - .collect(); - let session_cwd = resume_thread - .as_ref() - .and_then(|resume| { - resume - .cwd - .as_ref() - .and_then(|cwd| util::paths::normalize_lexically(cwd).ok()) - .filter(|cwd| { - worktree_roots - .iter() - .any(|root| cwd.starts_with(root.as_ref())) - }) - .map(|path| path.into()) - }) - .or_else(|| worktree_roots.first().cloned()) - .unwrap_or_else(|| paths::home_dir().as_path().into()); - - let (status_tx, mut status_rx) = watch::channel("Loading…".into()); - let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - Some(status_tx), - Some(new_version_available_tx), - ); - - let connect_task = agent.connect(delegate, cx); - let load_task = cx.spawn_in(window, async move |this, cx| { - let connection = match connect_task.await { - Ok((connection, login)) => { - this.update(cx, |this, _| this.login = login).ok(); - connection - } - Err(err) => { - this.update_in(cx, |this, window, cx| { - if err.downcast_ref::().is_some() { - this.handle_load_error(err, window, cx); - } else if let Some(active) = this.active_thread() { - active.update(cx, |active, cx| active.handle_any_thread_error(err, cx)); - } else { - this.handle_load_error(err, window, cx); - } - cx.notify(); - }) - .log_err(); - return; - } - }; - - telemetry::event!("Agent Thread Started", agent = connection.telemetry_id()); - - let mut resumed_without_history = false; - let result = if let Some(resume) = resume_thread.clone() { - cx.update(|_, cx| { - if connection.supports_load_session() { - connection - .clone() - .load_session(resume, project.clone(), &session_cwd, cx) - } else if connection.supports_resume_session() { - resumed_without_history = true; - connection - .clone() - .resume_session(resume, project.clone(), &session_cwd, cx) - } else { - Task::ready(Err(anyhow!(LoadError::Other( - "Loading or resuming sessions is not supported by this agent.".into() - )))) - } - }) - .log_err() - } else { - cx.update(|_, cx| { - connection - .clone() - .new_session(project.clone(), session_cwd.as_ref(), cx) - }) - .log_err() - }; - - let Some(result) = result else { - return; - }; - - let result = match result.await { - Err(e) => match e.downcast::() { - Ok(err) => { - cx.update(|window, cx| { - Self::handle_auth_required( - this, - err, - agent.name(), - connection, - window, - cx, - ) - }) - .log_err(); - return; - } - Err(err) => Err(err), - }, - Ok(thread) => Ok(thread), - }; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(thread) => { - let conversation = cx.new(|cx| { - let mut conversation = Conversation::default(); - conversation.register_thread(thread.clone(), cx); - conversation - }); - - #[cfg(feature = "external_websocket_sync")] - let is_resume = resume_thread.is_some(); - - let current = this.new_thread_view( - None, - thread, - conversation.clone(), - resumed_without_history, - resume_thread, - initial_content, - window, - cx, - ); - - if this.focus_handle.contains_focused(window, cx) { - current - .read(cx) - .message_editor - .focus_handle(cx) - .focus(window, cx); - } - - // Register thread with THREAD_REGISTRY so WebSocket commands find this entity - #[cfg(feature = "external_websocket_sync")] - { - let session_id = current.read(cx).thread.read(cx).session_id().to_string(); - external_websocket_sync::register_thread(session_id, current.read(cx).thread.clone()); - } - - let id = current.read(cx).thread.read(cx).session_id().clone(); - this.set_server_state( - ServerState::Connected(ConnectedServerState { - connection, - auth_state: AuthState::Ok, - active_id: Some(id.clone()), - threads: HashMap::from_iter([(id, current.clone())]), - conversation, - }), - cx, - ); - - // Defer UserCreatedThread to the first user-message NewEntry. - // See conversation_view.rs for the full reasoning and link to - // the design doc. - #[cfg(feature = "external_websocket_sync")] - { - if !is_resume { - let thread_entity = ¤t.read(cx).thread; - let acp_thread_id = thread_entity.read(cx).session_id().to_string(); - let title = thread_entity.read(cx).title().to_string(); - let title_opt = if title.is_empty() { None } else { Some(title) }; - external_websocket_sync::defer_user_created_thread( - acp_thread_id, - title_opt, - ); - } - } - } - Err(err) => { - this.handle_load_error(err, window, cx); - } - }; - }) - .log_err(); - }); - - cx.spawn(async move |this, cx| { - while let Ok(new_version) = new_version_available_rx.recv().await { - if let Some(new_version) = new_version { - this.update(cx, |this, cx| { - if let Some(thread) = this.active_thread() { - thread.update(cx, |thread, _cx| { - thread.new_server_version_available = Some(new_version.into()); - }); - } - cx.notify(); - }) - .ok(); - } - } - }) - .detach(); - - let loading_view = cx.new(|cx| { - let update_title_task = cx.spawn(async move |this, cx| { - loop { - let status = status_rx.recv().await?; - this.update(cx, |this: &mut LoadingView, cx| { - this.title = status; - cx.notify(); - })?; - } - }); - - LoadingView { - title: "Loading…".into(), - _load_task: load_task, - _update_title_task: update_title_task, - } - }); - - ServerState::Loading(loading_view) - } - - fn new_thread_view( - &self, - parent_id: Option, - thread: Entity, - conversation: Entity, - resumed_without_history: bool, - resume_thread: Option, - initial_content: Option, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - let agent_name = self.agent.name(); - let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); - let available_commands = Rc::new(RefCell::new(vec![])); - - let action_log = thread.read(cx).action_log().clone(); - - prompt_capabilities.replace(thread.read(cx).prompt_capabilities()); - - let entry_view_state = cx.new(|_| { - EntryViewState::new( - self.workspace.clone(), - self.project.downgrade(), - self.thread_store.clone(), - self.history.downgrade(), - self.prompt_store.clone(), - prompt_capabilities.clone(), - available_commands.clone(), - self.agent.name(), - ) - }); - - let count = thread.read(cx).entries().len(); - let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0)); - entry_view_state.update(cx, |view_state, cx| { - for ix in 0..count { - view_state.sync_entry(ix, &thread, window, cx); - } - list_state.splice_focusable( - 0..0, - (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)), - ); - }); - - AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx); - - let connection = thread.read(cx).connection().clone(); - let session_id = thread.read(cx).session_id().clone(); - let session_list = if connection.supports_session_history() { - connection.session_list(cx) - } else { - None - }; - self.history.update(cx, |history, cx| { - history.set_session_list(session_list, cx); - }); - - // Check for config options first - // Config options take precedence over legacy mode/model selectors - // (feature flag gating happens at the data layer) - let config_options_provider = connection.session_config_options(&session_id, cx); - - let config_options_view; - let mode_selector; - let model_selector; - if let Some(config_options) = config_options_provider { - // Use config options - don't create mode_selector or model_selector - let agent_server = self.agent.clone(); - let fs = self.project.read(cx).fs().clone(); - config_options_view = - Some(cx.new(|cx| { - ConfigOptionsView::new(config_options, agent_server, fs, window, cx) - })); - model_selector = None; - mode_selector = None; - } else { - // Fall back to legacy mode/model selectors - config_options_view = None; - model_selector = connection.model_selector(&session_id).map(|selector| { - let agent_server = self.agent.clone(); - let fs = self.project.read(cx).fs().clone(); - cx.new(|cx| { - AcpModelSelectorPopover::new( - selector, - agent_server, - fs, - PopoverMenuHandle::default(), - self.focus_handle(cx), - window, - cx, - ) - }) - }); - - mode_selector = connection - .session_modes(&session_id, cx) - .map(|session_modes| { - let fs = self.project.read(cx).fs().clone(); - cx.new(|_cx| ModeSelector::new(session_modes, self.agent.clone(), fs)) - }); - } - - let subscriptions = vec![ - cx.subscribe_in(&thread, window, Self::handle_thread_event), - cx.observe(&action_log, |_, _, cx| cx.notify()), - ]; - - let parent_session_id = thread.read(cx).session_id().clone(); - let subagent_sessions = thread - .read(cx) - .entries() - .iter() - .filter_map(|entry| match entry { - AgentThreadEntry::ToolCall(call) => call.subagent_session_id.clone(), - _ => None, - }) - .collect::>(); - - if !subagent_sessions.is_empty() { - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |this, window, cx| { - for subagent_id in subagent_sessions { - this.load_subagent_session( - subagent_id, - parent_session_id.clone(), - window, - cx, - ); - } - }) - }) - .detach(); - } - - let profile_selector: Option> = - connection.clone().downcast(); - let profile_selector = profile_selector - .and_then(|native_connection| native_connection.thread(&session_id, cx)) - .map(|native_thread| { - cx.new(|cx| { - ProfileSelector::new( - ::global(cx), - Arc::new(native_thread), - self.focus_handle(cx), - cx, - ) - }) - }); - - let agent_display_name = self - .agent_server_store - .read(cx) - .agent_display_name(&ExternalAgentServerName(agent_name.clone())) - .unwrap_or_else(|| agent_name.clone()); - - let agent_icon = self.agent.logo(); - - let weak = cx.weak_entity(); - cx.new(|cx| { - AcpThreadView::new( - parent_id, - thread, - conversation, - self.login.clone(), - weak, - agent_icon, - agent_name, - agent_display_name, - self.workspace.clone(), - entry_view_state, - config_options_view, - mode_selector, - model_selector, - profile_selector, - list_state, - prompt_capabilities, - available_commands, - resumed_without_history, - resume_thread, - self.project.downgrade(), - self.thread_store.clone(), - self.history.clone(), - self.prompt_store.clone(), - initial_content, - subscriptions, - window, - cx, - ) - }) - } - - fn handle_auth_required( - this: WeakEntity, - err: AuthRequired, - agent_name: SharedString, - connection: Rc, - window: &mut Window, - cx: &mut App, - ) { - let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id { - let registry = LanguageModelRegistry::global(cx); - - let sub = window.subscribe(®istry, cx, { - let provider_id = provider_id.clone(); - let this = this.clone(); - move |_, ev, window, cx| { - if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev - && &provider_id == updated_provider_id - && LanguageModelRegistry::global(cx) - .read(cx) - .provider(&provider_id) - .map_or(false, |provider| provider.is_authenticated(cx)) - { - this.update(cx, |this, cx| { - this.reset(window, cx); - }) - .ok(); - } - } - }); - - let view = registry.read(cx).provider(&provider_id).map(|provider| { - provider.configuration_view( - language_model::ConfigurationViewTargetAgent::Other(agent_name), - window, - cx, - ) - }); - - (view, Some(sub)) - } else { - (None, None) - }; - - this.update(cx, |this, cx| { - let description = err - .description - .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))); - let auth_state = AuthState::Unauthenticated { - pending_auth_method: None, - configuration_view, - description, - _subscription: subscription, - }; - if let Some(connected) = this.as_connected_mut() { - connected.auth_state = auth_state; - if let Some(view) = connected.active_view() - && view - .read(cx) - .message_editor - .focus_handle(cx) - .is_focused(window) - { - this.focus_handle.focus(window, cx) - } - } else { - this.set_server_state( - ServerState::Connected(ConnectedServerState { - auth_state, - active_id: None, - threads: HashMap::default(), - connection, - conversation: cx.new(|_cx| Conversation::default()), - }), - cx, - ); - } - cx.notify(); - }) - .ok(); - } - - fn handle_load_error( - &mut self, - err: anyhow::Error, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(view) = self.active_thread() { - if view - .read(cx) - .message_editor - .focus_handle(cx) - .is_focused(window) - { - self.focus_handle.focus(window, cx) - } - } - let load_error = if let Some(load_err) = err.downcast_ref::() { - load_err.clone() - } else { - LoadError::Other(format!("{:#}", err).into()) - }; - self.emit_load_error_telemetry(&load_error); - self.set_server_state(ServerState::LoadError(load_error), cx); - } - - fn handle_agent_servers_updated( - &mut self, - _agent_server_store: &Entity, - _event: &project::AgentServersUpdated, - window: &mut Window, - cx: &mut Context, - ) { - // If we're in a LoadError state OR have a thread_error set (which can happen - // when agent.connect() fails during loading), retry loading the thread. - // This handles the case where a thread is restored before authentication completes. - let should_retry = match &self.server_state { - ServerState::Loading(_) => false, - ServerState::LoadError(_) => true, - ServerState::Connected(connected) => { - connected.auth_state.is_ok() && connected.has_thread_error(cx) - } - }; - - if should_retry { - if let Some(active) = self.active_thread() { - active.update(cx, |active, cx| { - active.clear_thread_error(cx); - }); - } - self.reset(window, cx); - } - } - - pub fn workspace(&self) -> &WeakEntity { - &self.workspace - } - - pub fn title(&self, cx: &App) -> SharedString { - match &self.server_state { - ServerState::Connected(_) => "New Thread".into(), - ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(), - ServerState::LoadError(error) => match error { - LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), - LoadError::FailedToInstall(_) => { - format!("Failed to Install {}", self.agent.name()).into() - } - LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), - LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), - }, - } - } - - pub fn cancel_generation(&mut self, cx: &mut Context) { - if let Some(active) = self.active_thread() { - active.update(cx, |active, cx| { - active.cancel_generation(cx); - }); - } - } - - pub fn is_loading(&self) -> bool { - matches!(self.server_state, ServerState::Loading { .. }) - } - - fn update_turn_tokens(&mut self, cx: &mut Context) { - if let Some(active) = self.active_thread() { - active.update(cx, |active, cx| { - active.update_turn_tokens(cx); - }); - } - } - - fn send_queued_message_at_index( - &mut self, - index: usize, - is_send_now: bool, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active) = self.active_thread() { - active.update(cx, |active, cx| { - active.send_queued_message_at_index(index, is_send_now, window, cx); - }); - } - } - - fn handle_thread_event( - &mut self, - thread: &Entity, - event: &AcpThreadEvent, - window: &mut Window, - cx: &mut Context, - ) { - let thread_id = thread.read(cx).session_id().clone(); - let is_subagent = thread.read(cx).parent_session_id().is_some(); - match event { - AcpThreadEvent::NewEntry => { - let len = thread.read(cx).entries().len(); - let index = len - 1; - if let Some(active) = self.thread_view(&thread_id) { - let entry_view_state = active.read(cx).entry_view_state.clone(); - let list_state = active.read(cx).list_state.clone(); - entry_view_state.update(cx, |view_state, cx| { - view_state.sync_entry(index, thread, window, cx); - list_state.splice_focusable( - index..index, - [view_state - .entry(index) - .and_then(|entry| entry.focus_handle(cx))], - ); - }); - } - - // WebSocket event forwarding for NewEntry is handled by thread_service.rs - // (see create_new_thread_sync / handle_follow_up_message / load_thread_from_agent) - } - AcpThreadEvent::EntryUpdated(index) => { - if let Some(entry_view_state) = self - .thread_view(&thread_id) - .map(|active| active.read(cx).entry_view_state.clone()) - { - entry_view_state.update(cx, |view_state, cx| { - view_state.sync_entry(*index, thread, window, cx) - }); - } - - // WebSocket event forwarding for EntryUpdated is handled by thread_service.rs - } - AcpThreadEvent::EntriesRemoved(range) => { - if let Some(active) = self.thread_view(&thread_id) { - let entry_view_state = active.read(cx).entry_view_state.clone(); - let list_state = active.read(cx).list_state.clone(); - entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone())); - list_state.splice(range.clone(), 0); - } - } - AcpThreadEvent::SubagentSpawned(session_id) => self.load_subagent_session( - session_id.clone(), - thread.read(cx).session_id().clone(), - window, - cx, - ), - AcpThreadEvent::ToolAuthorizationRequested(_) => { - self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx); - } - AcpThreadEvent::ToolAuthorizationReceived(_) => {} - AcpThreadEvent::Retry(retry) => { - if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { - active.thread_retry_status = Some(retry.clone()); - }); - } - } - AcpThreadEvent::Stopped => { - if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { - active.thread_retry_status.take(); - }); - } - if is_subagent { - return; - } - - let used_tools = thread.read(cx).used_tools_since_last_user_message(); - self.notify_with_sound( - if used_tools { - "Finished running tools" - } else { - "New message" - }, - IconName::ZedAssistant, - window, - cx, - ); - - let should_send_queued = if let Some(active) = self.active_thread() { - active.update(cx, |active, cx| { - if active.skip_queue_processing_count > 0 { - active.skip_queue_processing_count -= 1; - false - } else if active.user_interrupted_generation { - // Manual interruption: don't auto-process queue. - // Reset the flag so future completions can process normally. - active.user_interrupted_generation = false; - false - } else { - let has_queued = !active.local_queued_messages.is_empty(); - // Don't auto-send if the first message editor is currently focused - let is_first_editor_focused = active - .queued_message_editors - .first() - .is_some_and(|editor| editor.focus_handle(cx).is_focused(window)); - has_queued && !is_first_editor_focused - } - }) - } else { - false - }; - if should_send_queued { - self.send_queued_message_at_index(0, false, window, cx); - } - - // WebSocket MessageCompleted is emitted by the persistent subscription's - // Stopped handler in thread_service.rs (create_new_thread_sync / - // load_thread_from_agent). No action needed here. - - self.history.update(cx, |history, cx| history.refresh(cx)); - } - AcpThreadEvent::Refusal => { - let error = ThreadError::Refusal; - if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, cx| { - active.handle_thread_error(error, cx); - active.thread_retry_status.take(); - }); - } - if !is_subagent { - let model_or_agent_name = self.current_model_name(cx); - let notification_message = - format!("{} refused to respond to this request", model_or_agent_name); - self.notify_with_sound(¬ification_message, IconName::Warning, window, cx); - } - } - AcpThreadEvent::Error => { - if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { - active.thread_retry_status.take(); - }); - } - if !is_subagent { - self.notify_with_sound( - "Agent stopped due to an error", - IconName::Warning, - window, - cx, - ); - } - } - AcpThreadEvent::LoadError(error) => { - if let Some(view) = self.active_thread() { - if view - .read(cx) - .message_editor - .focus_handle(cx) - .is_focused(window) - { - self.focus_handle.focus(window, cx) - } - } - self.set_server_state(ServerState::LoadError(error.clone()), cx); - } - AcpThreadEvent::TitleUpdated => { - let title = thread.read(cx).title(); - if let Some(active_thread) = self.thread_view(&thread_id) { - let title_editor = active_thread.read(cx).title_editor.clone(); - title_editor.update(cx, |editor, cx| { - if editor.text(cx) != title { - editor.set_text(title, window, cx); - } - }); - } - // Notify external system of title change - #[cfg(feature = "external_websocket_sync")] - { - let acp_thread_id = thread.read(cx).session_id().to_string(); - let title_str = thread.read(cx).title().to_string(); - - if let Err(e) = external_websocket_sync::send_websocket_event( - external_websocket_sync::SyncEvent::ThreadTitleChanged { - acp_thread_id, - title: title_str, - } - ) { - log::error!("Failed to send ThreadTitleChanged WebSocket event: {}", e); - } - } - - self.history.update(cx, |history, cx| history.refresh(cx)); - } - AcpThreadEvent::PromptCapabilitiesUpdated => { - if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { - active - .prompt_capabilities - .replace(thread.read(_cx).prompt_capabilities()); - }); - } - } - AcpThreadEvent::TokenUsageUpdated => { - self.update_turn_tokens(cx); - self.emit_token_limit_telemetry_if_needed(thread, cx); - } - AcpThreadEvent::AvailableCommandsUpdated(available_commands) => { - let mut available_commands = available_commands.clone(); - - if thread - .read(cx) - .connection() - .auth_methods() - .iter() - .any(|method| method.id.0.as_ref() == "claude-login") - { - available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); - available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); - } - - let has_commands = !available_commands.is_empty(); - if let Some(active) = self.active_thread() { - active.update(cx, |active, _cx| { - active.available_commands.replace(available_commands); - }); - } - - let agent_display_name = self - .agent_server_store - .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); - - if let Some(active) = self.active_thread() { - let new_placeholder = - placeholder_text(agent_display_name.as_ref(), has_commands); - active.update(cx, |active, cx| { - active.message_editor.update(cx, |editor, cx| { - editor.set_placeholder_text(&new_placeholder, window, cx); - }); - }); - } - } - AcpThreadEvent::ModeUpdated(_mode) => { - // The connection keeps track of the mode - cx.notify(); - } - AcpThreadEvent::ConfigOptionsUpdated(_) => { - // The watch task in ConfigOptionsView handles rebuilding selectors - cx.notify(); - } - } - cx.notify(); - } - - fn authenticate( - &mut self, - method: acp::AuthMethodId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(connected) = self.as_connected_mut() else { - return; - }; - let connection = connected.connection.clone(); - - let AuthState::Unauthenticated { - configuration_view, - pending_auth_method, - .. - } = &mut connected.auth_state - 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); - - if let Some(terminal_auth) = auth_method - .and_then(|a| a.meta.as_ref()) - .and_then(|m| m.get("terminal-auth")) - { - // Extract terminal auth details from meta - if let (Some(command), Some(label)) = ( - terminal_auth.get("command").and_then(|v| v.as_str()), - terminal_auth.get("label").and_then(|v| v.as_str()), - ) { - let args = terminal_auth - .get("args") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let env = terminal_auth - .get("env") - .and_then(|v| v.as_object()) - .map(|obj| { - obj.iter() - .filter_map(|(k, v)| v.as_str().map(|val| (k.clone(), val.to_string()))) - .collect::>() - }) - .unwrap_or_default(); - - // Build SpawnInTerminal from _meta - let login = task::SpawnInTerminal { - id: task::TaskId(format!("external-agent-{}-login", label)), - full_label: label.to_string(), - label: label.to_string(), - command: Some(command.to_string()), - args, - command_label: label.to_string(), - env, - use_new_terminal: true, - allow_concurrent_runs: true, - hide: task::HideStrategy::Always, - ..Default::default() - }; - - configuration_view.take(); - pending_auth_method.replace(method.clone()); - - if let Some(workspace) = self.workspace.upgrade() { - let project = self.project.clone(); - let authenticate = Self::spawn_external_agent_login( - login, - workspace, - project, - method.clone(), - false, - window, - cx, - ); - cx.notify(); - 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 Some(ConnectedServerState { - auth_state: - AuthState::Unauthenticated { - pending_auth_method, - .. - }, - .. - }) = this.as_connected_mut() - { - pending_auth_method.take(); - } - if let Some(active) = this.active_thread() { - active.update(cx, |active, cx| { - active.handle_any_thread_error(err, cx); - }) - } - } else { - this.reset(window, cx); - } - this.auth_task.take() - }) - .ok(); - } - })); - } - return; - } - } - - if method.0.as_ref() == "gemini-api-key" { - let registry = LanguageModelRegistry::global(cx); - let provider = registry - .read(cx) - .provider(&language_model::GOOGLE_PROVIDER_ID) - .unwrap(); - if !provider.is_authenticated(cx) { - let this = cx.weak_entity(); - let agent_name = self.agent.name(); - let connection = connection.clone(); - window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired { - description: Some("GEMINI_API_KEY must be set".to_owned()), - provider_id: Some(language_model::GOOGLE_PROVIDER_ID), - }, - agent_name, - connection, - window, - cx, - ); - }); - 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() - || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err())) - { - let this = cx.weak_entity(); - let agent_name = self.agent.name(); - let connection = connection.clone(); - - window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired { - description: Some( - "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed." - .to_owned(), - ), - provider_id: None, - }, - agent_name, - connection, - window, - cx, - ) - }); - return; - } - - configuration_view.take(); - pending_auth_method.replace(method.clone()); - let authenticate = if let Some(login) = self.login.clone() { - if let Some(workspace) = self.workspace.upgrade() { - let project = self.project.clone(); - Self::spawn_external_agent_login( - login, - workspace, - project, - method.clone(), - false, - window, - cx, - ) - } else { - Task::ready(Ok(())) - } - } else { - connection.authenticate(method, cx) - }; - cx.notify(); - 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 Some(ConnectedServerState { - auth_state: - AuthState::Unauthenticated { - pending_auth_method, - .. - }, - .. - }) = this.as_connected_mut() - { - pending_auth_method.take(); - } - if let Some(active) = this.active_thread() { - active.update(cx, |active, cx| active.handle_any_thread_error(err, cx)); - } - } else { - this.reset(window, cx); - } - this.auth_task.take() - }) - .ok(); - } - })); - } - - fn load_subagent_session( - &mut self, - subagent_id: acp::SessionId, - parent_id: acp::SessionId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(connected) = self.as_connected() else { - return; - }; - if connected.threads.contains_key(&subagent_id) - || !connected.connection.supports_load_session() - { - return; - } - let root_dir = self - .project - .read(cx) - .worktrees(cx) - .filter_map(|worktree| { - if worktree.read(cx).is_single_file() { - Some(worktree.read(cx).abs_path().parent()?.into()) - } else { - Some(worktree.read(cx).abs_path()) - } - }) - .next(); - let cwd = root_dir.unwrap_or_else(|| paths::home_dir().as_path().into()); - - let subagent_thread_task = connected.connection.clone().load_session( - AgentSessionInfo::new(subagent_id.clone()), - self.project.clone(), - &cwd, - cx, - ); - - cx.spawn_in(window, async move |this, cx| { - let subagent_thread = subagent_thread_task.await?; - this.update_in(cx, |this, window, cx| { - let conversation = this - .as_connected() - .map(|connected| connected.conversation.clone()); - let Some(conversation) = conversation else { - return; - }; - conversation.update(cx, |conversation, cx| { - conversation.register_thread(subagent_thread.clone(), cx); - }); - let view = this.new_thread_view( - Some(parent_id), - subagent_thread, - conversation, - false, - None, - None, - window, - cx, - ); - let Some(connected) = this.as_connected_mut() else { - return; - }; - connected.threads.insert(subagent_id, view); - }) - }) - .detach(); - } - - fn spawn_external_agent_login( - login: task::SpawnInTerminal, - workspace: Entity, - project: Entity, - method: acp::AuthMethodId, - previous_attempt: bool, - window: &mut Window, - cx: &mut App, - ) -> Task> { - let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { - return Task::ready(Ok(())); - }; - - window.spawn(cx, async move |cx| { - let mut task = login.clone(); - if let Some(cmd) = &task.command { - // Have "node" command use Zed's managed Node runtime by default - if cmd == "node" { - let resolved_node_runtime = project - .update(cx, |project, cx| { - let agent_server_store = project.agent_server_store().clone(); - agent_server_store.update(cx, |store, cx| { - store.node_runtime().map(|node_runtime| { - cx.background_spawn(async move { - node_runtime.binary_path().await - }) - }) - }) - }); - - if let Some(resolve_task) = resolved_node_runtime { - if let Ok(node_path) = resolve_task.await { - task.command = Some(node_path.to_string_lossy().to_string()); - } - } - } - } - task.shell = task::Shell::WithArguments { - program: task.command.take().expect("login command should be set"), - args: std::mem::take(&mut task.args), - title_override: None - }; - task.full_label = task.label.clone(); - task.id = task::TaskId(format!("external-agent-{}-login", task.label)); - task.command_label = task.label.clone(); - task.use_new_terminal = true; - task.allow_concurrent_runs = true; - task.hide = task::HideStrategy::Always; - - let terminal = terminal_panel - .update_in(cx, |terminal_panel, window, cx| { - terminal_panel.spawn_task(&task, window, cx) - })? - .await?; - - let success_patterns = match method.0.as_ref() { - "claude-login" | "spawn-gemini-cli" => vec![ - "Login successful".to_string(), - "Type your message".to_string(), - ], - _ => Vec::new(), - }; - if success_patterns.is_empty() { - // No success patterns specified: wait for the process to exit and check exit code - let exit_status = terminal - .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? - .await; - - match exit_status { - Some(status) if status.success() => Ok(()), - Some(status) => Err(anyhow!( - "Login command failed with exit code: {:?}", - status.code() - )), - None => Err(anyhow!("Login command terminated without exit status")), - } - } else { - // Look for specific output patterns to detect successful login - let mut exit_status = terminal - .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? - .fuse(); - - let logged_in = cx - .spawn({ - let terminal = terminal.clone(); - async move |cx| { - loop { - cx.background_executor().timer(Duration::from_secs(1)).await; - let content = - terminal.update(cx, |terminal, _cx| terminal.get_content())?; - if success_patterns.iter().any(|pattern| content.contains(pattern)) - { - return anyhow::Ok(()); - } - } - } - }) - .fuse(); - futures::pin_mut!(logged_in); - futures::select_biased! { - result = logged_in => { - if let Err(e) = result { - log::error!("{e}"); - return Err(anyhow!("exited before logging in")); - } - } - _ = exit_status => { - if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server()) && login.label.contains("gemini") { - return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, project.clone(), method, true, window, cx))?.await - } - return Err(anyhow!("exited before logging in")); - } - } - terminal.update(cx, |terminal, _| terminal.kill_active_task())?; - Ok(()) - } - }) - } - - pub fn has_user_submitted_prompt(&self, cx: &App) -> bool { - self.active_thread().is_some_and(|active| { - active - .read(cx) - .thread - .read(cx) - .entries() - .iter() - .any(|entry| { - matches!( - entry, - AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some() - ) - }) - }) - } - - fn render_auth_required_state( - &self, - connection: &Rc, - description: Option<&Entity>, - configuration_view: Option<&AnyView>, - pending_auth_method: Option<&acp::AuthMethodId>, - window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let auth_methods = connection.auth_methods(); - - let agent_display_name = self - .agent_server_store - .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); - - let show_fallback_description = auth_methods.len() > 1 - && configuration_view.is_none() - && description.is_none() - && pending_auth_method.is_none(); - - 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()) - }; - - 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(), - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - ) - })) - } - }) - .when(auth_methods.len() > 1, |this| { - this.gap_1().child(auth_buttons()) - }), - ) - .into_any_element() - } - - fn emit_token_limit_telemetry_if_needed( - &mut self, - thread: &Entity, - cx: &mut Context, - ) { - let Some(active_thread) = self.active_thread() else { - return; - }; - - let (ratio, agent_telemetry_id, session_id) = { - let thread_data = thread.read(cx); - let Some(token_usage) = thread_data.token_usage() else { - return; - }; - ( - token_usage.ratio(), - thread_data.connection().telemetry_id(), - thread_data.session_id().clone(), - ) - }; - - let kind = match ratio { - acp_thread::TokenUsageRatio::Normal => { - active_thread.update(cx, |active, _cx| { - active.last_token_limit_telemetry = None; - }); - return; - } - acp_thread::TokenUsageRatio::Warning => "warning", - acp_thread::TokenUsageRatio::Exceeded => "exceeded", - }; - - let should_skip = active_thread - .read(cx) - .last_token_limit_telemetry - .as_ref() - .is_some_and(|last| *last >= ratio); - if should_skip { - return; - } - - active_thread.update(cx, |active, _cx| { - active.last_token_limit_telemetry = Some(ratio); - }); - - telemetry::event!( - "Agent Token Limit Warning", - agent = agent_telemetry_id, - session_id = session_id, - kind = kind, - ); - } - - fn emit_load_error_telemetry(&self, error: &LoadError) { - let error_kind = match error { - LoadError::Unsupported { .. } => "unsupported", - LoadError::FailedToInstall(_) => "failed_to_install", - LoadError::Exited { .. } => "exited", - LoadError::Other(_) => "other", - }; - - let agent_name = self.agent.name(); - - telemetry::event!( - "Agent Panel Error Shown", - agent = agent_name, - kind = error_kind, - message = error.to_string(), - ); - } - - 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) - .actions_slot(div().children(action_slot)) - .into_any_element() - } - - fn render_unsupported( - &self, - path: &SharedString, - version: &SharedString, - minimum_version: &SharedString, - _window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let (heading_label, description_label) = ( - format!("Upgrade {} to work with Zed", self.agent.name()), - if version.is_empty() { - format!( - "Currently using {}, which does not report a valid --version", - path, - ) - } else { - format!( - "Currently using {}, which is only version {} (need at least {minimum_version})", - path, version - ) - }, - ); - - v_flex() - .w_full() - .p_3p5() - .gap_2p5() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(linear_gradient( - 180., - linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), - linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), - )) - .child( - v_flex().gap_0p5().child(Label::new(heading_label)).child( - Label::new(description_label) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() - } - - pub(crate) fn as_native_connection( - &self, - cx: &App, - ) -> Option> { - let acp_thread = self.active_thread()?.read(cx).thread.read(cx); - acp_thread.connection().clone().downcast() - } - - pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { - let acp_thread = self.active_thread()?.read(cx).thread.read(cx); - self.as_native_connection(cx)? - .thread(acp_thread.session_id(), cx) - } - - fn queued_messages_len(&self, cx: &App) -> usize { - self.active_thread() - .map(|thread| thread.read(cx).local_queued_messages.len()) - .unwrap_or_default() - } - - fn update_queued_message( - &mut self, - index: usize, - content: Vec, - tracked_buffers: Vec>, - cx: &mut Context, - ) -> bool { - match self.active_thread() { - Some(thread) => thread.update(cx, |thread, _cx| { - if index < thread.local_queued_messages.len() { - thread.local_queued_messages[index] = QueuedMessage { - content, - tracked_buffers, - }; - true - } else { - false - } - }), - None => false, - } - } - - fn queued_message_contents(&self, cx: &App) -> Vec> { - match self.active_thread() { - None => Vec::new(), - Some(thread) => thread - .read(cx) - .local_queued_messages - .iter() - .map(|q| q.content.clone()) - .collect(), - } - } - - fn save_queued_message_at_index(&mut self, index: usize, cx: &mut Context) { - let editor = match self.active_thread() { - Some(thread) => thread.read(cx).queued_message_editors.get(index).cloned(), - None => None, - }; - let Some(editor) = editor else { - return; - }; - - let contents_task = editor.update(cx, |editor, cx| editor.contents(false, cx)); - - cx.spawn(async move |this, cx| { - let Ok((content, tracked_buffers)) = contents_task.await else { - return Ok::<(), anyhow::Error>(()); - }; - - this.update(cx, |this, cx| { - this.update_queued_message(index, content, tracked_buffers, cx); - cx.notify(); - })?; - - Ok(()) - }) - .detach_and_log_err(cx); - } - - fn sync_queued_message_editors(&mut self, window: &mut Window, cx: &mut Context) { - let needed_count = self.queued_messages_len(cx); - let queued_messages = self.queued_message_contents(cx); - - let agent_name = self.agent.name(); - let workspace = self.workspace.clone(); - let project = self.project.downgrade(); - let history = self.history.downgrade(); - - let Some(thread) = self.active_thread() else { - return; - }; - let prompt_capabilities = thread.read(cx).prompt_capabilities.clone(); - let available_commands = thread.read(cx).available_commands.clone(); - - let current_count = thread.read(cx).queued_message_editors.len(); - let last_synced = thread.read(cx).last_synced_queue_length; - - if current_count == needed_count && needed_count == last_synced { - return; - } - - if current_count > needed_count { - thread.update(cx, |thread, _cx| { - thread.queued_message_editors.truncate(needed_count); - thread - .queued_message_editor_subscriptions - .truncate(needed_count); - }); - - let editors = thread.read(cx).queued_message_editors.clone(); - for (index, editor) in editors.into_iter().enumerate() { - if let Some(content) = queued_messages.get(index) { - editor.update(cx, |editor, cx| { - editor.set_message(content.clone(), window, cx); - }); - } - } - } - - while thread.read(cx).queued_message_editors.len() < needed_count { - let index = thread.read(cx).queued_message_editors.len(); - let content = queued_messages.get(index).cloned().unwrap_or_default(); - - let editor = cx.new(|cx| { - let mut editor = MessageEditor::new( - workspace.clone(), - project.clone(), - None, - history.clone(), - None, - prompt_capabilities.clone(), - available_commands.clone(), - agent_name.clone(), - "", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: Some(10), - }, - window, - cx, - ); - editor.set_message(content, window, cx); - editor - }); - - let subscription = cx.subscribe_in( - &editor, - window, - move |this, _editor, event, window, cx| match event { - MessageEditorEvent::LostFocus => { - this.save_queued_message_at_index(index, cx); - } - MessageEditorEvent::Cancel => { - window.focus(&this.focus_handle(cx), cx); - } - MessageEditorEvent::Send => { - window.focus(&this.focus_handle(cx), cx); - } - MessageEditorEvent::SendImmediately => { - this.send_queued_message_at_index(index, true, window, cx); - } - _ => {} - }, - ); - - thread.update(cx, |thread, _cx| { - thread.queued_message_editors.push(editor); - thread - .queued_message_editor_subscriptions - .push(subscription); - }); - } - - if let Some(active) = self.active_thread() { - active.update(cx, |active, _cx| { - active.last_synced_queue_length = needed_count; - }); - } - } - - fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { - let workspace = self.workspace.clone(); - MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { - crate::acp::thread_view::active_thread::open_link(text, &workspace, window, cx); - }) - } - - fn notify_with_sound( - &mut self, - caption: impl Into, - icon: IconName, - window: &mut Window, - cx: &mut Context, - ) { - self.play_notification_sound(window, cx); - self.show_notification(caption, icon, window, cx); - } - - fn agent_panel_visible(&self, multi_workspace: &Entity, cx: &App) -> bool { - let Some(workspace) = self.workspace.upgrade() else { - return false; - }; - - multi_workspace.read(cx).workspace() == &workspace && AgentPanel::is_visible(&workspace, cx) - } - - fn agent_status_visible(&self, window: &Window, cx: &App) -> bool { - if !window.is_window_active() { - return false; - } - - if let Some(multi_workspace) = window.root::().flatten() { - multi_workspace.read(cx).is_sidebar_open() - || self.agent_panel_visible(&multi_workspace, cx) - } else { - self.workspace - .upgrade() - .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx)) - } - } - - fn play_notification_sound(&self, window: &Window, cx: &mut App) { - let settings = AgentSettings::get_global(cx); - let visible = window.is_window_active() - && if let Some(mw) = window.root::().flatten() { - self.agent_panel_visible(&mw, cx) - } else { - self.workspace - .upgrade() - .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx)) - }; - if settings.play_sound_when_agent_done && !visible { - Audio::play_sound(Sound::AgentDone, cx); - } - } - - fn show_notification( - &mut self, - caption: impl Into, - icon: IconName, - window: &mut Window, - cx: &mut Context, - ) { - if !self.notifications.is_empty() { - return; - } - - let settings = AgentSettings::get_global(cx); - - let should_notify = !self.agent_status_visible(window, cx); - - if !should_notify { - return; - } - - // TODO: Change this once we have title summarization for external agents. - let title = self.agent.name(); - - match settings.notify_when_agent_waiting { - NotifyWhenAgentWaiting::PrimaryScreen => { - if let Some(primary) = cx.primary_display() { - self.pop_up(icon, caption.into(), title, window, primary, cx); - } - } - NotifyWhenAgentWaiting::AllScreens => { - let caption = caption.into(); - for screen in cx.displays() { - self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx); - } - } - NotifyWhenAgentWaiting::Never => { - // Don't show anything - } - } - } - - fn pop_up( - &mut self, - icon: IconName, - caption: SharedString, - title: SharedString, - window: &mut Window, - screen: Rc, - cx: &mut Context, - ) { - let options = AgentNotification::window_options(screen, cx); - - let project_name = self.workspace.upgrade().and_then(|workspace| { - workspace - .read(cx) - .project() - .read(cx) - .visible_worktrees(cx) - .next() - .map(|worktree| worktree.read(cx).root_name_str().to_string()) - }); - - if let Some(screen_window) = cx - .open_window(options, |_window, cx| { - cx.new(|_cx| { - AgentNotification::new(title.clone(), caption.clone(), icon, project_name) - }) - }) - .log_err() - && let Some(pop_up) = screen_window.entity(cx).log_err() - { - self.notification_subscriptions - .entry(screen_window) - .or_insert_with(Vec::new) - .push(cx.subscribe_in(&pop_up, window, { - |this, _, event, window, cx| match event { - AgentNotificationEvent::Accepted => { - let Some(handle) = window.window_handle().downcast::() - else { - log::error!("root view should be a MultiWorkspace"); - return; - }; - cx.activate(true); - - let workspace_handle = this.workspace.clone(); - - cx.defer(move |cx| { - handle - .update(cx, |multi_workspace, window, cx| { - window.activate_window(); - if let Some(workspace) = workspace_handle.upgrade() { - multi_workspace.activate(workspace.clone(), cx); - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); - }); - } - }) - .log_err(); - }); - - this.dismiss_notifications(cx); - } - AgentNotificationEvent::Dismissed => { - this.dismiss_notifications(cx); - } - } - })); - - self.notifications.push(screen_window); - - // If the user manually refocuses the original window, dismiss the popup. - self.notification_subscriptions - .entry(screen_window) - .or_insert_with(Vec::new) - .push({ - let pop_up_weak = pop_up.downgrade(); - - cx.observe_window_activation(window, move |this, window, cx| { - if this.agent_status_visible(window, cx) - && let Some(pop_up) = pop_up_weak.upgrade() - { - pop_up.update(cx, |notification, cx| { - notification.dismiss(cx); - }); - } - }) - }); - } - } - - fn dismiss_notifications(&mut self, cx: &mut Context) { - for window in self.notifications.drain(..) { - window - .update(cx, |_, window, _| { - window.remove_window(); - }) - .ok(); - - self.notification_subscriptions.remove(&window); - } - } - - fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context) { - if let Some(entry_view_state) = self - .active_thread() - .map(|active| active.read(cx).entry_view_state.clone()) - { - entry_view_state.update(cx, |entry_view_state, cx| { - entry_view_state.agent_ui_font_size_changed(cx); - }); - } - } - - pub(crate) fn insert_dragged_files( - &self, - paths: Vec, - added_worktrees: Vec>, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active_thread) = self.active_thread() { - active_thread.update(cx, |thread, cx| { - thread.message_editor.update(cx, |editor, cx| { - editor.insert_dragged_files(paths, added_worktrees, window, cx); - editor.focus_handle(cx).focus(window, cx); - }) - }); - } - } - - /// Inserts the selected text into the message editor or the message being - /// edited, if any. - pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context) { - if let Some(active_thread) = self.active_thread() { - active_thread.update(cx, |thread, cx| { - thread.active_editor(cx).update(cx, |editor, cx| { - editor.insert_selections(window, cx); - }) - }); - } - } - - /// Inserts terminal text as a crease into the message editor. - pub(crate) fn insert_terminal_text( - &self, - text: String, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active_thread) = self.active_thread() { - active_thread.update(cx, |thread, cx| { - thread.message_editor.update(cx, |editor, cx| { - editor.insert_terminal_crease(text, window, cx); - }) - }); - } - } - - fn current_model_name(&self, cx: &App) -> SharedString { - // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet") - // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI") - // This provides better clarity about what refused the request - if self.as_native_connection(cx).is_some() { - self.active_thread() - .and_then(|active| active.read(cx).model_selector.clone()) - .and_then(|selector| selector.read(cx).active_model(cx)) - .map(|model| model.name.clone()) - .unwrap_or_else(|| SharedString::from("The model")) - } else { - // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") - self.agent.name() - } - } - - fn create_copy_button(&self, message: impl Into) -> impl IntoElement { - let message = message.into(); - - CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message") - } - - pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { - let agent_name = self.agent.name(); - if let Some(active) = self.active_thread() { - active.update(cx, |active, cx| active.clear_thread_error(cx)); - } - let this = cx.weak_entity(); - let Some(connection) = self.as_connected().map(|c| c.connection.clone()) else { - debug_panic!("This should not be possible"); - return; - }; - window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired::new(), - agent_name, - connection, - window, - cx, - ); - }) - } - - pub fn delete_history_entry(&mut self, entry: AgentSessionInfo, cx: &mut Context) { - let task = self.history.update(cx, |history, cx| { - history.delete_session(&entry.session_id, cx) - }); - task.detach_and_log_err(cx); - } -} - -fn loading_contents_spinner(size: IconSize) -> AnyElement { - Icon::new(IconName::LoadCircle) - .size(size) - .color(Color::Accent) - .with_rotate_animation(3) - .into_any_element() -} - -fn placeholder_text(agent_name: &str, has_commands: bool) -> String { - if agent_name == "Zed Agent" { - format!("Message the {} — @ to include context", agent_name) - } else if has_commands { - format!( - "Message {} — @ to include context, / for commands", - agent_name - ) - } else { - format!("Message {} — @ to include context", agent_name) - } -} - -impl Focusable for AcpServerView { - fn focus_handle(&self, cx: &App) -> FocusHandle { - match self.active_thread() { - Some(thread) => thread.read(cx).focus_handle(cx), - None => self.focus_handle.clone(), - } - } -} - -#[cfg(any(test, feature = "test-support"))] -impl AcpServerView { - /// Expands a tool call so its content is visible. - /// This is primarily useful for visual testing. - pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context) { - if let Some(active) = self.active_thread() { - active.update(cx, |active, _cx| { - active.expanded_tool_calls.insert(tool_call_id); - }); - cx.notify(); - } - } -} - -impl Render for AcpServerView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.sync_queued_message_editors(window, cx); - - v_flex() - .track_focus(&self.focus_handle) - .size_full() - .bg(cx.theme().colors().panel_background) - .child(match &self.server_state { - ServerState::Loading { .. } => v_flex() - .flex_1() - // .child(self.render_recent_history(cx)) - .into_any(), - ServerState::LoadError(e) => v_flex() - .flex_1() - .size_full() - .items_center() - .justify_end() - .child(self.render_load_error(e, window, cx)) - .into_any(), - ServerState::Connected(ConnectedServerState { - connection, - auth_state: - AuthState::Unauthenticated { - description, - configuration_view, - pending_auth_method, - _subscription, - }, - .. - }) => 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_element(), - ServerState::Connected(connected) => { - if let Some(view) = connected.active_view() { - view.clone().into_any_element() - } else { - debug_panic!("This state should never be reached"); - div().into_any_element() - } - } - }) - } -} - -fn plan_label_markdown_style( - status: &acp::PlanEntryStatus, - window: &Window, - cx: &App, -) -> MarkdownStyle { - let default_md_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); - - MarkdownStyle { - base_text_style: TextStyle { - color: cx.theme().colors().text_muted, - strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) { - Some(gpui::StrikethroughStyle { - thickness: px(1.), - color: Some(cx.theme().colors().text_muted.opacity(0.8)), - }) - } else { - None - }, - ..default_md_style.base_text_style - }, - ..default_md_style - } -} - -#[cfg(test)] -pub(crate) mod tests { - use acp_thread::{ - AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection, - }; - use action_log::ActionLog; - use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext}; - use agent_client_protocol::SessionId; - use assistant_text_thread::TextThreadStore; - use editor::MultiBufferOffset; - use fs::FakeFs; - use gpui::{EventEmitter, TestAppContext, VisualTestContext}; - use parking_lot::Mutex; - use project::Project; - use serde_json::json; - use settings::SettingsStore; - use std::any::Any; - use std::path::{Path, PathBuf}; - use std::rc::Rc; - use std::sync::Arc; - use workspace::{Item, MultiWorkspace}; - - use crate::agent_panel; - - use super::*; - - #[gpui::test] - async fn test_drop(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - let weak_view = thread_view.downgrade(); - drop(thread_view); - assert!(!weak_view.is_upgradable()); - } - - #[gpui::test] - async fn test_notification_for_stop_event(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - - cx.deactivate_window(); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - assert!( - cx.windows() - .iter() - .any(|window| window.downcast::().is_some()) - ); - } - - #[gpui::test] - async fn test_notification_for_error(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await; - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - - cx.deactivate_window(); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - assert!( - cx.windows() - .iter() - .any(|window| window.downcast::().is_some()) - ); - } - - #[gpui::test] - async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) { - init_test(cx); - - let session_a = AgentSessionInfo::new(SessionId::new("session-a")); - let session_b = AgentSessionInfo::new(SessionId::new("session-b")); - - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - // Create history without an initial session list - it will be set after connection - let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); - - let thread_view = cx.update(|window, cx| { - cx.new(|cx| { - AcpServerView::new( - Rc::new(StubAgentServer::default_response()), - None, - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - history.clone(), - window, - cx, - ) - }) - }); - - // Wait for connection to establish - cx.run_until_parked(); - - // Initially empty because StubAgentConnection.session_list() returns None - active_thread(&thread_view, cx).read_with(cx, |view, _cx| { - assert_eq!(view.recent_history_entries.len(), 0); - }); - - // Now set the session list - this simulates external agents providing their history - let list_a: Rc = - Rc::new(StubSessionList::new(vec![session_a.clone()])); - history.update(cx, |history, cx| { - history.set_session_list(Some(list_a), cx); - }); - cx.run_until_parked(); - - active_thread(&thread_view, cx).read_with(cx, |view, _cx| { - assert_eq!(view.recent_history_entries.len(), 1); - assert_eq!( - view.recent_history_entries[0].session_id, - session_a.session_id - ); - }); - - // Update to a different session list - let list_b: Rc = - Rc::new(StubSessionList::new(vec![session_b.clone()])); - history.update(cx, |history, cx| { - history.set_session_list(Some(list_b), cx); - }); - cx.run_until_parked(); - - active_thread(&thread_view, cx).read_with(cx, |view, _cx| { - assert_eq!(view.recent_history_entries.len(), 1); - assert_eq!( - view.recent_history_entries[0].session_id, - session_b.session_id - ); - }); - } - - #[gpui::test] - async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) { - init_test(cx); - - let session = AgentSessionInfo::new(SessionId::new("resume-session")); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); - - let thread_view = cx.update(|window, cx| { - cx.new(|cx| { - AcpServerView::new( - Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), - Some(session), - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - history, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - thread_view.read_with(cx, |view, cx| { - let state = view.active_thread().unwrap(); - assert!(state.read(cx).resumed_without_history); - assert_eq!(state.read(cx).list_state.item_count(), 0); - }); - } - - #[gpui::test] - async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "subdir": { - "file.txt": "hello" - } - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let mut session = AgentSessionInfo::new(SessionId::new("session-1")); - session.cwd = Some(PathBuf::from("/project/subdir")); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - AcpServerView::new( - Rc::new(StubAgentServer::new(connection)), - Some(session), - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - history, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project/subdir")), - "Should use session cwd when it's inside the project" - ); - } - - #[gpui::test] - async fn test_resume_thread_uses_fallback_cwd_when_outside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let mut session = AgentSessionInfo::new(SessionId::new("session-1")); - session.cwd = Some(PathBuf::from("/some/other/path")); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - AcpServerView::new( - Rc::new(StubAgentServer::new(connection)), - Some(session), - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - history, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project")), - "Should use fallback project cwd when session cwd is outside the project" - ); - } - - #[gpui::test] - async fn test_resume_thread_rejects_unnormalized_cwd_outside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let mut session = AgentSessionInfo::new(SessionId::new("session-1")); - session.cwd = Some(PathBuf::from("/project/../outside")); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - AcpServerView::new( - Rc::new(StubAgentServer::new(connection)), - Some(session), - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - history, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project")), - "Should reject unnormalized cwd that resolves outside the project and use fallback cwd" - ); - } - - #[gpui::test] - async fn test_refusal_handling(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await; - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Do something harmful", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Check that the refusal error is set - thread_view.read_with(cx, |thread_view, cx| { - let state = thread_view.active_thread().unwrap(); - assert!( - matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)), - "Expected refusal error to be set" - ); - }); - } - - #[gpui::test] - async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = setup_thread_view(FailingAgentServer, cx).await; - - thread_view.read_with(cx, |view, cx| { - let title = view.title(cx); - assert_eq!( - title.as_ref(), - "Error Loading Codex CLI", - "Tab title should show the agent name with an error prefix" - ); - match &view.server_state { - ServerState::LoadError(LoadError::Other(msg)) => { - assert!( - msg.contains("Invalid gzip header"), - "Error callout should contain the underlying extraction error, got: {msg}" - ); - } - other => panic!( - "Expected LoadError::Other, got: {}", - match other { - ServerState::Loading(_) => "Loading (stuck!)", - ServerState::LoadError(_) => "LoadError (wrong variant)", - ServerState::Connected(_) => "Connected", - } - ), - } - }); - } - - #[gpui::test] - async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) { - init_test(cx); - - let connection = AuthGatedAgentConnection::new(); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - - // When new_session returns AuthRequired, the server should transition - // to Connected + Unauthenticated rather than getting stuck in Loading. - thread_view.read_with(cx, |view, _cx| { - let connected = view - .as_connected() - .expect("Should be in Connected state even though auth is required"); - assert!( - !connected.auth_state.is_ok(), - "Auth state should be Unauthenticated" - ); - assert!( - connected.active_id.is_none(), - "There should be no active thread since no session was created" - ); - assert!( - connected.threads.is_empty(), - "There should be no threads since no session was created" - ); - }); - - thread_view.read_with(cx, |view, _cx| { - assert!( - view.active_thread().is_none(), - "active_thread() should be None when unauthenticated without a session" - ); - }); - - // Authenticate using the real authenticate flow on AcpServerView. - // This calls connection.authenticate(), which flips the internal flag, - // then on success triggers reset() -> new_session() which now succeeds. - thread_view.update_in(cx, |view, window, cx| { - view.authenticate( - acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID), - window, - cx, - ); - }); - cx.run_until_parked(); - - // After auth, the server should have an active thread in the Ok state. - thread_view.read_with(cx, |view, cx| { - let connected = view - .as_connected() - .expect("Should still be in Connected state after auth"); - assert!(connected.auth_state.is_ok(), "Auth state should be Ok"); - assert!( - connected.active_id.is_some(), - "There should be an active thread after successful auth" - ); - assert_eq!( - connected.threads.len(), - 1, - "There should be exactly one thread" - ); - - let active = view - .active_thread() - .expect("active_thread() should return the new thread"); - assert!( - active.read(cx).thread_error.is_none(), - "The new thread should have no errors" - ); - }); - } - - #[gpui::test] - async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) { - init_test(cx); - - 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, - PermissionOptions::Flat(vec![acp::PermissionOption::new( - "1", - "Allow", - acp::PermissionOptionKind::AllowOnce, - )]), - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - - cx.deactivate_window(); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - assert!( - cx.windows() - .iter() - .any(|window| window.downcast::().is_some()) - ); - } - - #[gpui::test] - async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - - // Window is active (don't deactivate), but panel will be hidden - // Note: In the test environment, the panel is not actually added to the dock, - // so is_agent_panel_hidden will return true - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Should show notification because window is active but panel is hidden - assert!( - cx.windows() - .iter() - .any(|window| window.downcast::().is_some()), - "Expected notification when panel is hidden" - ); - } - - #[gpui::test] - async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - - // Deactivate window - should show notification regardless of setting - cx.deactivate_window(); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Should still show notification when window is inactive (existing behavior) - assert!( - cx.windows() - .iter() - .any(|window| window.downcast::().is_some()), - "Expected notification when window is inactive" - ); - } - - #[gpui::test] - async fn test_notification_when_workspace_is_background_in_multi_workspace( - cx: &mut TestAppContext, - ) { - init_test(cx); - - // Enable multi-workspace feature flag and init globals needed by AgentPanel - let fs = FakeFs::new(cx.executor()); - - cx.update(|cx| { - cx.update_flags(true, vec!["agent-v2".to_string()]); - agent::ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - ::set_global(fs.clone(), cx); - }); - - let project1 = Project::test(fs.clone(), [], cx).await; - - // Create a MultiWorkspace window with one workspace - let multi_workspace_handle = - cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx)); - - // Get workspace 1 (the initial workspace) - let workspace1 = multi_workspace_handle - .read_with(cx, |mw, _cx| mw.workspace().clone()) - .unwrap(); - - let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); - - workspace1.update_in(cx, |workspace, window, cx| { - let text_thread_store = - cx.new(|cx| TextThreadStore::fake(workspace.project().clone(), cx)); - let panel = - cx.new(|cx| crate::AgentPanel::new(workspace, text_thread_store, None, window, cx)); - workspace.add_panel(panel, window, cx); - - // Open the dock and activate the agent panel so it's visible - workspace.focus_panel::(window, cx); - }); - - cx.run_until_parked(); - - cx.read(|cx| { - assert!( - crate::AgentPanel::is_visible(&workspace1, cx), - "AgentPanel should be visible in workspace1's dock" - ); - }); - - // Set up thread view in workspace 1 - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); - - let agent = StubAgentServer::default_response(); - let thread_view = cx.update(|window, cx| { - cx.new(|cx| { - AcpServerView::new( - Rc::new(agent), - None, - None, - workspace1.downgrade(), - project1.clone(), - Some(thread_store), - None, - history, - window, - cx, - ) - }) - }); - cx.run_until_parked(); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - - // Create a second workspace and switch to it. - // This makes workspace1 the "background" workspace. - let project2 = Project::test(fs, [], cx).await; - multi_workspace_handle - .update(cx, |mw, window, cx| { - mw.test_add_workspace(project2, window, cx); - }) - .unwrap(); - - cx.run_until_parked(); - - // Verify workspace1 is no longer the active workspace - multi_workspace_handle - .read_with(cx, |mw, _cx| { - assert_eq!(mw.active_workspace_index(), 1); - assert_ne!(mw.workspace(), &workspace1); - }) - .unwrap(); - - // Window is active, agent panel is visible in workspace1, but workspace1 - // is in the background. The notification should show because the user - // can't actually see the agent panel. - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - assert!( - cx.windows() - .iter() - .any(|window| window.downcast::().is_some()), - "Expected notification when workspace is in background within MultiWorkspace" - ); - - // Also verify: clicking "View Panel" should switch to workspace1. - cx.windows() - .iter() - .find_map(|window| window.downcast::()) - .unwrap() - .update(cx, |window, _, cx| window.accept(cx)) - .unwrap(); - - cx.run_until_parked(); - - multi_workspace_handle - .read_with(cx, |mw, _cx| { - assert_eq!( - mw.workspace(), - &workspace1, - "Expected workspace1 to become the active workspace after accepting notification" - ); - }) - .unwrap(); - } - - #[gpui::test] - async fn test_notification_respects_never_setting(cx: &mut TestAppContext) { - init_test(cx); - - // Set notify_when_agent_waiting to Never - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - - // Window is active - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Should NOT show notification because notify_when_agent_waiting is Never - assert!( - !cx.windows() - .iter() - .any(|window| window.downcast::().is_some()), - "Expected no notification when notify_when_agent_waiting is Never" - ); - } - - #[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 = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - - cx.deactivate_window(); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| 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, - ) -> (Entity, &mut VisualTestContext) { - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); - - let thread_view = cx.update(|window, cx| { - cx.new(|cx| { - AcpServerView::new( - Rc::new(agent), - None, - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - history, - window, - cx, - ) - }) - }); - cx.run_until_parked(); - (thread_view, cx) - } - - fn add_to_workspace(thread_view: Entity, cx: &mut VisualTestContext) { - let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone()); - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane( - Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))), - None, - true, - window, - cx, - ); - }) - .unwrap(); - } - - struct ThreadViewItem(Entity); - - impl Item for ThreadViewItem { - type Event = (); - - fn include_in_nav_history() -> bool { - false - } - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Test".into() - } - } - - impl EventEmitter<()> for ThreadViewItem {} - - impl Focusable for ThreadViewItem { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.0.read(cx).focus_handle(cx) - } - } - - impl Render for ThreadViewItem { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.0.clone().into_any_element() - } - } - - pub(crate) struct StubAgentServer { - connection: C, - } - - impl StubAgentServer { - pub(crate) fn new(connection: C) -> Self { - Self { connection } - } - } - - impl StubAgentServer { - pub(crate) fn default_response() -> Self { - let conn = StubAgentConnection::new(); - conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Default response".into()), - )]); - Self::new(conn) - } - } - - impl AgentServer for StubAgentServer - where - C: 'static + AgentConnection + Send + Clone, - { - fn logo(&self) -> ui::IconName { - ui::IconName::Ai - } - - fn name(&self) -> SharedString { - "Test".into() - } - - fn connect( - &self, - _delegate: AgentServerDelegate, - _cx: &mut App, - ) -> Task, Option)>> { - Task::ready(Ok((Rc::new(self.connection.clone()), None))) - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - struct FailingAgentServer; - - impl AgentServer for FailingAgentServer { - fn logo(&self) -> ui::IconName { - ui::IconName::AiOpenAi - } - - fn name(&self) -> SharedString { - "Codex CLI".into() - } - - fn connect( - &self, - _delegate: AgentServerDelegate, - _cx: &mut App, - ) -> Task, Option)>> { - Task::ready(Err(anyhow!( - "extracting downloaded asset for \ - https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\ - codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \ - failed to iterate over archive: Invalid gzip header" - ))) - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - #[derive(Clone)] - struct StubSessionList { - sessions: Vec, - } - - impl StubSessionList { - fn new(sessions: Vec) -> Self { - Self { sessions } - } - } - - impl AgentSessionList for StubSessionList { - fn list_sessions( - &self, - _request: AgentSessionListRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) - } - fn into_any(self: Rc) -> Rc { - self - } - } - - #[derive(Clone)] - struct ResumeOnlyAgentConnection; - - impl AgentConnection for ResumeOnlyAgentConnection { - fn telemetry_id(&self) -> SharedString { - "resume-only".into() - } - - fn new_session( - self: Rc, - project: Entity, - _cwd: &Path, - cx: &mut gpui::App, - ) -> Task>> { - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|cx| { - AcpThread::new( - None, - "ResumeOnlyAgentConnection", - self.clone(), - project, - action_log, - SessionId::new("new-session"), - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }); - Task::ready(Ok(thread)) - } - - fn supports_resume_session(&self) -> bool { - true - } - - fn resume_session( - self: Rc, - session: AgentSessionInfo, - project: Entity, - _cwd: &Path, - cx: &mut App, - ) -> Task>> { - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|cx| { - AcpThread::new( - None, - "ResumeOnlyAgentConnection", - self.clone(), - project, - action_log, - session.session_id, - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }); - Task::ready(Ok(thread)) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate( - &self, - _method_id: acp::AuthMethodId, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(())) - } - - fn prompt( - &self, - _id: Option, - _params: acp::PromptRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) - } - - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} - - fn into_any(self: Rc) -> Rc { - self - } - } - - /// Simulates an agent that requires authentication before a session can be - /// created. `new_session` returns `AuthRequired` until `authenticate` is - /// called with the correct method, after which sessions are created normally. - #[derive(Clone)] - struct AuthGatedAgentConnection { - authenticated: Arc>, - auth_method: acp::AuthMethod, - } - - impl AuthGatedAgentConnection { - const AUTH_METHOD_ID: &str = "test-login"; - - fn new() -> Self { - Self { - authenticated: Arc::new(Mutex::new(false)), - auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"), - } - } - } - - impl AgentConnection for AuthGatedAgentConnection { - fn telemetry_id(&self) -> SharedString { - "auth-gated".into() - } - - fn new_session( - self: Rc, - project: Entity, - _cwd: &Path, - cx: &mut gpui::App, - ) -> Task>> { - if !*self.authenticated.lock() { - return Task::ready(Err(acp_thread::AuthRequired::new() - .with_description("Sign in to continue".to_string()) - .into())); - } - - let session_id = acp::SessionId::new("auth-gated-session"); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - Task::ready(Ok(cx.new(|cx| { - AcpThread::new( - None, - "AuthGatedAgent", - self, - project, - action_log, - session_id, - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }))) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - std::slice::from_ref(&self.auth_method) - } - - fn authenticate( - &self, - method_id: acp::AuthMethodId, - _cx: &mut App, - ) -> Task> { - if method_id == self.auth_method.id { - *self.authenticated.lock() = true; - Task::ready(Ok(())) - } else { - Task::ready(Err(anyhow::anyhow!("Unknown auth method"))) - } - } - - fn prompt( - &self, - _id: Option, - _params: acp::PromptRequest, - _cx: &mut App, - ) -> Task> { - unimplemented!() - } - - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { - unimplemented!() - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - #[derive(Clone)] - struct SaboteurAgentConnection; - - impl AgentConnection for SaboteurAgentConnection { - fn telemetry_id(&self) -> SharedString { - "saboteur".into() - } - - fn new_session( - self: Rc, - project: Entity, - _cwd: &Path, - cx: &mut gpui::App, - ) -> Task>> { - Task::ready(Ok(cx.new(|cx| { - let action_log = cx.new(|_| ActionLog::new(project.clone())); - AcpThread::new( - None, - "SaboteurAgentConnection", - self, - project, - action_log, - SessionId::new("test"), - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }))) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate( - &self, - _method_id: acp::AuthMethodId, - _cx: &mut App, - ) -> Task> { - unimplemented!() - } - - fn prompt( - &self, - _id: Option, - _params: acp::PromptRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Err(anyhow::anyhow!("Error prompting"))) - } - - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { - unimplemented!() - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - /// Simulates a model which always returns a refusal response - #[derive(Clone)] - struct RefusalAgentConnection; - - impl AgentConnection for RefusalAgentConnection { - fn telemetry_id(&self) -> SharedString { - "refusal".into() - } - - fn new_session( - self: Rc, - project: Entity, - _cwd: &Path, - cx: &mut gpui::App, - ) -> Task>> { - Task::ready(Ok(cx.new(|cx| { - let action_log = cx.new(|_| ActionLog::new(project.clone())); - AcpThread::new( - None, - "RefusalAgentConnection", - self, - project, - action_log, - SessionId::new("test"), - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }))) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate( - &self, - _method_id: acp::AuthMethodId, - _cx: &mut App, - ) -> Task> { - unimplemented!() - } - - fn prompt( - &self, - _id: Option, - _params: acp::PromptRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal))) - } - - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { - unimplemented!() - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - #[derive(Clone)] - struct CwdCapturingConnection { - captured_cwd: Arc>>, - } - - impl CwdCapturingConnection { - fn new() -> Self { - Self { - captured_cwd: Arc::new(Mutex::new(None)), - } - } - } - - impl AgentConnection for CwdCapturingConnection { - fn telemetry_id(&self) -> SharedString { - "cwd-capturing".into() - } - - fn new_session( - self: Rc, - project: Entity, - cwd: &Path, - cx: &mut gpui::App, - ) -> Task>> { - *self.captured_cwd.lock() = Some(cwd.to_path_buf()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|cx| { - AcpThread::new( - None, - "CwdCapturingConnection", - self.clone(), - project, - action_log, - SessionId::new("new-session"), - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }); - Task::ready(Ok(thread)) - } - - fn supports_load_session(&self) -> bool { - true - } - - fn load_session( - self: Rc, - session: AgentSessionInfo, - project: Entity, - cwd: &Path, - cx: &mut App, - ) -> Task>> { - *self.captured_cwd.lock() = Some(cwd.to_path_buf()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|cx| { - AcpThread::new( - None, - "CwdCapturingConnection", - self.clone(), - project, - action_log, - session.session_id, - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }); - Task::ready(Ok(thread)) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate( - &self, - _method_id: acp::AuthMethodId, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(())) - } - - fn prompt( - &self, - _id: Option, - _params: acp::PromptRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) - } - - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} - - fn into_any(self: Rc) -> Rc { - self - } - } - - pub(crate) fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - agent_panel::init(cx); - release_channel::init(semver::Version::new(0, 0, 0), cx); - prompt_store::init(cx) - }); - } - - fn active_thread( - thread_view: &Entity, - cx: &TestAppContext, - ) -> Entity { - cx.read(|cx| { - thread_view - .read(cx) - .active_thread() - .expect("No active thread") - .clone() - }) - } - - fn message_editor( - thread_view: &Entity, - cx: &TestAppContext, - ) -> Entity { - let thread = active_thread(thread_view, cx); - cx.read(|cx| thread.read(cx).message_editor.clone()) - } - - #[gpui::test] - async fn test_rewind_views(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "test1.txt": "old content 1", - "test2.txt": "old content 2" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); - - let connection = Rc::new(StubAgentConnection::new()); - let thread_view = cx.update(|window, cx| { - cx.new(|cx| { - AcpServerView::new( - Rc::new(StubAgentServer::new(connection.as_ref().clone())), - None, - None, - workspace.downgrade(), - project.clone(), - Some(thread_store.clone()), - None, - history, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - let thread = thread_view - .read_with(cx, |view, cx| { - view.active_thread().map(|r| r.read(cx).thread.clone()) - }) - .unwrap(); - - // First user message - 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)) - .await - .unwrap(); - cx.run_until_parked(); - - thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.entries().len(), 2); - }); - - thread_view.read_with(cx, |view, cx| { - let entry_view_state = view - .active_thread() - .map(|active| active.read(cx).entry_view_state.clone()) - .unwrap(); - entry_view_state.read_with(cx, |entry_view_state, _| { - assert!( - entry_view_state - .entry(0) - .unwrap() - .message_editor() - .is_some() - ); - assert!(entry_view_state.entry(1).unwrap().has_content()); - }); - }); - - // Second user message - 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)) - .await - .unwrap(); - cx.run_until_parked(); - - let second_user_message_id = thread.read_with(cx, |thread, _| { - assert_eq!(thread.entries().len(), 4); - let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else { - panic!(); - }; - user_message.id.clone().unwrap() - }); - - thread_view.read_with(cx, |view, cx| { - let entry_view_state = view - .active_thread() - .unwrap() - .read(cx) - .entry_view_state - .clone(); - entry_view_state.read_with(cx, |entry_view_state, _| { - assert!( - entry_view_state - .entry(0) - .unwrap() - .message_editor() - .is_some() - ); - assert!(entry_view_state.entry(1).unwrap().has_content()); - assert!( - entry_view_state - .entry(2) - .unwrap() - .message_editor() - .is_some() - ); - assert!(entry_view_state.entry(3).unwrap().has_content()); - }); - }); - - // Rewind to first message - thread - .update(cx, |thread, cx| thread.rewind(second_user_message_id, cx)) - .await - .unwrap(); - - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert_eq!(thread.entries().len(), 2); - }); - - thread_view.read_with(cx, |view, cx| { - let active = view.active_thread().unwrap(); - active - .read(cx) - .entry_view_state - .read_with(cx, |entry_view_state, _| { - assert!( - entry_view_state - .entry(0) - .unwrap() - .message_editor() - .is_some() - ); - assert!(entry_view_state.entry(1).unwrap().has_content()); - - // Old views should be dropped - assert!(entry_view_state.entry(2).is_none()); - assert!(entry_view_state.entry(3).is_none()); - }); - }); - } - - #[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, cx| { - view.active_thread().map(|r| r.read(cx).thread.clone()) - }) - .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. - active_thread(&thread_view, cx).update(cx, |view, cx| { - view.scroll_to_top(cx); - }); - cx.run_until_parked(); - - active_thread(&thread_view, cx).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. - active_thread(&thread_view, cx).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); - - let connection = StubAgentConnection::new(); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Response".into()), - )]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Original message to edit", window, cx); - }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - let user_message_editor = thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - None - ); - - view.active_thread() - .map(|active| &active.read(cx).entry_view_state) - .as_ref() - .unwrap() - .read(cx) - .entry(0) - .unwrap() - .message_editor() - .unwrap() - .clone() - }); - - // Focus - cx.focus(&user_message_editor); - thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - Some(0) - ); - }); - - // Edit - user_message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Edited message content", window, cx); - }); - - // Cancel - user_message_editor.update_in(cx, |_editor, window, cx| { - window.dispatch_action(Box::new(editor::actions::Cancel), cx); - }); - - thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - None - ); - }); - - user_message_editor.read_with(cx, |editor, cx| { - assert_eq!(editor.text(cx), "Original message to edit"); - }); - } - - #[gpui::test] - async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) { - init_test(cx); - - let connection = StubAgentConnection::new(); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("", window, cx); - }); - - let thread = cx.read(|cx| { - thread_view - .read(cx) - .active_thread() - .unwrap() - .read(cx) - .thread - .clone() - }); - let entries_before = cx.read(|cx| thread.read(cx).entries().len()); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| { - view.send(window, cx); - }); - cx.run_until_parked(); - - let entries_after = cx.read(|cx| thread.read(cx).entries().len()); - assert_eq!( - entries_before, entries_after, - "No message should be sent when editor is empty" - ); - } - - #[gpui::test] - async fn test_message_editing_regenerate(cx: &mut TestAppContext) { - init_test(cx); - - let connection = StubAgentConnection::new(); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Response".into()), - )]); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Original message to edit", window, cx); - }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - let user_message_editor = thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - None - ); - assert_eq!( - view.active_thread() - .unwrap() - .read(cx) - .thread - .read(cx) - .entries() - .len(), - 2 - ); - - view.active_thread() - .map(|active| &active.read(cx).entry_view_state) - .as_ref() - .unwrap() - .read(cx) - .entry(0) - .unwrap() - .message_editor() - .unwrap() - .clone() - }); - - // Focus - cx.focus(&user_message_editor); - - // Edit - user_message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Edited message content", window, cx); - }); - - // Send - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("New Response".into()), - )]); - - user_message_editor.update_in(cx, |_editor, window, cx| { - window.dispatch_action(Box::new(Chat), cx); - }); - - cx.run_until_parked(); - - thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - None - ); - - let entries = view - .active_thread() - .unwrap() - .read(cx) - .thread - .read(cx) - .entries(); - assert_eq!(entries.len(), 2); - assert_eq!( - entries[0].to_markdown(cx), - "## User\n\nEdited message content\n\n" - ); - assert_eq!( - entries[1].to_markdown(cx), - "## Assistant\n\nNew Response\n\n" - ); - - let entry_view_state = view - .active_thread() - .map(|active| &active.read(cx).entry_view_state) - .unwrap(); - let new_editor = entry_view_state.read_with(cx, |state, _cx| { - assert!(!state.entry(1).unwrap().has_content()); - state.entry(0).unwrap().message_editor().unwrap().clone() - }); - - assert_eq!(new_editor.read(cx).text(cx), "Edited message content"); - }) - } - - #[gpui::test] - async fn test_message_editing_while_generating(cx: &mut TestAppContext) { - init_test(cx); - - let connection = StubAgentConnection::new(); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Original message to edit", window, cx); - }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| { - let thread = view.active_thread().unwrap().read(cx).thread.read(cx); - assert_eq!(thread.entries().len(), 1); - - let editor = view - .active_thread() - .map(|active| &active.read(cx).entry_view_state) - .as_ref() - .unwrap() - .read(cx) - .entry(0) - .unwrap() - .message_editor() - .unwrap() - .clone(); - - (editor, thread.session_id().clone()) - }); - - // Focus - cx.focus(&user_message_editor); - - thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - Some(0) - ); - }); - - // Edit - user_message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Edited message content", window, cx); - }); - - thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - Some(0) - ); - }); - - // Finish streaming response - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())), - cx, - ); - connection.end_turn(session_id, acp::StopReason::EndTurn); - }); - - thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - Some(0) - ); - }); - - cx.run_until_parked(); - - // Should still be editing - cx.update(|window, cx| { - assert!(user_message_editor.focus_handle(cx).is_focused(window)); - assert_eq!( - thread_view - .read(cx) - .active_thread() - .and_then(|active| active.read(cx).editing_message), - Some(0) - ); - assert_eq!( - user_message_editor.read(cx).text(cx), - "Edited message content" - ); - }); - } - - struct GeneratingThreadSetup { - thread_view: Entity, - thread: Entity, - message_editor: Entity, - } - - async fn setup_generating_thread( - cx: &mut TestAppContext, - ) -> (GeneratingThreadSetup, &mut VisualTestContext) { - let connection = StubAgentConnection::new(); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Hello", window, cx); - }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - let (thread, session_id) = thread_view.read_with(cx, |view, cx| { - let thread = view - .active_thread() - .as_ref() - .unwrap() - .read(cx) - .thread - .clone(); - (thread.clone(), thread.read(cx).session_id().clone()) - }); - - cx.run_until_parked(); - - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( - "Response chunk".into(), - )), - cx, - ); - }); - - cx.run_until_parked(); - - thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.status(), ThreadStatus::Generating); - }); - - ( - GeneratingThreadSetup { - thread_view, - thread, - message_editor, - }, - cx, - ) - } - - #[gpui::test] - async fn test_escape_cancels_generation_from_conversation_focus(cx: &mut TestAppContext) { - init_test(cx); - - let (setup, cx) = setup_generating_thread(cx).await; - - let focus_handle = setup - .thread_view - .read_with(cx, |view, cx| view.focus_handle(cx)); - cx.update(|window, cx| { - window.focus(&focus_handle, cx); - }); - - setup.thread_view.update_in(cx, |_, window, cx| { - window.dispatch_action(menu::Cancel.boxed_clone(), cx); - }); - - cx.run_until_parked(); - - setup.thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.status(), ThreadStatus::Idle); - }); - } - - #[gpui::test] - async fn test_escape_cancels_generation_from_editor_focus(cx: &mut TestAppContext) { - init_test(cx); - - let (setup, cx) = setup_generating_thread(cx).await; - - let editor_focus_handle = setup - .message_editor - .read_with(cx, |editor, cx| editor.focus_handle(cx)); - cx.update(|window, cx| { - window.focus(&editor_focus_handle, cx); - }); - - setup.message_editor.update_in(cx, |_, window, cx| { - window.dispatch_action(editor::actions::Cancel.boxed_clone(), cx); - }); - - cx.run_until_parked(); - - setup.thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.status(), ThreadStatus::Idle); - }); - } - - #[gpui::test] - async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let thread = thread_view.read_with(cx, |view, cx| { - view.active_thread().unwrap().read(cx).thread.clone() - }); - - thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.status(), ThreadStatus::Idle); - }); - - let focus_handle = thread_view.read_with(cx, |view, _cx| view.focus_handle.clone()); - cx.update(|window, cx| { - window.focus(&focus_handle, cx); - }); - - thread_view.update_in(cx, |_, window, cx| { - window.dispatch_action(menu::Cancel.boxed_clone(), cx); - }); - - cx.run_until_parked(); - - thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.status(), ThreadStatus::Idle); - }); - } - - #[gpui::test] - async fn test_interrupt(cx: &mut TestAppContext) { - init_test(cx); - - let connection = StubAgentConnection::new(); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Message 1", window, cx); - }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - let (thread, session_id) = thread_view.read_with(cx, |view, cx| { - let thread = view.active_thread().unwrap().read(cx).thread.clone(); - - (thread.clone(), thread.read(cx).session_id().clone()) - }); - - cx.run_until_parked(); - - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( - "Message 1 resp".into(), - )), - cx, - ); - }); - - cx.run_until_parked(); - - thread.read_with(cx, |thread, cx| { - assert_eq!( - thread.to_markdown(cx), - indoc::indoc! {" - ## User - - Message 1 - - ## Assistant - - Message 1 resp - - "} - ) - }); - - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Message 2", window, cx); - }); - active_thread(&thread_view, cx) - .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx)); - - cx.update(|_, cx| { - // Simulate a response sent after beginning to cancel - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())), - cx, - ); - }); - - cx.run_until_parked(); - - // Last Message 1 response should appear before Message 2 - thread.read_with(cx, |thread, cx| { - assert_eq!( - thread.to_markdown(cx), - indoc::indoc! {" - ## User - - Message 1 - - ## Assistant - - Message 1 response - - ## User - - Message 2 - - "} - ) - }); - - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( - "Message 2 response".into(), - )), - cx, - ); - connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); - }); - - cx.run_until_parked(); - - thread.read_with(cx, |thread, cx| { - assert_eq!( - thread.to_markdown(cx), - indoc::indoc! {" - ## User - - Message 1 - - ## Assistant - - Message 1 response - - ## User - - Message 2 - - ## Assistant - - Message 2 response - - "} - ) - }); - } - - #[gpui::test] - async fn test_message_editing_insert_selections(cx: &mut TestAppContext) { - init_test(cx); - - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Response".into()), - )]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Original message to edit", window, cx) - }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - cx.run_until_parked(); - - let user_message_editor = thread_view.read_with(cx, |thread_view, cx| { - thread_view - .active_thread() - .map(|active| &active.read(cx).entry_view_state) - .as_ref() - .unwrap() - .read(cx) - .entry(0) - .expect("Should have at least one entry") - .message_editor() - .expect("Should have message editor") - .clone() - }); - - cx.focus(&user_message_editor); - thread_view.read_with(cx, |view, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - Some(0) - ); - }); - - // Ensure to edit the focused message before proceeding otherwise, since - // its content is not different from what was sent, focus will be lost. - user_message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Original message to edit with ", window, cx) - }); - - // Create a simple buffer with some text so we can create a selection - // that will then be added to the message being edited. - let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| { - (thread_view.workspace.clone(), thread_view.project.clone()) - }); - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer("let a = 10 + 10;", None, false, cx) - }); - - workspace - .update_in(cx, |workspace, window, cx| { - let editor = cx.new(|cx| { - let mut editor = - Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); - - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]); - }); - - editor - }); - workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx); - }) - .unwrap(); - - thread_view.update_in(cx, |view, window, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - Some(0) - ); - view.insert_selections(window, cx); - }); - - user_message_editor.read_with(cx, |editor, cx| { - let text = editor.editor().read(cx).text(cx); - let expected_text = String::from("Original message to edit with selection "); - - assert_eq!(text, expected_text); - }); - } - - #[gpui::test] - async fn test_insert_selections(cx: &mut TestAppContext) { - init_test(cx); - - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Response".into()), - )]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Can you review this snippet ", window, cx) - }); - - // Create a simple buffer with some text so we can create a selection - // that will then be added to the message being edited. - let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| { - (thread_view.workspace.clone(), thread_view.project.clone()) - }); - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer("let a = 10 + 10;", None, false, cx) - }); - - workspace - .update_in(cx, |workspace, window, cx| { - let editor = cx.new(|cx| { - let mut editor = - Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); - - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges([MultiBufferOffset(8)..MultiBufferOffset(15)]); - }); - - editor - }); - workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx); - }) - .unwrap(); - - thread_view.update_in(cx, |view, window, cx| { - assert_eq!( - view.active_thread() - .and_then(|active| active.read(cx).editing_message), - None - ); - view.insert_selections(window, cx); - }); - - message_editor.read_with(cx, |editor, cx| { - let text = editor.text(cx); - let expected_txt = String::from("Can you review this snippet selection "); - - assert_eq!(text, expected_txt); - }) - } - - #[gpui::test] - async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("terminal-1"); - let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`") - .kind(acp::ToolKind::Edit); - - let permission_options = ToolPermissionContext::new( - TerminalTool::NAME, - vec!["cargo build --release".to_string()], - ) - .build_permission_options(); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options, - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - - // Disable notifications to avoid popup windows - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Run cargo build", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Verify the tool call is in WaitingForConfirmation state with the expected options - thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view - .active_thread() - .expect("Thread should exist") - .read(cx) - .thread - .clone(); - let thread = thread.read(cx); - - let tool_call = thread.entries().iter().find_map(|entry| { - if let acp_thread::AgentThreadEntry::ToolCall(call) = entry { - Some(call) - } else { - None - } - }); - - assert!(tool_call.is_some(), "Expected a tool call entry"); - let tool_call = tool_call.unwrap(); - - // Verify it's waiting for confirmation - assert!( - matches!( - tool_call.status, - acp_thread::ToolCallStatus::WaitingForConfirmation { .. } - ), - "Expected WaitingForConfirmation status, got {:?}", - tool_call.status - ); - - // Verify the options count (granularity options only, no separate Deny option) - if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } = - &tool_call.status - { - let PermissionOptions::Dropdown(choices) = options else { - panic!("Expected dropdown permission options"); - }; - - assert_eq!( - choices.len(), - 3, - "Expected 3 permission options (granularity only)" - ); - - // Verify specific button labels (now using neutral names) - let labels: Vec<&str> = choices - .iter() - .map(|choice| choice.allow.name.as_ref()) - .collect(); - assert!( - labels.contains(&"Always for terminal"), - "Missing 'Always for terminal' option" - ); - assert!( - labels.contains(&"Always for `cargo build` commands"), - "Missing pattern option" - ); - assert!( - labels.contains(&"Only this time"), - "Missing 'Only this time' option" - ); - } - }); - } - - #[gpui::test] - async fn test_tool_permission_buttons_edit_file_with_path_pattern(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("edit-file-1"); - let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`") - .kind(acp::ToolKind::Edit); - - let permission_options = - ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()]) - .build_permission_options(); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options, - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - - // Disable notifications - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Edit the main file", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Verify the options - thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view - .active_thread() - .expect("Thread should exist") - .read(cx) - .thread - .clone(); - let thread = thread.read(cx); - - let tool_call = thread.entries().iter().find_map(|entry| { - if let acp_thread::AgentThreadEntry::ToolCall(call) = entry { - Some(call) - } else { - None - } - }); - - assert!(tool_call.is_some(), "Expected a tool call entry"); - let tool_call = tool_call.unwrap(); - - if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } = - &tool_call.status - { - let PermissionOptions::Dropdown(choices) = options else { - panic!("Expected dropdown permission options"); - }; - - let labels: Vec<&str> = choices - .iter() - .map(|choice| choice.allow.name.as_ref()) - .collect(); - assert!( - labels.contains(&"Always for edit file"), - "Missing 'Always for edit file' option" - ); - assert!( - labels.contains(&"Always for `src/`"), - "Missing path pattern option" - ); - } else { - panic!("Expected WaitingForConfirmation status"); - } - }); - } - - #[gpui::test] - async fn test_tool_permission_buttons_fetch_with_domain_pattern(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("fetch-1"); - let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Fetch `https://docs.rs/gpui`") - .kind(acp::ToolKind::Fetch); - - let permission_options = - ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()]) - .build_permission_options(); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options, - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - - // Disable notifications - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Fetch the docs", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Verify the options - thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view - .active_thread() - .expect("Thread should exist") - .read(cx) - .thread - .clone(); - let thread = thread.read(cx); - - let tool_call = thread.entries().iter().find_map(|entry| { - if let acp_thread::AgentThreadEntry::ToolCall(call) = entry { - Some(call) - } else { - None - } - }); - - assert!(tool_call.is_some(), "Expected a tool call entry"); - let tool_call = tool_call.unwrap(); - - if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } = - &tool_call.status - { - let PermissionOptions::Dropdown(choices) = options else { - panic!("Expected dropdown permission options"); - }; - - let labels: Vec<&str> = choices - .iter() - .map(|choice| choice.allow.name.as_ref()) - .collect(); - assert!( - labels.contains(&"Always for fetch"), - "Missing 'Always for fetch' option" - ); - assert!( - labels.contains(&"Always for `docs.rs`"), - "Missing domain pattern option" - ); - } else { - panic!("Expected WaitingForConfirmation status"); - } - }); - } - - #[gpui::test] - async fn test_tool_permission_buttons_without_pattern(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("terminal-no-pattern-1"); - let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `./deploy.sh --production`") - .kind(acp::ToolKind::Edit); - - // No pattern button since ./deploy.sh doesn't match the alphanumeric pattern - let permission_options = ToolPermissionContext::new( - TerminalTool::NAME, - vec!["./deploy.sh --production".to_string()], - ) - .build_permission_options(); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options, - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - - // Disable notifications - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Run the deploy script", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Verify only 2 options (no pattern button when command doesn't match pattern) - thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view - .active_thread() - .expect("Thread should exist") - .read(cx) - .thread - .clone(); - let thread = thread.read(cx); - - let tool_call = thread.entries().iter().find_map(|entry| { - if let acp_thread::AgentThreadEntry::ToolCall(call) = entry { - Some(call) - } else { - None - } - }); - - assert!(tool_call.is_some(), "Expected a tool call entry"); - let tool_call = tool_call.unwrap(); - - if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } = - &tool_call.status - { - let PermissionOptions::Dropdown(choices) = options else { - panic!("Expected dropdown permission options"); - }; - - assert_eq!( - choices.len(), - 2, - "Expected 2 permission options (no pattern option)" - ); - - let labels: Vec<&str> = choices - .iter() - .map(|choice| choice.allow.name.as_ref()) - .collect(); - assert!( - labels.contains(&"Always for terminal"), - "Missing 'Always for terminal' option" - ); - assert!( - labels.contains(&"Only this time"), - "Missing 'Only this time' option" - ); - // Should NOT contain a pattern option - assert!( - !labels.iter().any(|l| l.contains("commands")), - "Should not have pattern option" - ); - } else { - panic!("Expected WaitingForConfirmation status"); - } - }); - } - - #[gpui::test] - async fn test_authorize_tool_call_action_triggers_authorization(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("action-test-1"); - let tool_call = - acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit); - - let permission_options = - ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()]) - .build_permission_options(); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options, - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Run tests", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Verify tool call is waiting for confirmation - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); - assert!( - tool_call.is_some(), - "Expected a tool call waiting for confirmation" - ); - }); - - // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection) - thread_view.update_in(cx, |_, window, cx| { - window.dispatch_action( - crate::AuthorizeToolCall { - tool_call_id: "action-test-1".to_string(), - option_id: "allow".to_string(), - option_kind: "AllowOnce".to_string(), - } - .boxed_clone(), - cx, - ); - }); - - cx.run_until_parked(); - - // Verify tool call is no longer waiting for confirmation (was authorized) - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); - assert!( - tool_call.is_none(), - "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action" - ); - }); - } - - #[gpui::test] - async fn test_authorize_tool_call_action_with_pattern_option(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("pattern-action-test-1"); - let tool_call = - acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit); - - let permission_options = - ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()]) - .build_permission_options(); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options.clone(), - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Install dependencies", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Find the pattern option ID - let pattern_option = match &permission_options { - PermissionOptions::Dropdown(choices) => choices - .iter() - .find(|choice| { - choice - .allow - .option_id - .0 - .starts_with("always_allow_pattern:") - }) - .map(|choice| &choice.allow) - .expect("Should have a pattern option for npm command"), - _ => panic!("Expected dropdown permission options"), - }; - - // Dispatch action with the pattern option (simulating "Always allow `npm` commands") - thread_view.update_in(cx, |_, window, cx| { - window.dispatch_action( - crate::AuthorizeToolCall { - tool_call_id: "pattern-action-test-1".to_string(), - option_id: pattern_option.option_id.0.to_string(), - option_kind: "AllowAlways".to_string(), - } - .boxed_clone(), - cx, - ); - }); - - cx.run_until_parked(); - - // Verify tool call was authorized - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); - assert!( - tool_call.is_none(), - "Tool call should be authorized after selecting pattern option" - ); - }); - } - - #[gpui::test] - async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("granularity-test-1"); - let tool_call = - acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit); - - let permission_options = - ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()]) - .build_permission_options(); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options.clone(), - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Build the project", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Verify default granularity is the last option (index 2 = "Only this time") - thread_view.read_with(cx, |thread_view, cx| { - let state = thread_view.active_thread().unwrap(); - let selected = state - .read(cx) - .selected_permission_granularity - .get(&tool_call_id); - assert!( - selected.is_none(), - "Should have no selection initially (defaults to last)" - ); - }); - - // Select the first option (index 0 = "Always for terminal") - thread_view.update_in(cx, |_, window, cx| { - window.dispatch_action( - crate::SelectPermissionGranularity { - tool_call_id: "granularity-test-1".to_string(), - index: 0, - } - .boxed_clone(), - cx, - ); - }); - - cx.run_until_parked(); - - // Verify the selection was updated - thread_view.read_with(cx, |thread_view, cx| { - let state = thread_view.active_thread().unwrap(); - let selected = state - .read(cx) - .selected_permission_granularity - .get(&tool_call_id); - assert_eq!(selected, Some(&0), "Should have selected index 0"); - }); - } - - #[gpui::test] - async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1"); - let tool_call = - acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit); - - let permission_options = - ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()]) - .build_permission_options(); - - // Verify we have the expected options - let PermissionOptions::Dropdown(choices) = &permission_options else { - panic!("Expected dropdown permission options"); - }; - - assert_eq!(choices.len(), 3); - assert!( - choices[0] - .allow - .option_id - .0 - .contains("always_allow:terminal") - ); - assert!( - choices[1] - .allow - .option_id - .0 - .contains("always_allow_pattern:terminal") - ); - assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow"); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options.clone(), - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Install dependencies", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Select the pattern option (index 1 = "Always for `npm` commands") - thread_view.update_in(cx, |_, window, cx| { - window.dispatch_action( - crate::SelectPermissionGranularity { - tool_call_id: "allow-granularity-test-1".to_string(), - index: 1, - } - .boxed_clone(), - cx, - ); - }); - - cx.run_until_parked(); - - // Simulate clicking the Allow button by dispatching AllowOnce action - // which should use the selected granularity - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| { - view.allow_once(&AllowOnce, window, cx) - }); - - cx.run_until_parked(); - - // Verify tool call was authorized - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); - assert!( - tool_call.is_none(), - "Tool call should be authorized after Allow with pattern granularity" - ); - }); - } - - #[gpui::test] - async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) { - init_test(cx); - - let tool_call_id = acp::ToolCallId::new("deny-granularity-test-1"); - let tool_call = - acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit); - - let permission_options = - ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()]) - .build_permission_options(); - - let connection = - StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( - tool_call_id.clone(), - permission_options.clone(), - )])); - - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); - - cx.update(|_window, cx| { - AgentSettings::override_global( - AgentSettings { - notify_when_agent_waiting: NotifyWhenAgentWaiting::Never, - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Push changes", window, cx); - }); - - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - cx.run_until_parked(); - - // Use default granularity (last option = "Only this time") - // Simulate clicking the Deny button - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| { - view.reject_once(&RejectOnce, window, cx) - }); - - cx.run_until_parked(); - - // Verify tool call was rejected (no longer waiting for confirmation) - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); - assert!( - tool_call.is_none(), - "Tool call should be rejected after Deny" - ); - }); - } - - #[gpui::test] - async fn test_option_id_transformation_for_allow() { - let permission_options = ToolPermissionContext::new( - TerminalTool::NAME, - vec!["cargo build --release".to_string()], - ) - .build_permission_options(); - - let PermissionOptions::Dropdown(choices) = permission_options else { - panic!("Expected dropdown permission options"); - }; - - let allow_ids: Vec = choices - .iter() - .map(|choice| choice.allow.option_id.0.to_string()) - .collect(); - - assert!(allow_ids.contains(&"always_allow:terminal".to_string())); - assert!(allow_ids.contains(&"allow".to_string())); - assert!( - allow_ids - .iter() - .any(|id| id.starts_with("always_allow_pattern:terminal\n")), - "Missing allow pattern option" - ); - } - - #[gpui::test] - async fn test_option_id_transformation_for_deny() { - let permission_options = ToolPermissionContext::new( - TerminalTool::NAME, - vec!["cargo build --release".to_string()], - ) - .build_permission_options(); - - let PermissionOptions::Dropdown(choices) = permission_options else { - panic!("Expected dropdown permission options"); - }; - - let deny_ids: Vec = choices - .iter() - .map(|choice| choice.deny.option_id.0.to_string()) - .collect(); - - assert!(deny_ids.contains(&"always_deny:terminal".to_string())); - assert!(deny_ids.contains(&"deny".to_string())); - assert!( - deny_ids - .iter() - .any(|id| id.starts_with("always_deny_pattern:terminal\n")), - "Missing deny pattern option" - ); - } - - #[gpui::test] - async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - - let active = active_thread(&thread_view, cx); - let title_editor = cx.read(|cx| active.read(cx).title_editor.clone()); - let thread = cx.read(|cx| active.read(cx).thread.clone()); - - title_editor.read_with(cx, |editor, cx| { - assert!(!editor.read_only(cx)); - }); - - title_editor.update_in(cx, |editor, window, cx| { - editor.set_text("My Custom Title", window, cx); - }); - cx.run_until_parked(); - - title_editor.read_with(cx, |editor, cx| { - assert_eq!(editor.text(cx), "My Custom Title"); - }); - thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.title().as_ref(), "My Custom Title"); - }); - } - - #[gpui::test] - async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) { - init_test(cx); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await; - - let active = active_thread(&thread_view, cx); - let title_editor = cx.read(|cx| active.read(cx).title_editor.clone()); - - title_editor.read_with(cx, |editor, cx| { - assert!( - editor.read_only(cx), - "Title editor should be read-only when the connection does not support set_title" - ); - }); - } - - #[gpui::test] - async fn test_max_tokens_error_is_rendered(cx: &mut TestAppContext) { - init_test(cx); - - let connection = StubAgentConnection::new(); - - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - - let message_editor = message_editor(&thread_view, cx); - message_editor.update_in(cx, |editor, window, cx| { - editor.set_text("Some prompt", window, cx); - }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); - - let session_id = thread_view.read_with(cx, |view, cx| { - view.active_thread() - .unwrap() - .read(cx) - .thread - .read(cx) - .session_id() - .clone() - }); - - cx.run_until_parked(); - - cx.update(|_, _cx| { - connection.end_turn(session_id, acp::StopReason::MaxTokens); - }); - - cx.run_until_parked(); - - thread_view.read_with(cx, |thread_view, cx| { - let state = thread_view.active_thread().unwrap(); - let error = &state.read(cx).thread_error; - match error { - Some(ThreadError::Other { message, .. }) => { - assert!( - message.contains("Max tokens reached"), - "Expected 'Max tokens reached' error, got: {}", - message - ); - } - other => panic!( - "Expected ThreadError::Other with 'Max tokens reached', got: {:?}", - other.is_some() - ), - } - }); - } - - fn create_test_acp_thread( - parent_session_id: Option, - session_id: &str, - connection: Rc, - project: Entity, - cx: &mut App, - ) -> Entity { - let action_log = cx.new(|_| ActionLog::new(project.clone())); - cx.new(|cx| { - AcpThread::new( - parent_session_id, - "Test Thread", - connection, - project, - action_log, - acp::SessionId::new(session_id), - watch::Receiver::constant(acp::PromptCapabilities::new()), - cx, - ) - }) - } - - fn request_test_tool_authorization( - thread: &Entity, - tool_call_id: &str, - option_id: &str, - cx: &mut TestAppContext, - ) -> Task { - let tool_call_id = acp::ToolCallId::new(tool_call_id); - let label = format!("Tool {tool_call_id}"); - let option_id = acp::PermissionOptionId::new(option_id); - cx.update(|cx| { - thread.update(cx, |thread, cx| { - thread - .request_tool_call_authorization( - acp::ToolCall::new(tool_call_id, label) - .kind(acp::ToolKind::Edit) - .into(), - PermissionOptions::Flat(vec![acp::PermissionOption::new( - option_id, - "Allow", - acp::PermissionOptionKind::AllowOnce, - )]), - cx, - ) - .unwrap() - }) - }) - } - - #[gpui::test] - async fn test_conversation_multiple_tool_calls_fifo_ordering(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - let connection: Rc = Rc::new(StubAgentConnection::new()); - - let (thread, conversation) = cx.update(|cx| { - let thread = - create_test_acp_thread(None, "session-1", connection.clone(), project.clone(), cx); - let conversation = cx.new(|cx| { - let mut conversation = Conversation::default(); - conversation.register_thread(thread.clone(), cx); - conversation - }); - (thread, conversation) - }); - - let _task1 = request_test_tool_authorization(&thread, "tc-1", "allow-1", cx); - let _task2 = request_test_tool_authorization(&thread, "tc-2", "allow-2", cx); - - cx.read(|cx| { - let session_id = acp::SessionId::new("session-1"); - let (_, tool_call_id, _) = conversation - .read(cx) - .pending_tool_call(&session_id, cx) - .expect("Expected a pending tool call"); - assert_eq!(tool_call_id, acp::ToolCallId::new("tc-1")); - }); - - cx.update(|cx| { - conversation.update(cx, |conversation, cx| { - conversation.authorize_tool_call( - acp::SessionId::new("session-1"), - acp::ToolCallId::new("tc-1"), - acp::PermissionOptionId::new("allow-1"), - acp::PermissionOptionKind::AllowOnce, - cx, - ); - }); - }); - - cx.run_until_parked(); - - cx.read(|cx| { - let session_id = acp::SessionId::new("session-1"); - let (_, tool_call_id, _) = conversation - .read(cx) - .pending_tool_call(&session_id, cx) - .expect("Expected tc-2 to be pending after tc-1 was authorized"); - assert_eq!(tool_call_id, acp::ToolCallId::new("tc-2")); - }); - - cx.update(|cx| { - conversation.update(cx, |conversation, cx| { - conversation.authorize_tool_call( - acp::SessionId::new("session-1"), - acp::ToolCallId::new("tc-2"), - acp::PermissionOptionId::new("allow-2"), - acp::PermissionOptionKind::AllowOnce, - cx, - ); - }); - }); - - cx.run_until_parked(); - - cx.read(|cx| { - let session_id = acp::SessionId::new("session-1"); - assert!( - conversation - .read(cx) - .pending_tool_call(&session_id, cx) - .is_none(), - "Expected no pending tool calls after both were authorized" - ); - }); - } - - #[gpui::test] - async fn test_conversation_subagent_scoped_pending_tool_call(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - let connection: Rc = Rc::new(StubAgentConnection::new()); - - let (parent_thread, subagent_thread, conversation) = cx.update(|cx| { - let parent_thread = - create_test_acp_thread(None, "parent", connection.clone(), project.clone(), cx); - let subagent_thread = create_test_acp_thread( - Some(acp::SessionId::new("parent")), - "subagent", - connection.clone(), - project.clone(), - cx, - ); - let conversation = cx.new(|cx| { - let mut conversation = Conversation::default(); - conversation.register_thread(parent_thread.clone(), cx); - conversation.register_thread(subagent_thread.clone(), cx); - conversation - }); - (parent_thread, subagent_thread, conversation) - }); - - let _parent_task = - request_test_tool_authorization(&parent_thread, "parent-tc", "allow-parent", cx); - let _subagent_task = - request_test_tool_authorization(&subagent_thread, "subagent-tc", "allow-subagent", cx); - - // Querying with the subagent's session ID returns only the - // subagent's own tool call (subagent path is scoped to its session) - cx.read(|cx| { - let subagent_id = acp::SessionId::new("subagent"); - let (session_id, tool_call_id, _) = conversation - .read(cx) - .pending_tool_call(&subagent_id, cx) - .expect("Expected subagent's pending tool call"); - assert_eq!(session_id, acp::SessionId::new("subagent")); - assert_eq!(tool_call_id, acp::ToolCallId::new("subagent-tc")); - }); - - // Querying with the parent's session ID returns the first pending - // request in FIFO order across all sessions - cx.read(|cx| { - let parent_id = acp::SessionId::new("parent"); - let (session_id, tool_call_id, _) = conversation - .read(cx) - .pending_tool_call(&parent_id, cx) - .expect("Expected a pending tool call from parent query"); - assert_eq!(session_id, acp::SessionId::new("parent")); - assert_eq!(tool_call_id, acp::ToolCallId::new("parent-tc")); - }); - } - - #[gpui::test] - async fn test_conversation_parent_pending_tool_call_returns_first_across_threads( - cx: &mut TestAppContext, - ) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - let connection: Rc = Rc::new(StubAgentConnection::new()); - - let (thread_a, thread_b, conversation) = cx.update(|cx| { - let thread_a = - create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx); - let thread_b = - create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx); - let conversation = cx.new(|cx| { - let mut conversation = Conversation::default(); - conversation.register_thread(thread_a.clone(), cx); - conversation.register_thread(thread_b.clone(), cx); - conversation - }); - (thread_a, thread_b, conversation) - }); - - let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx); - let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx); - - // Both threads are non-subagent, so pending_tool_call always returns - // the first entry from permission_requests (FIFO across all sessions) - cx.read(|cx| { - let session_a = acp::SessionId::new("thread-a"); - let (session_id, tool_call_id, _) = conversation - .read(cx) - .pending_tool_call(&session_a, cx) - .expect("Expected a pending tool call"); - assert_eq!(session_id, acp::SessionId::new("thread-a")); - assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a")); - }); - - // Querying with thread-b also returns thread-a's tool call, - // because non-subagent queries always use permission_requests.first() - cx.read(|cx| { - let session_b = acp::SessionId::new("thread-b"); - let (session_id, tool_call_id, _) = conversation - .read(cx) - .pending_tool_call(&session_b, cx) - .expect("Expected a pending tool call from thread-b query"); - assert_eq!( - session_id, - acp::SessionId::new("thread-a"), - "Non-subagent queries always return the first pending request in FIFO order" - ); - assert_eq!(tool_call_id, acp::ToolCallId::new("tc-a")); - }); - - // After authorizing thread-a's tool call, thread-b's becomes first - cx.update(|cx| { - conversation.update(cx, |conversation, cx| { - conversation.authorize_tool_call( - acp::SessionId::new("thread-a"), - acp::ToolCallId::new("tc-a"), - acp::PermissionOptionId::new("allow-a"), - acp::PermissionOptionKind::AllowOnce, - cx, - ); - }); - }); - - cx.run_until_parked(); - - cx.read(|cx| { - let session_b = acp::SessionId::new("thread-b"); - let (session_id, tool_call_id, _) = conversation - .read(cx) - .pending_tool_call(&session_b, cx) - .expect("Expected thread-b's tool call after thread-a's was authorized"); - assert_eq!(session_id, acp::SessionId::new("thread-b")); - assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b")); - }); - } -} diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs deleted file mode 100644 index 6c99bf16a83820..00000000000000 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ /dev/null @@ -1,7465 +0,0 @@ -use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; -use gpui::{Corner, List}; -use language_model::LanguageModelEffortLevel; -use settings::update_settings_file; -use ui::{ButtonLike, SplitButton, SplitButtonStyle, Tab}; - -use super::*; - -#[derive(Default)] -struct ThreadFeedbackState { - feedback: Option, - comments_editor: Option>, -} - -impl ThreadFeedbackState { - pub fn submit( - &mut self, - thread: Entity, - feedback: ThreadFeedback, - window: &mut Window, - cx: &mut App, - ) { - let Some(telemetry) = thread.read(cx).connection().telemetry() else { - return; - }; - - let project = thread.read(cx).project().read(cx); - let client = project.client(); - let user_store = project.user_store(); - let organization = user_store.read(cx).current_organization(); - - if self.feedback == Some(feedback) { - return; - } - - self.feedback = Some(feedback); - match feedback { - ThreadFeedback::Positive => { - self.comments_editor = None; - } - ThreadFeedback::Negative => { - self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx)); - } - } - let session_id = thread.read(cx).session_id().clone(); - 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", - ThreadFeedback::Negative => "negative", - }; - cx.background_spawn(async move { - let thread = task.await?; - - client - .cloud_client() - .submit_agent_feedback(SubmitAgentThreadFeedbackBody { - organization_id: organization.map(|organization| organization.id.clone()), - agent: agent_telemetry_id.to_string(), - session_id: session_id.to_string(), - rating: rating.to_string(), - thread, - }) - .await?; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn submit_comments(&mut self, thread: Entity, cx: &mut App) { - let Some(telemetry) = thread.read(cx).connection().telemetry() else { - return; - }; - - let Some(comments) = self - .comments_editor - .as_ref() - .map(|editor| editor.read(cx).text(cx)) - .filter(|text| !text.trim().is_empty()) - else { - return; - }; - - self.comments_editor.take(); - - let project = thread.read(cx).project().read(cx); - let client = project.client(); - let user_store = project.user_store(); - let organization = user_store.read(cx).current_organization(); - - let session_id = thread.read(cx).session_id().clone(); - 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?; - - client - .cloud_client() - .submit_agent_feedback_comments(SubmitAgentThreadFeedbackCommentsBody { - organization_id: organization.map(|organization| organization.id.clone()), - agent: agent_telemetry_id.to_string(), - session_id: session_id.to_string(), - comments, - thread, - }) - .await?; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn clear(&mut self) { - *self = Self::default() - } - - pub fn dismiss_comments(&mut self) { - self.comments_editor.take(); - } - - fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity { - let buffer = cx.new(|cx| { - let empty_string = String::new(); - MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx) - }); - - let editor = cx.new(|cx| { - let mut editor = Editor::new( - editor::EditorMode::AutoHeight { - min_lines: 1, - max_lines: Some(4), - }, - buffer, - None, - window, - cx, - ); - editor.set_placeholder_text( - "What went wrong? Share your feedback so we can improve.", - window, - cx, - ); - editor - }); - - editor.read(cx).focus_handle(cx).focus(window, cx); - editor - } -} - -#[derive(Default, Clone, Copy)] -struct DiffStats { - lines_added: u32, - lines_removed: u32, -} - -impl DiffStats { - fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { - let mut stats = DiffStats::default(); - let diff_snapshot = diff.snapshot(cx); - let buffer_snapshot = buffer.snapshot(); - let base_text = diff_snapshot.base_text(); - - for hunk in diff_snapshot.hunks(&buffer_snapshot) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - stats.lines_added += added_rows; - - let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; - let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; - let removed_rows = base_end.saturating_sub(base_start); - stats.lines_removed += removed_rows; - } - - stats - } - - fn all_files(changed_buffers: &BTreeMap, Entity>, cx: &App) -> Self { - let mut total = DiffStats::default(); - for (buffer, diff) in changed_buffers { - let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); - total.lines_added += stats.lines_added; - total.lines_removed += stats.lines_removed; - } - total - } -} - -pub struct AcpThreadView { - pub id: acp::SessionId, - pub parent_id: Option, - pub login: Option, // is some <=> Active | Unauthenticated - pub thread: Entity, - pub(crate) conversation: Entity, - pub server_view: WeakEntity, - pub agent_icon: IconName, - pub agent_name: SharedString, - pub focus_handle: FocusHandle, - pub workspace: WeakEntity, - pub entry_view_state: Entity, - pub title_editor: Entity, - pub config_options_view: Option>, - pub mode_selector: Option>, - pub model_selector: Option>, - pub profile_selector: Option>, - pub permission_dropdown_handle: PopoverMenuHandle, - pub thread_retry_status: Option, - pub(super) thread_error: Option, - pub thread_error_markdown: Option>, - pub token_limit_callout_dismissed: bool, - pub last_token_limit_telemetry: Option, - thread_feedback: ThreadFeedbackState, - pub list_state: ListState, - pub prompt_capabilities: Rc>, - pub available_commands: Rc>>, - /// Tracks which tool calls have their content/output expanded. - /// Used for showing/hiding tool call results, terminal output, etc. - pub expanded_tool_calls: HashSet, - pub expanded_tool_call_raw_inputs: HashSet, - pub expanded_thinking_blocks: HashSet<(usize, usize)>, - pub subagent_scroll_handles: RefCell>, - pub edits_expanded: bool, - pub plan_expanded: bool, - pub queue_expanded: bool, - pub editor_expanded: bool, - pub should_be_following: bool, - pub editing_message: Option, - pub local_queued_messages: Vec, - pub queued_message_editors: Vec>, - pub queued_message_editor_subscriptions: Vec, - pub last_synced_queue_length: usize, - pub turn_fields: TurnFields, - pub discarded_partial_edits: HashSet, - pub is_loading_contents: bool, - pub new_server_version_available: Option, - pub resumed_without_history: bool, - /// Tracks the selected granularity index for each tool call's permission dropdown. - /// The index corresponds to the position in the allow_options list. - /// Default is the last option (index pointing to "Only this time"). - pub selected_permission_granularity: HashMap, - pub resume_thread_metadata: Option, - pub _cancel_task: Option>, - pub skip_queue_processing_count: usize, - pub user_interrupted_generation: bool, - pub can_fast_track_queue: bool, - pub hovered_edited_file_buttons: Option, - pub in_flight_prompt: Option>, - pub _subscriptions: Vec, - pub message_editor: Entity, - pub add_context_menu_handle: PopoverMenuHandle, - pub thinking_effort_menu_handle: PopoverMenuHandle, - pub project: WeakEntity, - pub recent_history_entries: Vec, - pub hovered_recent_history_item: Option, - pub show_codex_windows_warning: bool, - pub history: Entity, - pub _history_subscription: Subscription, -} -impl Focusable for AcpThreadView { - fn focus_handle(&self, cx: &App) -> FocusHandle { - if self.parent_id.is_some() { - self.focus_handle.clone() - } else { - self.active_editor(cx).focus_handle(cx) - } - } -} - -#[derive(Default)] -pub struct TurnFields { - pub _turn_timer_task: Option>, - pub last_turn_duration: Option, - pub last_turn_tokens: Option, - pub turn_generation: usize, - pub turn_started_at: Option, - pub turn_tokens: Option, -} - -impl AcpThreadView { - pub(crate) fn new( - parent_id: Option, - thread: Entity, - conversation: Entity, - login: Option, - server_view: WeakEntity, - agent_icon: IconName, - agent_name: SharedString, - agent_display_name: SharedString, - workspace: WeakEntity, - entry_view_state: Entity, - config_options_view: Option>, - mode_selector: Option>, - model_selector: Option>, - profile_selector: Option>, - list_state: ListState, - prompt_capabilities: Rc>, - available_commands: Rc>>, - resumed_without_history: bool, - resume_thread_metadata: Option, - project: WeakEntity, - thread_store: Option>, - history: Entity, - prompt_store: Option>, - initial_content: Option, - mut subscriptions: Vec, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let id = thread.read(cx).session_id().clone(); - - let placeholder = placeholder_text(agent_display_name.as_ref(), false); - - let history_subscription = cx.observe(&history, |this, history, cx| { - this.update_recent_history_from_cache(&history, cx); - }); - - let mut should_auto_submit = false; - - let message_editor = cx.new(|cx| { - let mut editor = MessageEditor::new( - workspace.clone(), - project.clone(), - thread_store, - history.downgrade(), - prompt_store, - prompt_capabilities.clone(), - available_commands.clone(), - agent_name.clone(), - &placeholder, - editor::EditorMode::AutoHeight { - min_lines: AgentSettings::get_global(cx).message_editor_min_lines, - max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()), - }, - window, - cx, - ); - if let Some(content) = initial_content { - match content { - AgentInitialContent::ThreadSummary(entry) => { - editor.insert_thread_summary(entry, window, cx); - } - AgentInitialContent::ContentBlock { - blocks, - auto_submit, - } => { - should_auto_submit = auto_submit; - editor.set_message(blocks, window, cx); - } - } - } - editor - }); - - let show_codex_windows_warning = cfg!(windows) - && project.upgrade().is_some_and(|p| p.read(cx).is_local()) - && agent_name == "Codex"; - - let title_editor = { - let can_edit = thread.update(cx, |thread, cx| thread.can_set_title(cx)); - let editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_text(thread.read(cx).title(), window, cx); - editor.set_read_only(!can_edit); - editor - }); - subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event)); - editor - }; - - subscriptions.push(cx.subscribe_in( - &entry_view_state, - window, - Self::handle_entry_view_event, - )); - - subscriptions.push(cx.subscribe_in( - &message_editor, - window, - Self::handle_message_editor_event, - )); - - let recent_history_entries = history.read(cx).get_recent_sessions(3); - - let mut this = Self { - id, - parent_id, - focus_handle: cx.focus_handle(), - thread, - conversation, - login, - server_view, - agent_icon, - agent_name, - workspace, - entry_view_state, - title_editor, - config_options_view, - mode_selector, - model_selector, - profile_selector, - list_state, - prompt_capabilities, - available_commands, - resumed_without_history, - resume_thread_metadata, - _subscriptions: subscriptions, - permission_dropdown_handle: PopoverMenuHandle::default(), - thread_retry_status: None, - thread_error: None, - thread_error_markdown: None, - token_limit_callout_dismissed: false, - last_token_limit_telemetry: None, - thread_feedback: Default::default(), - expanded_tool_calls: HashSet::default(), - expanded_tool_call_raw_inputs: HashSet::default(), - expanded_thinking_blocks: HashSet::default(), - subagent_scroll_handles: RefCell::new(HashMap::default()), - edits_expanded: false, - plan_expanded: false, - queue_expanded: true, - editor_expanded: false, - should_be_following: true, - editing_message: None, - local_queued_messages: Vec::new(), - queued_message_editors: Vec::new(), - queued_message_editor_subscriptions: Vec::new(), - last_synced_queue_length: 0, - turn_fields: TurnFields::default(), - discarded_partial_edits: HashSet::default(), - is_loading_contents: false, - new_server_version_available: None, - selected_permission_granularity: HashMap::default(), - _cancel_task: None, - skip_queue_processing_count: 0, - user_interrupted_generation: false, - can_fast_track_queue: false, - hovered_edited_file_buttons: None, - in_flight_prompt: None, - message_editor, - add_context_menu_handle: PopoverMenuHandle::default(), - thinking_effort_menu_handle: PopoverMenuHandle::default(), - project, - recent_history_entries, - hovered_recent_history_item: None, - history, - _history_subscription: history_subscription, - show_codex_windows_warning, - }; - if should_auto_submit { - this.send(window, cx); - } - this - } - - pub fn handle_message_editor_event( - &mut self, - _editor: &Entity, - event: &MessageEditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - MessageEditorEvent::Send => self.send(window, cx), - MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx), - MessageEditorEvent::Cancel => self.cancel_generation(cx), - MessageEditorEvent::Focus => { - self.cancel_editing(&Default::default(), window, cx); - } - MessageEditorEvent::LostFocus => {} - } - } - - pub(crate) fn as_native_connection( - &self, - cx: &App, - ) -> Option> { - let acp_thread = self.thread.read(cx); - acp_thread.connection().clone().downcast() - } - - pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { - let acp_thread = self.thread.read(cx); - self.as_native_connection(cx)? - .thread(acp_thread.session_id(), cx) - } - - pub fn current_model_id(&self, cx: &App) -> Option { - if let Some(selector) = self.model_selector.as_ref() { - return selector.read(cx).active_model(cx).map(|m| m.id.to_string()); - } - if let Some(config_view) = self.config_options_view.as_ref() { - return config_view.read(cx).current_model_value(); - } - // Fallback for headless/external threads that have neither selector nor config options: - // read from the global language model registry. - LanguageModelRegistry::read_global(cx) - .default_model() - .map(|m| m.model.id().0.to_string()) - } - - pub fn current_mode_id(&self, cx: &App) -> Option> { - if let Some(thread) = self.as_native_thread(cx) { - Some(thread.read(cx).profile().0.clone()) - } else { - let mode_selector = self.mode_selector.as_ref()?; - Some(mode_selector.read(cx).mode().0) - } - } - - fn is_subagent(&self) -> bool { - self.parent_id.is_some() - } - - /// Returns the currently active editor, either for a message that is being - /// edited or the editor for a new message. - pub(crate) fn active_editor(&self, cx: &App) -> Entity { - if let Some(index) = self.editing_message - && let Some(editor) = self - .entry_view_state - .read(cx) - .entry(index) - .and_then(|entry| entry.message_editor()) - .cloned() - { - editor - } else { - self.message_editor.clone() - } - } - - pub fn has_queued_messages(&self) -> bool { - !self.local_queued_messages.is_empty() - } - - pub fn is_imported_thread(&self, cx: &App) -> bool { - let Some(thread) = self.as_native_thread(cx) else { - return false; - }; - thread.read(cx).is_imported() - } - - // events - - pub fn handle_entry_view_event( - &mut self, - _: &Entity, - event: &EntryViewEvent, - window: &mut Window, - cx: &mut Context, - ) { - match &event.view_event { - ViewEvent::NewDiff(tool_call_id) => { - if AgentSettings::get_global(cx).expand_edit_card { - self.expanded_tool_calls.insert(tool_call_id.clone()); - } - } - ViewEvent::NewTerminal(tool_call_id) => { - if AgentSettings::get_global(cx).expand_terminal_card { - self.expanded_tool_calls.insert(tool_call_id.clone()); - } - } - ViewEvent::TerminalMovedToBackground(tool_call_id) => { - self.expanded_tool_calls.remove(tool_call_id); - } - ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => { - if let Some(AgentThreadEntry::UserMessage(user_message)) = - self.thread.read(cx).entries().get(event.entry_index) - && user_message.id.is_some() - { - self.editing_message = Some(event.entry_index); - cx.notify(); - } - } - ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => { - if let Some(AgentThreadEntry::UserMessage(user_message)) = - self.thread.read(cx).entries().get(event.entry_index) - && user_message.id.is_some() - { - if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) { - self.editing_message = None; - cx.notify(); - } - } - } - ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SendImmediately) => {} - ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => { - self.regenerate(event.entry_index, editor.clone(), window, cx); - } - ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => { - self.cancel_editing(&Default::default(), window, cx); - } - } - } - - // turns - - pub fn start_turn(&mut self, cx: &mut Context) -> usize { - self.turn_fields.turn_generation += 1; - let generation = self.turn_fields.turn_generation; - self.turn_fields.turn_started_at = Some(Instant::now()); - self.turn_fields.last_turn_duration = None; - self.turn_fields.last_turn_tokens = None; - self.turn_fields.turn_tokens = Some(0); - self.turn_fields._turn_timer_task = Some(cx.spawn(async move |this, cx| { - loop { - cx.background_executor().timer(Duration::from_secs(1)).await; - if this.update(cx, |_, cx| cx.notify()).is_err() { - break; - } - } - })); - generation - } - - pub fn stop_turn(&mut self, generation: usize) { - if self.turn_fields.turn_generation != generation { - return; - } - self.turn_fields.last_turn_duration = self - .turn_fields - .turn_started_at - .take() - .map(|started| started.elapsed()); - self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take(); - self.turn_fields._turn_timer_task = None; - } - - pub fn update_turn_tokens(&mut self, cx: &App) { - if let Some(usage) = self.thread.read(cx).token_usage() { - if let Some(tokens) = &mut self.turn_fields.turn_tokens { - *tokens += usage.output_tokens; - } - } - } - - // sending - - pub fn send(&mut self, window: &mut Window, cx: &mut Context) { - let thread = &self.thread; - - if self.is_loading_contents { - return; - } - - let message_editor = self.message_editor.clone(); - let is_editor_empty = message_editor.read(cx).is_empty(cx); - let is_generating = thread.read(cx).status() != ThreadStatus::Idle; - - let has_queued = self.has_queued_messages(); - if is_editor_empty && self.can_fast_track_queue && has_queued { - self.can_fast_track_queue = false; - self.send_queued_message_at_index(0, true, window, cx); - return; - } - - if is_editor_empty { - return; - } - - if is_generating { - self.queue_message(message_editor, window, cx); - return; - } - - let text = message_editor.read(cx).text(cx); - let text = text.trim(); - if text == "/login" || text == "/logout" { - let connection = thread.read(cx).connection().clone(); - let can_login = !connection.auth_methods().is_empty() || self.login.is_some(); - // Does the agent have a specific logout command? Prefer that in case they need to reset internal state. - let logout_supported = text == "/logout" - && self - .available_commands - .borrow() - .iter() - .any(|command| command.name == "logout"); - if can_login && !logout_supported { - message_editor.update(cx, |editor, cx| editor.clear(window, cx)); - - let connection = self.thread.read(cx).connection().clone(); - window.defer(cx, { - let agent_name = self.agent_name.clone(); - let server_view = self.server_view.clone(); - move |window, cx| { - AcpServerView::handle_auth_required( - server_view.clone(), - AuthRequired::new(), - agent_name, - connection, - window, - cx, - ); - } - }); - cx.notify(); - return; - } - } - - self.send_impl(message_editor, window, cx) - } - - pub fn send_impl( - &mut self, - message_editor: Entity, - window: &mut Window, - cx: &mut Context, - ) { - let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| { - // Include full contents when using minimal profile - let thread = thread.read(cx); - AgentSettings::get_global(cx) - .profiles - .get(thread.profile()) - .is_some_and(|profile| profile.tools.is_empty()) - }); - - let contents = message_editor.update(cx, |message_editor, cx| { - message_editor.contents(full_mention_content, cx) - }); - - self.thread_error.take(); - self.thread_feedback.clear(); - self.editing_message.take(); - - if self.should_be_following { - self.workspace - .update(cx, |workspace, cx| { - workspace.follow(CollaboratorId::Agent, window, cx); - }) - .ok(); - } - - let contents_task = cx.spawn_in(window, async move |_this, cx| { - let (contents, tracked_buffers) = contents.await?; - - if contents.is_empty() { - return Ok(None); - } - - let _ = cx.update(|window, cx| { - message_editor.update(cx, |message_editor, cx| { - message_editor.clear(window, cx); - }); - }); - - Ok(Some((contents, tracked_buffers))) - }); - - self.send_content(contents_task, window, cx); - } - - pub fn send_content( - &mut self, - contents_task: Task, Vec>)>>>, - window: &mut Window, - cx: &mut Context, - ) { - let session_id = self.thread.read(cx).session_id().clone(); - let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id(); - let thread = self.thread.downgrade(); - - self.is_loading_contents = true; - - let model_id = self.current_model_id(cx); - let mode_id = self.current_mode_id(cx); - let guard = cx.new(|_| ()); - cx.observe_release(&guard, |this, _guard, cx| { - this.is_loading_contents = false; - cx.notify(); - }) - .detach(); - - let task = cx.spawn_in(window, async move |this, cx| { - let Some((contents, tracked_buffers)) = contents_task.await? else { - return Ok(()); - }; - - let generation = this.update(cx, |this, cx| { - let generation = this.start_turn(cx); - this.in_flight_prompt = Some(contents.clone()); - generation - })?; - - this.update_in(cx, |this, _window, cx| { - this.set_editor_is_expanded(false, cx); - })?; - let _ = this.update(cx, |this, cx| this.scroll_to_bottom(cx)); - - let _stop_turn = defer({ - let this = this.clone(); - let mut cx = cx.clone(); - move || { - this.update(&mut cx, |this, cx| { - this.stop_turn(generation); - cx.notify(); - }) - .ok(); - } - }); - let turn_start_time = Instant::now(); - let send = thread.update(cx, |thread, cx| { - thread.action_log().update(cx, |action_log, cx| { - for buffer in tracked_buffers { - action_log.buffer_read(buffer, cx) - } - }); - drop(guard); - - telemetry::event!( - "Agent Message Sent", - agent = agent_telemetry_id, - session = session_id, - model = model_id, - mode = mode_id - ); - - thread.send(contents, cx) - })?; - let res = send.await; - let turn_time_ms = turn_start_time.elapsed().as_millis(); - drop(_stop_turn); - let status = if res.is_ok() { - let _ = this.update(cx, |this, _| this.in_flight_prompt.take()); - "success" - } else { - "failure" - }; - telemetry::event!( - "Agent Turn Completed", - agent = agent_telemetry_id, - session = session_id, - model = model_id, - mode = mode_id, - status, - turn_time_ms, - ); - res.map(|_| ()) - }); - - cx.spawn(async move |this, cx| { - if let Err(err) = task.await { - this.update(cx, |this, cx| { - this.handle_any_thread_error(err, cx); - }) - .ok(); - } else { - this.update(cx, |this, cx| { - let should_be_following = this - .workspace - .update(cx, |workspace, _| { - workspace.is_being_followed(CollaboratorId::Agent) - }) - .unwrap_or_default(); - this.should_be_following = should_be_following; - }) - .ok(); - } - }) - .detach(); - } - - pub fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context) { - let thread = &self.thread; - - if self.is_loading_contents { - return; - } - - let message_editor = self.message_editor.clone(); - if thread.read(cx).status() == ThreadStatus::Idle { - self.send_impl(message_editor, window, cx); - return; - } - - self.stop_current_and_send_new_message(message_editor, window, cx); - } - - fn stop_current_and_send_new_message( - &mut self, - message_editor: Entity, - window: &mut Window, - cx: &mut Context, - ) { - let thread = self.thread.clone(); - self.skip_queue_processing_count = 0; - self.user_interrupted_generation = true; - - let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx)); - - cx.spawn_in(window, async move |this, cx| { - cancelled.await; - - this.update_in(cx, |this, window, cx| { - this.send_impl(message_editor, window, cx); - }) - .ok(); - }) - .detach(); - } - - pub(crate) fn handle_any_thread_error(&mut self, error: anyhow::Error, cx: &mut Context) { - let error = ThreadError::from_err(error, &self.agent_name); - self.handle_thread_error(error, cx); - } - - pub(crate) fn handle_thread_error(&mut self, error: ThreadError, cx: &mut Context) { - self.emit_thread_error_telemetry(&error, cx); - self.thread_error = Some(error); - cx.notify(); - } - - fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context) { - let (error_kind, acp_error_code, message): (&str, Option, SharedString) = - match error { - ThreadError::PaymentRequired => ( - "payment_required", - None, - "You reached your free usage limit. Upgrade to Zed Pro for more prompts." - .into(), - ), - ThreadError::Refusal => { - let model_or_agent_name = self.current_model_name(cx); - let message = format!( - "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.", - model_or_agent_name - ); - ("refusal", None, message.into()) - } - ThreadError::AuthenticationRequired(message) => { - ("authentication_required", None, message.clone()) - } - ThreadError::Other { - acp_error_code, - message, - } => ("other", acp_error_code.clone(), message.clone()), - }; - - let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id(); - let session_id = self.thread.read(cx).session_id().clone(); - - telemetry::event!( - "Agent Panel Error Shown", - agent = agent_telemetry_id, - session_id = session_id, - kind = error_kind, - acp_error_code = acp_error_code, - message = message, - ); - } - - // generation - - pub fn cancel_generation(&mut self, cx: &mut Context) { - self.thread_retry_status.take(); - self.thread_error.take(); - self.user_interrupted_generation = true; - self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx))); - } - - pub fn retry_generation(&mut self, cx: &mut Context) { - self.thread_error.take(); - - let thread = &self.thread; - if !thread.read(cx).can_retry(cx) { - return; - } - - let task = thread.update(cx, |thread, cx| thread.retry(cx)); - cx.spawn(async move |this, cx| { - let result = task.await; - - this.update(cx, |this, cx| { - if let Err(err) = result { - this.handle_any_thread_error(err, cx); - } - }) - }) - .detach(); - } - - pub fn regenerate( - &mut self, - entry_ix: usize, - message_editor: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if self.is_loading_contents { - return; - } - let thread = self.thread.clone(); - - let Some(user_message_id) = thread.update(cx, |thread, _| { - thread.entries().get(entry_ix)?.user_message()?.id.clone() - }) else { - return; - }; - - cx.spawn_in(window, async move |this, cx| { - // Check if there are any edits from prompts before the one being regenerated. - // - // If there are, we keep/accept them since we're not regenerating the prompt that created them. - // - // If editing the prompt that generated the edits, they are auto-rejected - // through the `rewind` function in the `acp_thread`. - let has_earlier_edits = thread.read_with(cx, |thread, _| { - thread - .entries() - .iter() - .take(entry_ix) - .any(|entry| entry.diffs().next().is_some()) - }); - - if has_earlier_edits { - thread.update(cx, |thread, cx| { - thread.action_log().update(cx, |action_log, cx| { - action_log.keep_all_edits(None, cx); - }); - }); - } - - thread - .update(cx, |thread, cx| thread.rewind(user_message_id, cx)) - .await?; - this.update_in(cx, |thread, window, cx| { - thread.send_impl(message_editor, window, cx); - thread.focus_handle(cx).focus(window, cx); - })?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - // message queueing - - fn queue_message( - &mut self, - message_editor: Entity, - window: &mut Window, - cx: &mut Context, - ) { - let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle; - - if is_idle { - self.send_impl(message_editor.clone(), window, cx); - return; - } - - let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| { - let thread = thread.read(cx); - AgentSettings::get_global(cx) - .profiles - .get(thread.profile()) - .is_some_and(|profile| profile.tools.is_empty()) - }); - - let contents = message_editor.update(cx, |message_editor, cx| { - message_editor.contents(full_mention_content, cx) - }); - - cx.spawn_in(window, async move |this, cx| { - let (content, tracked_buffers) = contents.await?; - - if content.is_empty() { - return Ok::<(), anyhow::Error>(()); - } - - this.update_in(cx, |this, window, cx| { - this.add_to_queue(content, tracked_buffers, cx); - this.can_fast_track_queue = true; - message_editor.update(cx, |message_editor, cx| { - message_editor.clear(window, cx); - }); - cx.notify(); - })?; - Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn add_to_queue( - &mut self, - content: Vec, - tracked_buffers: Vec>, - cx: &mut Context, - ) { - self.local_queued_messages.push(QueuedMessage { - content, - tracked_buffers, - }); - self.sync_queue_flag_to_native_thread(cx); - } - - pub fn remove_from_queue( - &mut self, - index: usize, - cx: &mut Context, - ) -> Option { - if index < self.local_queued_messages.len() { - let removed = self.local_queued_messages.remove(index); - self.sync_queue_flag_to_native_thread(cx); - Some(removed) - } else { - None - } - } - - pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context) { - if let Some(native_thread) = self.as_native_thread(cx) { - let has_queued = self.has_queued_messages(); - native_thread.update(cx, |thread, _| { - thread.set_has_queued_message(has_queued); - }); - } - } - - pub fn send_queued_message_at_index( - &mut self, - index: usize, - is_send_now: bool, - window: &mut Window, - cx: &mut Context, - ) { - let Some(queued) = self.remove_from_queue(index, cx) else { - return; - }; - let content = queued.content; - let tracked_buffers = queued.tracked_buffers; - - // Only increment skip count for "Send Now" operations (out-of-order sends) - // Normal auto-processing from the Stopped handler doesn't need to skip. - // We only skip the Stopped event from the cancelled generation, NOT the - // Stopped event from the newly sent message (which should trigger queue processing). - if is_send_now { - let is_generating = - self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating; - self.skip_queue_processing_count += if is_generating { 1 } else { 0 }; - } - - let cancelled = self.thread.update(cx, |thread, cx| thread.cancel(cx)); - - let workspace = self.workspace.clone(); - - let should_be_following = self.should_be_following; - let contents_task = cx.spawn_in(window, async move |_this, cx| { - cancelled.await; - if should_be_following { - workspace - .update_in(cx, |workspace, window, cx| { - workspace.follow(CollaboratorId::Agent, window, cx); - }) - .ok(); - } - - Ok(Some((content, tracked_buffers))) - }); - - self.send_content(contents_task, window, cx); - } - - // editor methods - - pub fn expand_message_editor( - &mut self, - _: &ExpandMessageEditor, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_editor_is_expanded(!self.editor_expanded, cx); - cx.stop_propagation(); - cx.notify(); - } - - pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { - self.editor_expanded = is_expanded; - self.message_editor.update(cx, |editor, cx| { - if is_expanded { - editor.set_mode( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, - }, - cx, - ) - } else { - let agent_settings = AgentSettings::get_global(cx); - editor.set_mode( - EditorMode::AutoHeight { - min_lines: agent_settings.message_editor_min_lines, - max_lines: Some(agent_settings.set_message_editor_max_lines()), - }, - cx, - ) - } - }); - cx.notify(); - } - - pub fn handle_title_editor_event( - &mut self, - title_editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - let thread = &self.thread; - - match event { - EditorEvent::BufferEdited => { - let new_title = title_editor.read(cx).text(cx); - thread.update(cx, |thread, cx| { - thread - .set_title(new_title.into(), cx) - .detach_and_log_err(cx); - }) - } - EditorEvent::Blurred => { - if title_editor.read(cx).text(cx).is_empty() { - title_editor.update(cx, |editor, cx| { - editor.set_text("New Thread", window, cx); - }); - } - } - _ => {} - } - } - - pub fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - if let Some(index) = self.editing_message.take() - && let Some(editor) = &self - .entry_view_state - .read(cx) - .entry(index) - .and_then(|e| e.message_editor()) - .cloned() - { - editor.update(cx, |editor, cx| { - if let Some(user_message) = self - .thread - .read(cx) - .entries() - .get(index) - .and_then(|e| e.user_message()) - { - editor.set_message(user_message.chunks.clone(), window, cx); - } - }) - }; - cx.notify(); - } - - // tool permissions - - pub fn authorize_tool_call( - &mut self, - session_id: acp::SessionId, - tool_call_id: acp::ToolCallId, - option_id: acp::PermissionOptionId, - option_kind: acp::PermissionOptionKind, - window: &mut Window, - cx: &mut Context, - ) { - self.conversation.update(cx, |conversation, cx| { - conversation.authorize_tool_call(session_id, tool_call_id, option_id, option_kind, cx); - }); - if self.should_be_following { - self.workspace - .update(cx, |workspace, cx| { - workspace.follow(CollaboratorId::Agent, window, cx); - }) - .ok(); - } - cx.notify(); - } - - pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context) { - self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); - } - - pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { - self.authorize_pending_with_granularity(true, window, cx); - } - - pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { - self.authorize_pending_with_granularity(false, window, cx); - } - - pub fn authorize_pending_tool_call( - &mut self, - kind: acp::PermissionOptionKind, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - self.conversation.update(cx, |conversation, cx| { - conversation.authorize_pending_tool_call(&self.id, kind, cx) - })?; - if self.should_be_following { - self.workspace - .update(cx, |workspace, cx| { - workspace.follow(CollaboratorId::Agent, window, cx); - }) - .ok(); - } - cx.notify(); - Some(()) - } - - fn handle_authorize_tool_call( - &mut self, - action: &AuthorizeToolCall, - window: &mut Window, - cx: &mut Context, - ) { - let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone()); - let option_id = acp::PermissionOptionId::new(action.option_id.clone()); - let option_kind = match action.option_kind.as_str() { - "AllowOnce" => acp::PermissionOptionKind::AllowOnce, - "AllowAlways" => acp::PermissionOptionKind::AllowAlways, - "RejectOnce" => acp::PermissionOptionKind::RejectOnce, - "RejectAlways" => acp::PermissionOptionKind::RejectAlways, - _ => acp::PermissionOptionKind::AllowOnce, - }; - - self.authorize_tool_call( - self.id.clone(), - tool_call_id, - option_id, - option_kind, - window, - cx, - ); - } - - pub fn handle_select_permission_granularity( - &mut self, - action: &SelectPermissionGranularity, - _window: &mut Window, - cx: &mut Context, - ) { - let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone()); - self.selected_permission_granularity - .insert(tool_call_id, action.index); - - cx.notify(); - } - - fn authorize_pending_with_granularity( - &mut self, - is_allow: bool, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let (session_id, tool_call_id, options) = - self.conversation.read(cx).pending_tool_call(&self.id, cx)?; - let PermissionOptions::Dropdown(choices) = options else { - let kind = if is_allow { - acp::PermissionOptionKind::AllowOnce - } else { - acp::PermissionOptionKind::RejectOnce - }; - return self.authorize_pending_tool_call(kind, window, cx); - }; - - // Get selected index, defaulting to last option ("Only this time") - let selected_index = self - .selected_permission_granularity - .get(&tool_call_id) - .copied() - .unwrap_or_else(|| choices.len().saturating_sub(1)); - - let selected_choice = choices.get(selected_index).or(choices.last())?; - - let selected_option = if is_allow { - &selected_choice.allow - } else { - &selected_choice.deny - }; - - self.authorize_tool_call( - session_id, - tool_call_id, - selected_option.option_id.clone(), - selected_option.kind, - window, - cx, - ); - - Some(()) - } - - // edits - - pub fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context) { - let thread = &self.thread; - let telemetry = ActionLogTelemetry::from(thread.read(cx)); - let action_log = thread.read(cx).action_log().clone(); - action_log.update(cx, |action_log, cx| { - action_log.keep_all_edits(Some(telemetry), cx) - }); - } - - pub fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context) { - let thread = &self.thread; - let telemetry = ActionLogTelemetry::from(thread.read(cx)); - let action_log = thread.read(cx).action_log().clone(); - let has_changes = action_log.read(cx).changed_buffers(cx).len() > 0; - - action_log - .update(cx, |action_log, cx| { - action_log.reject_all_edits(Some(telemetry), cx) - }) - .detach(); - - if has_changes { - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - crate::ui::show_undo_reject_toast(workspace, action_log, cx); - }); - } - } - } - - pub fn undo_last_reject( - &mut self, - _: &UndoLastReject, - _window: &mut Window, - cx: &mut Context, - ) { - let thread = &self.thread; - let action_log = thread.read(cx).action_log().clone(); - action_log - .update(cx, |action_log, cx| action_log.undo_last_reject(cx)) - .detach() - } - - pub fn open_edited_buffer( - &mut self, - buffer: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let thread = &self.thread; - - let Some(diff) = - AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err() - else { - return; - }; - - diff.update(cx, |diff, cx| { - diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx) - }) - } - - // thread stuff - - fn share_thread(&mut self, _window: &mut Window, cx: &mut Context) { - let Some((thread, project)) = self.as_native_thread(cx).zip(self.project.upgrade()) else { - return; - }; - - let client = project.read(cx).client(); - let workspace = self.workspace.clone(); - let session_id = thread.read(cx).id().to_string(); - - let load_task = thread.read(cx).to_db(cx); - - cx.spawn(async move |_this, cx| { - let db_thread = load_task.await; - - let shared_thread = SharedThread::from_db_thread(&db_thread); - let thread_data = shared_thread.to_bytes()?; - let title = shared_thread.title.to_string(); - - client - .request(proto::ShareAgentThread { - session_id: session_id.clone(), - title, - thread_data, - }) - .await?; - - let share_url = client::zed_urls::shared_agent_thread_url(&session_id); - - cx.update(|cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - struct ThreadSharedToast; - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Thread shared!", - ) - .on_click( - "Copy URL", - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - share_url.clone(), - )); - }, - ), - cx, - ); - }); - } - }); - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn sync_thread( - &mut self, - project: Entity, - server_view: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if !self.is_imported_thread(cx) { - return; - } - - let Some(session_list) = self - .as_native_connection(cx) - .and_then(|connection| connection.session_list(cx)) - .and_then(|list| list.downcast::()) - else { - return; - }; - let thread_store = session_list.thread_store().clone(); - - let client = project.read(cx).client(); - let session_id = self.thread.read(cx).session_id().clone(); - cx.spawn_in(window, async move |this, cx| { - let response = client - .request(proto::GetSharedAgentThread { - session_id: session_id.to_string(), - }) - .await?; - - let shared_thread = SharedThread::from_bytes(&response.thread_data)?; - - let db_thread = shared_thread.to_db_thread(); - - thread_store - .update(&mut cx.clone(), |store, cx| { - store.save_thread(session_id.clone(), db_thread, cx) - }) - .await?; - - let thread_metadata = AgentSessionInfo { - session_id, - cwd: None, - title: Some(format!("🔗 {}", response.title).into()), - updated_at: Some(chrono::Utc::now()), - meta: None, - }; - - this.update_in(cx, |this, window, cx| { - this.resume_thread_metadata = Some(thread_metadata); - server_view.update(cx, |server_view, cx| server_view.reset(window, cx)); - })?; - - this.update_in(cx, |this, _window, cx| { - if let Some(workspace) = this.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - struct ThreadSyncedToast; - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Thread synced with latest version", - ) - .autohide(), - cx, - ); - }); - } - })?; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context) { - self.thread - .update(cx, |thread, cx| { - thread.restore_checkpoint(message_id.clone(), cx) - }) - .detach_and_log_err(cx); - } - - pub fn clear_thread_error(&mut self, cx: &mut Context) { - self.thread_error = None; - self.thread_error_markdown = None; - self.token_limit_callout_dismissed = true; - cx.notify(); - } - - fn is_following(&self, cx: &App) -> bool { - match self.thread.read(cx).status() { - ThreadStatus::Generating => self - .workspace - .read_with(cx, |workspace, _| { - workspace.is_being_followed(CollaboratorId::Agent) - }) - .unwrap_or(false), - _ => self.should_be_following, - } - } - - fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { - let following = self.is_following(cx); - - self.should_be_following = !following; - if self.thread.read(cx).status() == ThreadStatus::Generating { - self.workspace - .update(cx, |workspace, cx| { - if following { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } else { - workspace.follow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); - } - - telemetry::event!("Follow Agent Selected", following = !following); - } - - // other - - pub fn render_thread_retry_status_callout(&self) -> Option { - let state = self.thread_retry_status.as_ref()?; - - let next_attempt_in = state - .duration - .saturating_sub(Instant::now().saturating_duration_since(state.started_at)); - if next_attempt_in.is_zero() { - return None; - } - - let next_attempt_in_secs = next_attempt_in.as_secs() + 1; - - let retry_message = if state.max_attempts == 1 { - if next_attempt_in_secs == 1 { - "Retrying. Next attempt in 1 second.".to_string() - } else { - format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.") - } - } else if next_attempt_in_secs == 1 { - format!( - "Retrying. Next attempt in 1 second (Attempt {} of {}).", - state.attempt, state.max_attempts, - ) - } else { - format!( - "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).", - state.attempt, state.max_attempts, - ) - }; - - Some( - Callout::new() - .icon(IconName::Warning) - .severity(Severity::Warning) - .title(state.last_error.clone()) - .description(retry_message), - ) - } - - pub fn handle_open_rules( - &mut self, - _: &ClickEvent, - window: &mut Window, - cx: &mut Context, - ) { - let Some(thread) = self.as_native_thread(cx) else { - return; - }; - let project_context = thread.read(cx).project_context().read(cx); - - let project_entry_ids = project_context - .worktrees - .iter() - .flat_map(|worktree| worktree.rules_file.as_ref()) - .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id)) - .collect::>(); - - self.workspace - .update(cx, move |workspace, cx| { - // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules - // files clear. For example, if rules file 1 is already open but rules file 2 is not, - // this would open and focus rules file 2 in a tab that is not next to rules file 1. - let project = workspace.project().read(cx); - let project_paths = project_entry_ids - .into_iter() - .flat_map(|entry_id| project.path_for_entry(entry_id, cx)) - .collect::>(); - for project_path in project_paths { - workspace - .open_path(project_path, None, true, window, cx) - .detach_and_log_err(cx); - } - }) - .ok(); - } - - fn activity_bar_bg(&self, cx: &Context) -> Hsla { - let editor_bg_color = cx.theme().colors().editor_background; - let active_color = cx.theme().colors().element_selected; - editor_bg_color.blend(active_color.opacity(0.3)) - } - - pub fn render_activity_bar( - &self, - window: &mut Window, - cx: &Context, - ) -> Option { - let thread = self.thread.read(cx); - let action_log = thread.action_log(); - let telemetry = ActionLogTelemetry::from(thread); - let changed_buffers = action_log.read(cx).changed_buffers(cx); - let plan = thread.plan(); - let queue_is_empty = !self.has_queued_messages(); - - if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty { - return None; - } - - // Temporarily always enable ACP edit controls. This is temporary, to lessen the - // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't - // be, which blocks you from being able to accept or reject edits. This switches the - // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't - // block you from using the panel. - let pending_edits = false; - - let plan_expanded = self.plan_expanded; - let edits_expanded = self.edits_expanded; - let queue_expanded = self.queue_expanded; - - v_flex() - .mt_1() - .mx_2() - .bg(self.activity_bar_bg(cx)) - .border_1() - .border_b_0() - .border_color(cx.theme().colors().border) - .rounded_t_md() - .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.15), - offset: point(px(1.), px(-1.)), - blur_radius: px(3.), - spread_radius: px(0.), - }]) - .when(!plan.is_empty(), |this| { - this.child(self.render_plan_summary(plan, window, cx)) - .when(plan_expanded, |parent| { - parent.child(self.render_plan_entries(plan, window, cx)) - }) - }) - .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| { - this.child(Divider::horizontal().color(DividerColor::Border)) - }) - .when(!changed_buffers.is_empty(), |this| { - this.child(self.render_edits_summary( - &changed_buffers, - edits_expanded, - pending_edits, - cx, - )) - .when(edits_expanded, |parent| { - parent.child(self.render_edited_files( - action_log, - telemetry.clone(), - &changed_buffers, - pending_edits, - cx, - )) - }) - }) - .when(!queue_is_empty, |this| { - this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| { - this.child(Divider::horizontal().color(DividerColor::Border)) - }) - .child(self.render_message_queue_summary(window, cx)) - .when(queue_expanded, |parent| { - parent.child(self.render_message_queue_entries(window, cx)) - }) - }) - .into_any() - .into() - } - - fn render_edited_files( - &self, - action_log: &Entity, - telemetry: ActionLogTelemetry, - changed_buffers: &BTreeMap, Entity>, - pending_edits: bool, - cx: &Context, - ) -> impl IntoElement { - let editor_bg_color = cx.theme().colors().editor_background; - - // Sort edited files alphabetically for consistency with Git diff view - let mut sorted_buffers: Vec<_> = changed_buffers.iter().collect(); - sorted_buffers.sort_by(|(buffer_a, _), (buffer_b, _)| { - let path_a = buffer_a.read(cx).file().map(|f| f.path().clone()); - let path_b = buffer_b.read(cx).file().map(|f| f.path().clone()); - path_a.cmp(&path_b) - }); - - v_flex() - .id("edited_files_list") - .max_h_40() - .overflow_y_scroll() - .children( - sorted_buffers - .into_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_name = path.file_name().map(|name| { - Label::new(name.to_string()) - .size(LabelSize::XSmall) - .buffer_font(cx) - .ml_1() - }); - - 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_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); - - let buttons = self.render_edited_files_buttons( - index, - buffer, - action_log, - &telemetry, - pending_edits, - editor_bg_color, - cx, - ); - - let element = h_flex() - .group("edited-code") - .id(("file-container", index)) - .relative() - .min_w_0() - .p_1p5() - .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() - .rounded_xs() - .child(file_icon) - .children(file_name) - .children(file_path) - .child( - DiffStat::new( - "file", - file_stats.lines_added as usize, - file_stats.lines_removed as usize, - ) - .label_size(LabelSize::XSmall), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .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(buttons); - - Some(element) - }), - ) - .into_any_element() - } - - fn render_edited_files_buttons( - &self, - index: usize, - buffer: &Entity, - action_log: &Entity, - telemetry: &ActionLogTelemetry, - pending_edits: bool, - editor_bg_color: Hsla, - cx: &Context, - ) -> impl IntoElement { - h_flex() - .id("edited-buttons-container") - .visible_on_hover("edited-code") - .absolute() - .right_0() - .px_1() - .gap_1() - .bg(editor_bg_color) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_edited_file_buttons = Some(index); - } else if this.hovered_edited_file_buttons == Some(index) { - this.hovered_edited_file_buttons = None; - } - cx.notify(); - })) - .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( - Button::new(("reject-file", index), "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_max_range_for_buffer( - buffer.read(cx).remote_id(), - )], - Some(telemetry.clone()), - cx, - ) - .0 - .detach_and_log_err(cx); - }) - } - }), - ) - .child( - Button::new(("keep-file", index), "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, - ); - }) - } - }), - ) - } - - fn render_message_queue_summary( - &self, - _window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let queue_count = self.local_queued_messages.len(); - let title: SharedString = if queue_count == 1 { - "1 Queued Message".into() - } else { - format!("{} Queued Messages", queue_count).into() - }; - - h_flex() - .p_1() - .w_full() - .gap_1() - .justify_between() - .when(self.queue_expanded, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - h_flex() - .id("queue_summary") - .gap_1() - .child(Disclosure::new("queue_disclosure", self.queue_expanded)) - .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) - .on_click(cx.listener(|this, _, _, cx| { - this.queue_expanded = !this.queue_expanded; - cx.notify(); - })), - ) - .child( - Button::new("clear_queue", "Clear All") - .label_size(LabelSize::Small) - .key_binding(KeyBinding::for_action(&ClearMessageQueue, cx)) - .on_click(cx.listener(|this, _, _, cx| { - this.clear_queue(cx); - this.can_fast_track_queue = false; - cx.notify(); - })), - ) - .into_any_element() - } - - fn clear_queue(&mut self, cx: &mut Context) { - self.local_queued_messages.clear(); - self.sync_queue_flag_to_native_thread(cx); - } - - fn render_plan_summary( - &self, - plan: &Plan, - window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let plan_expanded = self.plan_expanded; - let stats = plan.stats(); - - let title = if let Some(entry) = stats.in_progress_entry - && !plan_expanded - { - h_flex() - .cursor_default() - .relative() - .w_full() - .gap_1() - .truncate() - .child( - Label::new("Current:") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().colors().text_muted) - .line_clamp(1) - .child(MarkdownElement::new( - entry.content.clone(), - plan_label_markdown_style(&entry.status, window, cx), - )), - ) - .when(stats.pending > 0, |this| { - this.child( - h_flex() - .absolute() - .top_0() - .right_0() - .h_full() - .child(div().min_w_8().h_full().bg(linear_gradient( - 90., - linear_color_stop(self.activity_bar_bg(cx), 1.), - linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.), - ))) - .child( - div().pr_0p5().bg(self.activity_bar_bg(cx)).child( - Label::new(format!("{} left", stats.pending)) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ), - ) - }) - } else { - let status_label = if stats.pending == 0 { - "All Done".to_string() - } else if stats.completed == 0 { - format!("{} Tasks", plan.entries.len()) - } else { - format!("{}/{}", stats.completed, plan.entries.len()) - }; - - h_flex() - .w_full() - .gap_1() - .justify_between() - .child( - Label::new("Plan") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(status_label) - .size(LabelSize::Small) - .color(Color::Muted) - .mr_1(), - ) - }; - - h_flex() - .id("plan_summary") - .p_1() - .w_full() - .gap_1() - .when(plan_expanded, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child(Disclosure::new("plan_disclosure", plan_expanded)) - .child(title) - .on_click(cx.listener(|this, _, _, cx| { - this.plan_expanded = !this.plan_expanded; - cx.notify(); - })) - .into_any_element() - } - - 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) - })) - .into_any_element() - } - - fn render_edits_summary( - &self, - changed_buffers: &BTreeMap, Entity>, - expanded: bool, - pending_edits: bool, - cx: &Context, - ) -> Div { - const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete."; - - let focus_handle = self.focus_handle(cx); - - h_flex() - .p_1() - .justify_between() - .flex_wrap() - .when(expanded, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - h_flex() - .id("edits-container") - .cursor_pointer() - .gap_1() - .child(Disclosure::new("edits-disclosure", expanded)) - .map(|this| { - if pending_edits { - this.child( - Label::new(format!( - "Editing {} {}…", - changed_buffers.len(), - if changed_buffers.len() == 1 { - "file" - } else { - "files" - } - )) - .color(Color::Muted) - .size(LabelSize::Small) - .with_animation( - "edit-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.7)), - |label, delta| label.alpha(delta), - ), - ) - } else { - let stats = DiffStats::all_files(changed_buffers, cx); - let dot_divider = || { - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Disabled) - }; - - this.child( - Label::new("Edits") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(dot_divider()) - .child( - Label::new(format!( - "{} {}", - changed_buffers.len(), - if changed_buffers.len() == 1 { - "file" - } else { - "files" - } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(dot_divider()) - .child(DiffStat::new( - "total", - stats.lines_added as usize, - stats.lines_removed as usize, - )) - } - }) - .on_click(cx.listener(|this, _, _, cx| { - this.edits_expanded = !this.edits_expanded; - cx.notify(); - })), - ) - .child( - h_flex() - .gap_1() - .child( - IconButton::new("review-changes", IconName::ListTodo) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Review Changes", - &OpenAgentDiff, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(OpenAgentDiff.boxed_clone(), cx); - })), - ) - .child(Divider::vertical().color(DividerColor::Border)) - .child( - Button::new("reject-all-changes", "Reject All") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .when(pending_edits, |this| { - this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) - }) - .key_binding( - KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.reject_all(&RejectAll, window, cx); - })), - ) - .child( - Button::new("keep-all-changes", "Keep All") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .when(pending_edits, |this| { - this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) - }) - .key_binding( - KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.keep_all(&KeepAll, window, cx); - })), - ), - ) - } - - fn is_subagent_canceled_or_failed(&self, cx: &App) -> bool { - let Some(parent_session_id) = self.parent_id.as_ref() else { - return false; - }; - - let my_session_id = self.thread.read(cx).session_id().clone(); - - self.server_view - .upgrade() - .and_then(|sv| sv.read(cx).thread_view(parent_session_id)) - .is_some_and(|parent_view| { - parent_view - .read(cx) - .thread - .read(cx) - .tool_call_for_subagent(&my_session_id) - .is_some_and(|tc| { - matches!( - tc.status, - ToolCallStatus::Canceled - | ToolCallStatus::Failed - | ToolCallStatus::Rejected - ) - }) - }) - } - - pub(crate) fn render_subagent_titlebar(&mut self, cx: &mut Context) -> Option
{ - let Some(parent_session_id) = self.parent_id.clone() else { - return None; - }; - - let server_view = self.server_view.clone(); - let thread = self.thread.clone(); - let is_done = thread.read(cx).status() == ThreadStatus::Idle; - let is_canceled_or_failed = self.is_subagent_canceled_or_failed(cx); - - Some( - h_flex() - .h(Tab::container_height(cx)) - .pl_2() - .pr_1p5() - .w_full() - .justify_between() - .gap_1() - .border_b_1() - .when(is_done && is_canceled_or_failed, |this| { - this.border_dashed() - }) - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background.opacity(0.2)) - .child( - h_flex() - .flex_1() - .gap_2() - .child( - Icon::new(IconName::ForwardArrowUp) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child(self.title_editor.clone()) - .when(is_done && is_canceled_or_failed, |this| { - this.child(Icon::new(IconName::Close).color(Color::Error)) - }) - .when(is_done && !is_canceled_or_failed, |this| { - this.child(Icon::new(IconName::Check).color(Color::Success)) - }), - ) - .child( - h_flex() - .gap_0p5() - .when(!is_done, |this| { - this.child( - IconButton::new("stop_subagent", IconName::Stop) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .tooltip(Tooltip::text("Stop Subagent")) - .on_click(move |_, _, cx| { - thread.update(cx, |thread, cx| { - thread.cancel(cx).detach(); - }); - }), - ) - }) - .child( - IconButton::new("minimize_subagent", IconName::Minimize) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Minimize Subagent")) - .on_click(move |_, window, cx| { - let _ = server_view.update(cx, |server_view, cx| { - server_view.navigate_to_session( - parent_session_id.clone(), - window, - cx, - ); - }); - }), - ), - ), - ) - } - - pub(crate) fn render_message_editor( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - if self.is_subagent() { - return div().into_any_element(); - } - - let focus_handle = self.message_editor.focus_handle(cx); - let editor_bg_color = cx.theme().colors().editor_background; - let editor_expanded = self.editor_expanded; - let (expand_icon, expand_tooltip) = if editor_expanded { - (IconName::Minimize, "Minimize Message Editor") - } else { - (IconName::Maximize, "Expand Message Editor") - }; - - v_flex() - .on_action(cx.listener(Self::expand_message_editor)) - .p_2() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(editor_bg_color) - .when(editor_expanded, |this| { - this.h(vh(0.8, window)).size_full().justify_between() - }) - .child( - v_flex() - .relative() - .size_full() - .pt_1() - .pr_2p5() - .child(self.message_editor.clone()) - .child( - h_flex() - .absolute() - .top_0() - .right_0() - .opacity(0.5) - .hover(|this| this.opacity(1.0)) - .child( - IconButton::new("toggle-height", expand_icon) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - expand_tooltip, - &ExpandMessageEditor, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener(|this, _, window, cx| { - this.expand_message_editor( - &ExpandMessageEditor, - window, - cx, - ); - })), - ), - ), - ) - .child( - h_flex() - .flex_none() - .flex_wrap() - .justify_between() - .child( - h_flex() - .gap_0p5() - .child(self.render_add_context_button(cx)) - .child(self.render_follow_toggle(cx)) - .children(self.render_thinking_control(cx)), - ) - .child( - h_flex() - .gap_1() - .children(self.render_token_usage(cx)) - .children(self.profile_selector.clone()) - .map(|this| { - // Either config_options_view OR (mode_selector + model_selector) - match self.config_options_view.clone() { - Some(config_view) => this.child(config_view), - None => this - .children(self.mode_selector.clone()) - .children(self.model_selector.clone()), - } - }) - .child(self.render_send_button(cx)), - ), - ) - .into_any() - } - - fn render_message_queue_entries( - &self, - _window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let message_editor = self.message_editor.read(cx); - let focus_handle = message_editor.focus_handle(cx); - - let queued_message_editors = &self.queued_message_editors; - let queue_len = queued_message_editors.len(); - let can_fast_track = self.can_fast_track_queue && queue_len > 0; - - v_flex() - .id("message_queue_list") - .max_h_40() - .overflow_y_scroll() - .children( - queued_message_editors - .iter() - .enumerate() - .map(|(index, editor)| { - let is_next = index == 0; - let (icon_color, tooltip_text) = if is_next { - (Color::Accent, "Next in Queue") - } else { - (Color::Muted, "In Queue") - }; - - let editor_focused = editor.focus_handle(cx).is_focused(_window); - let keybinding_size = rems_from_px(12.); - - h_flex() - .group("queue_entry") - .w_full() - .p_1p5() - .gap_1() - .bg(cx.theme().colors().editor_background) - .when(index < queue_len - 1, |this| { - this.border_b_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - div() - .id("next_in_queue") - .child( - Icon::new(IconName::Circle) - .size(IconSize::Small) - .color(icon_color), - ) - .tooltip(Tooltip::text(tooltip_text)), - ) - .child(editor.clone()) - .child(if editor_focused { - h_flex() - .gap_1() - .min_w_40() - .child( - IconButton::new(("cancel_edit", index), IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Cancel Edit", - &editor::actions::Cancel, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let main_editor = self.message_editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&main_editor.focus_handle(cx), cx); - }) - }), - ) - .child( - IconButton::new(("save_edit", index), IconName::Check) - .icon_size(IconSize::Small) - .icon_color(Color::Success) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Save Edit", - &Chat, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let main_editor = self.message_editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&main_editor.focus_handle(cx), cx); - }) - }), - ) - .child( - Button::new(("send_now_focused", index), "Send Now") - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in( - &SendImmediately, - &editor.focus_handle(cx), - cx, - ) - .map(|kb| kb.size(keybinding_size)), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.send_queued_message_at_index( - index, true, window, cx, - ); - })), - ) - } else { - h_flex() - .gap_1() - .when(!is_next, |this| this.visible_on_hover("queue_entry")) - .child( - IconButton::new(("edit", index), IconName::Pencil) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - if is_next { - Tooltip::for_action_in( - "Edit", - &EditFirstQueuedMessage, - &focus_handle, - cx, - ) - } else { - Tooltip::simple("Edit", cx) - } - } - }) - .on_click({ - let editor = editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&editor.focus_handle(cx), cx); - }) - }), - ) - .child( - IconButton::new(("delete", index), IconName::Trash) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - if is_next { - Tooltip::for_action_in( - "Remove Message from Queue", - &RemoveFirstQueuedMessage, - &focus_handle, - cx, - ) - } else { - Tooltip::simple( - "Remove Message from Queue", - cx, - ) - } - } - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_from_queue(index, cx); - cx.notify(); - })), - ) - .child( - Button::new(("send_now", index), "Send Now") - .label_size(LabelSize::Small) - .when(is_next && message_editor.is_empty(cx), |this| { - let action: Box = - if can_fast_track { - Box::new(Chat) - } else { - Box::new(SendNextQueuedMessage) - }; - - this.style(ButtonStyle::Outlined).key_binding( - KeyBinding::for_action_in( - action.as_ref(), - &focus_handle.clone(), - cx, - ) - .map(|kb| kb.size(keybinding_size)), - ) - }) - .when(is_next && !message_editor.is_empty(cx), |this| { - this.style(ButtonStyle::Outlined) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.send_queued_message_at_index( - index, true, window, cx, - ); - })), - ) - }) - }), - ) - .into_any_element() - } - - fn supports_split_token_display(&self, cx: &App) -> bool { - self.as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .is_some_and(|model| model.supports_split_token_display()) - } - - fn render_token_usage(&self, cx: &mut Context) -> Option { - let thread = self.thread.read(cx); - let usage = thread.token_usage()?; - let is_generating = thread.status() != ThreadStatus::Idle; - let show_split = self.supports_split_token_display(cx); - - let separator_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.5)); - let token_label = |text: String, animation_id: &'static str| { - Label::new(text) - .size(LabelSize::Small) - .color(Color::Muted) - .map(|label| { - if is_generating { - label - .with_animation( - animation_id, - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.8)), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - label.into_any_element() - } - }) - }; - - if show_split { - let max_output_tokens = self - .as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .and_then(|model| model.max_output_tokens()) - .unwrap_or(0); - - let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens); - let input_max = crate::text_thread_editor::humanize_token_count( - usage.max_tokens.saturating_sub(max_output_tokens), - ); - let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); - let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens); - - Some( - h_flex() - .flex_shrink_0() - .gap_1() - .mr_1p5() - .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::ArrowUp) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(token_label(input, "input-tokens-label")) - .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) - .child( - Label::new(input_max) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::ArrowDown) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(token_label(output, "output-tokens-label")) - .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) - .child( - Label::new(output_max) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element(), - ) - } else { - let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); - let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); - let progress_ratio = if usage.max_tokens > 0 { - usage.used_tokens as f32 / usage.max_tokens as f32 - } else { - 0.0 - }; - - let progress_color = if progress_ratio >= 0.85 { - cx.theme().status().warning - } else { - cx.theme().colors().text_muted - }; - let separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); - - let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); - - let (user_rules_count, project_rules_count) = self - .as_native_thread(cx) - .map(|thread| { - let project_context = thread.read(cx).project_context().read(cx); - let user_rules = project_context.user_rules.len(); - let project_rules = project_context - .worktrees - .iter() - .filter(|wt| wt.rules_file.is_some()) - .count(); - (user_rules, project_rules) - }) - .unwrap_or((0, 0)); - - Some( - h_flex() - .id("circular_progress_tokens") - .mt_px() - .mr_1() - .child( - CircularProgress::new( - usage.used_tokens as f32, - usage.max_tokens as f32, - px(16.0), - cx, - ) - .stroke_width(px(2.)) - .progress_color(progress_color), - ) - .tooltip(Tooltip::element({ - move |_, cx| { - v_flex() - .min_w_40() - .child( - Label::new("Context") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - h_flex() - .gap_0p5() - .child(Label::new(percentage.clone())) - .child(Label::new("•").color(separator_color).mx_1()) - .child(Label::new(used.clone())) - .child(Label::new("/").color(separator_color)) - .child(Label::new(max.clone()).color(Color::Muted)), - ) - .when(user_rules_count > 0 || project_rules_count > 0, |this| { - this.child( - v_flex() - .mt_1p5() - .pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new("Rules") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .when(user_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} user rules", - user_rules_count - ))) - }) - .when(project_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} project rules", - project_rules_count - ))) - }), - ) - }) - .into_any_element() - } - })) - .into_any_element(), - ) - } - } - - fn render_thinking_control(&self, cx: &mut Context) -> Option { - let thread = self.as_native_thread(cx)?.read(cx); - let model = thread.model()?; - - let supports_thinking = model.supports_thinking(); - if !supports_thinking { - return None; - } - - let thinking = thread.thinking_enabled(); - - let (tooltip_label, icon, color) = if thinking { - ( - "Disable Thinking Mode", - IconName::ThinkingMode, - Color::Muted, - ) - } else { - ( - "Enable Thinking Mode", - IconName::ThinkingModeOff, - Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)), - ) - }; - - let focus_handle = self.message_editor.focus_handle(cx); - - let thinking_toggle = IconButton::new("thinking-mode", icon) - .icon_size(IconSize::Small) - .icon_color(color) - .tooltip(move |_, cx| { - Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) - }) - .on_click(cx.listener(move |this, _, _window, cx| { - if let Some(thread) = this.as_native_thread(cx) { - thread.update(cx, |thread, cx| { - let enable_thinking = !thread.thinking_enabled(); - thread.set_thinking_enabled(enable_thinking, cx); - - let fs = thread.project().read(cx).fs().clone(); - update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = agent.default_model.as_mut() - { - default_model.enable_thinking = enable_thinking; - } - }); - }); - } - })); - - if model.supported_effort_levels().is_empty() { - return Some(thinking_toggle.into_any_element()); - } - - if !model.supported_effort_levels().is_empty() && !thinking { - return Some(thinking_toggle.into_any_element()); - } - - let left_btn = thinking_toggle; - let right_btn = self.render_effort_selector( - model.supported_effort_levels(), - thread.thinking_effort().cloned(), - cx, - ); - - Some( - SplitButton::new(left_btn, right_btn.into_any_element()) - .style(SplitButtonStyle::Transparent) - .into_any_element(), - ) - } - - fn render_effort_selector( - &self, - supported_effort_levels: Vec, - selected_effort: Option, - cx: &Context, - ) -> impl IntoElement { - let weak_self = cx.weak_entity(); - - let default_effort_level = supported_effort_levels - .iter() - .find(|effort_level| effort_level.is_default) - .cloned(); - - let selected = selected_effort.and_then(|effort| { - supported_effort_levels - .iter() - .find(|level| level.value == effort) - .cloned() - }); - - let label = selected - .clone() - .or(default_effort_level) - .map_or("Select Effort".into(), |effort| effort.name); - - let (label_color, icon) = if self.thinking_effort_menu_handle.is_deployed() { - (Color::Accent, IconName::ChevronUp) - } else { - (Color::Muted, IconName::ChevronDown) - }; - - let focus_handle = self.message_editor.focus_handle(cx); - let show_cycle_row = supported_effort_levels.len() > 1; - - let tooltip = Tooltip::element({ - move |_, cx| { - let mut content = v_flex().gap_1().child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Change Thinking Effort")) - .child(KeyBinding::for_action_in( - &ToggleThinkingEffortMenu, - &focus_handle, - cx, - )), - ); - - if show_cycle_row { - content = content.child( - h_flex() - .pt_1() - .gap_2() - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(Label::new("Cycle Thinking Effort")) - .child(KeyBinding::for_action_in( - &CycleThinkingEffort, - &focus_handle, - cx, - )), - ); - } - - content.into_any_element() - } - }); - - PopoverMenu::new("effort-selector") - .trigger_with_tooltip( - ButtonLike::new_rounded_right("effort-selector-trigger") - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child(Label::new(label).size(LabelSize::Small).color(label_color)) - .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), - tooltip, - ) - .menu(move |window, cx| { - Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| { - menu = menu.header("Change Thinking Effort"); - - for effort_level in supported_effort_levels.clone() { - let is_selected = selected - .as_ref() - .is_some_and(|selected| selected.value == effort_level.value); - let entry = ContextMenuEntry::new(effort_level.name) - .toggleable(IconPosition::End, is_selected); - - menu.push_item(entry.handler({ - let effort = effort_level.value.clone(); - let weak_self = weak_self.clone(); - move |_window, cx| { - let effort = effort.clone(); - weak_self - .update(cx, |this, cx| { - if let Some(thread) = this.as_native_thread(cx) { - thread.update(cx, |thread, cx| { - thread.set_thinking_effort( - Some(effort.to_string()), - cx, - ); - - let fs = thread.project().read(cx).fs().clone(); - update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = - agent.default_model.as_mut() - { - default_model.effort = - Some(effort.to_string()); - } - }); - }); - } - }) - .ok(); - } - })); - } - - menu - })) - }) - .with_handle(self.thinking_effort_menu_handle.clone()) - .offset(gpui::Point { - x: px(0.0), - y: px(-2.0), - }) - .anchor(Corner::BottomLeft) - } - - fn render_send_button(&self, cx: &mut Context) -> AnyElement { - let message_editor = self.message_editor.read(cx); - let is_editor_empty = message_editor.is_empty(cx); - let focus_handle = message_editor.focus_handle(cx); - - let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle; - - if self.is_loading_contents { - div() - .id("loading-message-content") - .px_1() - .tooltip(Tooltip::text("Loading Added Context…")) - .child(loading_contents_spinner(IconSize::default())) - .into_any_element() - } else if is_generating && is_editor_empty { - IconButton::new("stop-generation", IconName::Stop) - .icon_color(Color::Error) - .style(ButtonStyle::Tinted(TintColor::Error)) - .tooltip(move |_window, cx| { - Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx) - }) - .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) - .into_any_element() - } else { - IconButton::new("send-message", IconName::Send) - .style(ButtonStyle::Filled) - .map(|this| { - if is_editor_empty && !is_generating { - this.disabled(true).icon_color(Color::Muted) - } else { - this.icon_color(Color::Accent) - } - }) - .tooltip(move |_window, cx| { - if is_editor_empty && !is_generating { - Tooltip::for_action("Type to Send", &Chat, cx) - } else if is_generating { - let focus_handle = focus_handle.clone(); - - Tooltip::element(move |_window, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Queue and Send")) - .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(Label::new("Send Immediately")) - .child(KeyBinding::for_action_in( - &SendImmediately, - &focus_handle, - cx, - )), - ) - .into_any_element() - })(_window, cx) - } else { - Tooltip::for_action("Send Message", &Chat, cx) - } - }) - .on_click(cx.listener(|this, _, window, cx| { - this.send(window, cx); - })) - .into_any_element() - } - } - - fn render_add_context_button(&mut self, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.message_editor.focus_handle(cx); - let weak_self = cx.weak_entity(); - - PopoverMenu::new("add-context-menu") - .trigger_with_tooltip( - IconButton::new("add-context", IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), - { - move |_window, cx| { - Tooltip::for_action_in( - "Add Context", - &OpenAddContextMenu, - &focus_handle, - cx, - ) - } - }, - ) - .anchor(Corner::BottomLeft) - .with_handle(self.add_context_menu_handle.clone()) - .offset(gpui::Point { - x: px(0.0), - y: px(-2.0), - }) - .menu(move |window, cx| { - weak_self - .update(cx, |this, cx| this.build_add_context_menu(window, cx)) - .ok() - }) - } - - fn build_add_context_menu( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - let message_editor = self.message_editor.clone(); - let workspace = self.workspace.clone(); - let supports_images = self.prompt_capabilities.borrow().image; - - let has_editor_selection = workspace - .upgrade() - .and_then(|ws| { - ws.read(cx) - .active_item(cx) - .and_then(|item| item.downcast::()) - }) - .is_some_and(|editor| { - editor.update(cx, |editor, cx| { - editor.has_non_empty_selection(&editor.display_snapshot(cx)) - }) - }); - - let has_terminal_selection = workspace - .upgrade() - .and_then(|ws| ws.read(cx).panel::(cx)) - .is_some_and(|panel| !panel.read(cx).terminal_selections(cx).is_empty()); - - let has_selection = has_editor_selection || has_terminal_selection; - - ContextMenu::build(window, cx, move |menu, _window, _cx| { - menu.key_context("AddContextMenu") - .header("Context") - .item( - ContextMenuEntry::new("Files & Directories") - .icon(IconName::File) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.insert_context_type("file", window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Symbols") - .icon(IconName::Code) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.insert_context_type("symbol", window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Threads") - .icon(IconName::Thread) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.insert_context_type("thread", window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Rules") - .icon(IconName::Reader) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.insert_context_type("rule", window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Image") - .icon(IconName::Image) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .disabled(!supports_images) - .handler({ - let message_editor = message_editor.clone(); - move |window, cx| { - message_editor.focus_handle(cx).focus(window, cx); - message_editor.update(cx, |editor, cx| { - editor.add_images_from_picker(window, cx); - }); - } - }), - ) - .item( - ContextMenuEntry::new("Selection") - .icon(IconName::CursorIBeam) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .disabled(!has_selection) - .handler({ - move |window, cx| { - window.dispatch_action( - zed_actions::agent::AddSelectionToThread.boxed_clone(), - cx, - ); - } - }), - ) - }) - } - - fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { - let following = self.is_following(cx); - - let tooltip_label = if following { - if self.agent_name == "Zed Agent" { - format!("Stop Following the {}", self.agent_name) - } else { - format!("Stop Following {}", self.agent_name) - } - } else { - if self.agent_name == "Zed Agent" { - format!("Follow the {}", self.agent_name) - } else { - format!("Follow {}", self.agent_name) - } - }; - - IconButton::new("follow-agent", IconName::Crosshair) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .toggle_state(following) - .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) - .tooltip(move |_window, cx| { - if following { - Tooltip::for_action(tooltip_label.clone(), &Follow, cx) - } else { - Tooltip::with_meta( - tooltip_label.clone(), - Some(&Follow), - "Track the agent's location as it reads and edits files.", - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.toggle_following(window, cx); - })) - } -} - -impl AcpThreadView { - pub(crate) fn render_entries(&mut self, cx: &mut Context) -> List { - list( - self.list_state.clone(), - cx.processor(|this, index: usize, window, cx| { - let entries = this.thread.read(cx).entries(); - let Some(entry) = entries.get(index) else { - return Empty.into_any(); - }; - this.render_entry(index, entries.len(), entry, window, cx) - }), - ) - .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_grow() - } - - fn render_entry( - &self, - entry_ix: usize, - total_entries: usize, - entry: &AgentThreadEntry, - window: &Window, - cx: &Context, - ) -> AnyElement { - let is_indented = entry.is_indented(); - let is_first_indented = is_indented - && self - .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 - .entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.message_editor()) - .cloned() - else { - return Empty.into_any_element(); - }; - - let editing = self.editing_message == Some(entry_ix); - let editor_focus = editor.focus_handle(cx).is_focused(window); - let focus_border = cx.theme().colors().border_focused; - - let rules_item = if entry_ix == 0 { - self.render_rules_item(cx) - } else { - None - }; - - let has_checkpoint_button = message - .checkpoint - .as_ref() - .is_some_and(|checkpoint| checkpoint.show); - - let agent_name = self.agent_name.clone(); - let is_subagent = self.is_subagent(); - - let non_editable_icon = || { - IconButton::new("non_editable", IconName::PencilUnavailable) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .style(ButtonStyle::Transparent) - }; - - v_flex() - .id(("user_message", entry_ix)) - .map(|this| { - 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() - } else { - this.pt_2() - } - }) - .pb_3() - .px_2() - .gap_1p5() - .w_full() - .children(rules_item) - .children(message.id.clone().and_then(|message_id| { - message.checkpoint.as_ref()?.show.then(|| { - h_flex() - .px_3() - .gap_2() - .child(Divider::horizontal()) - .child( - Button::new("restore-checkpoint", "Restore Checkpoint") - .icon(IconName::Undo) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::Start) - .label_size(LabelSize::XSmall) - .icon_color(Color::Muted) - .color(Color::Muted) - .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation.")) - .on_click(cx.listener(move |this, _, _window, cx| { - this.restore_checkpoint(&message_id, cx); - })) - ) - .child(Divider::horizontal()) - }) - })) - .child( - div() - .relative() - .child( - div() - .py_3() - .px_2() - .rounded_md() - .bg(cx.theme().colors().editor_background) - .border_1() - .when(is_indented, |this| { - this.py_2().px_2().shadow_sm() - }) - .border_color(cx.theme().colors().border) - .map(|this| { - if is_subagent { - return this.border_dashed(); - } - if editing && editor_focus { - return this.border_color(focus_border); - } - if editing && !editor_focus { - return this.border_dashed() - } - if message.id.is_some() { - return this.shadow_md().hover(|s| { - s.border_color(focus_border.opacity(0.8)) - }); - } - this - }) - .text_xs() - .child(editor.clone().into_any_element()) - ) - .when(editor_focus, |this| { - let base_container = h_flex() - .absolute() - .top_neg_3p5() - .right_3() - .gap_1() - .rounded_sm() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .overflow_hidden(); - - let is_loading_contents = self.is_loading_contents; - if is_subagent { - this.child( - base_container.border_dashed().child( - non_editable_icon().tooltip(move |_, cx| { - Tooltip::with_meta( - "Unavailable Editing", - None, - "Editing subagent messages is currently not supported.", - cx, - ) - }), - ), - ) - } else if message.id.is_some() { - this.child( - base_container - .child( - IconButton::new("cancel", IconName::Close) - .disabled(is_loading_contents) - .icon_color(Color::Error) - .icon_size(IconSize::XSmall) - .on_click(cx.listener(Self::cancel_editing)) - ) - .child( - if is_loading_contents { - div() - .id("loading-edited-message-content") - .tooltip(Tooltip::text("Loading Added Context…")) - .child(loading_contents_spinner(IconSize::XSmall)) - .into_any_element() - } else { - IconButton::new("regenerate", IconName::Return) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text( - "Editing will restart the thread from this point." - )) - .on_click(cx.listener({ - let editor = editor.clone(); - move |this, _, window, cx| { - this.regenerate( - entry_ix, editor.clone(), window, cx, - ); - } - })).into_any_element() - } - ) - ) - } else { - this.child( - base_container - .border_dashed() - .child( - non_editable_icon() - .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() - } - })) - ) - ) - } - }), - ) - .into_any() - } - AgentThreadEntry::AssistantMessage(AssistantMessage { - chunks, - indented: _, - }) => { - let mut is_blank = true; - let is_last = entry_ix + 1 == total_entries; - - let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); - let message_body = v_flex() - .w_full() - .gap_3() - .children(chunks.iter().enumerate().filter_map( - |(chunk_ix, chunk)| match chunk { - AssistantMessageChunk::Message { block } => { - 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().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(); - - 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(self.render_message_context_menu(entry_ix, message_body, cx)) - .into_any() - } - } - AgentThreadEntry::ToolCall(tool_call) => self - .render_any_tool_call( - &self.id, - entry_ix, - tool_call, - &self.focus_handle(cx), - window, - cx, - ) - .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 { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ) - } else { - false - }; - - let thread = self.thread.clone(); - let comments_editor = self.thread_feedback.comments_editor.clone(); - - let primary = if entry_ix == total_entries - 1 { - v_flex() - .w_full() - .child(primary) - .map(|this| { - if needs_confirmation { - this.child(self.render_generating(true, cx)) - } else { - this.child(self.render_thread_controls(&thread, cx)) - } - }) - .when_some(comments_editor, |this, editor| { - this.child(Self::render_feedback_feedback_editor(editor, cx)) - }) - .into_any_element() - } else { - primary - }; - - if let Some(editing_index) = self.editing_message - && editing_index < entry_ix - { - let is_subagent = self.is_subagent(); - - let backdrop = div() - .id(("backdrop", entry_ix)) - .size_full() - .absolute() - .inset_0() - .bg(cx.theme().colors().panel_background) - .opacity(0.8) - .block_mouse_except_scroll() - .on_click(cx.listener(Self::cancel_editing)); - - div() - .relative() - .child(primary) - .when(!is_subagent, |this| this.child(backdrop)) - .into_any_element() - } else { - primary - } - } - - fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { - h_flex() - .key_context("AgentFeedbackMessageEditor") - .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { - this.thread_feedback.dismiss_comments(); - cx.notify(); - })) - .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { - this.submit_feedback_message(cx); - })) - .p_2() - .mb_2() - .mx_5() - .gap_1() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .child(div().w_full().child(editor)) - .child( - h_flex() - .child( - IconButton::new("dismiss-feedback-message", IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::XSmall) - .shape(ui::IconButtonShape::Square) - .on_click(cx.listener(move |this, _, _window, cx| { - this.thread_feedback.dismiss_comments(); - cx.notify(); - })), - ) - .child( - IconButton::new("submit-feedback-message", IconName::Return) - .icon_size(IconSize::XSmall) - .shape(ui::IconButtonShape::Square) - .on_click(cx.listener(move |this, _, _window, cx| { - this.submit_feedback_message(cx); - })), - ), - ) - } - - fn render_thread_controls( - &self, - thread: &Entity, - cx: &Context, - ) -> impl IntoElement { - let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); - if is_generating { - return self.render_generating(false, cx).into_any_element(); - } - - let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Open Thread as Markdown")) - .on_click(cx.listener(move |this, _, window, cx| { - if let Some(workspace) = this.workspace.upgrade() { - this.open_thread_as_markdown(workspace, window, cx) - .detach_and_log_err(cx); - } - })); - - 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) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Top")) - .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to_top(cx); - })); - - let show_stats = AgentSettings::get_global(cx).show_turn_stats; - let last_turn_clock = show_stats - .then(|| { - self.turn_fields - .last_turn_duration - .filter(|&duration| duration > STOPWATCH_THRESHOLD) - .map(|duration| { - Label::new(duration_alt_display(duration)) - .size(LabelSize::Small) - .color(Color::Muted) - }) - }) - .flatten(); - - let last_turn_tokens_label = last_turn_clock - .is_some() - .then(|| { - self.turn_fields - .last_turn_tokens - .filter(|&tokens| tokens > TOKEN_THRESHOLD) - .map(|tokens| { - Label::new(format!( - "{} tokens", - crate::text_thread_editor::humanize_token_count(tokens) - )) - .size(LabelSize::Small) - .color(Color::Muted) - }) - }) - .flatten(); - - let mut container = h_flex() - .w_full() - .py_2() - .px_5() - .gap_px() - .opacity(0.6) - .hover(|s| s.opacity(1.)) - .justify_end() - .when( - last_turn_tokens_label.is_some() || last_turn_clock.is_some(), - |this| { - this.child( - h_flex() - .gap_1() - .px_1() - .when_some(last_turn_tokens_label, |this, label| this.child(label)) - .when_some(last_turn_clock, |this, label| this.child(label)), - ) - }, - ); - - if AgentSettings::get_global(cx).enable_feedback - && self.thread.read(cx).connection().telemetry().is_some() - { - let feedback = self.thread_feedback.feedback; - - let tooltip_meta = || { - SharedString::new( - "Rating the thread sends all of your current conversation to the Zed team.", - ) - }; - - container = container - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Positive) => { - Tooltip::text("Thanks for your feedback!")(window, cx) - } - _ => { - Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Positive, window, cx); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Negative) => { - Tooltip::text( - "We appreciate your feedback and will use it to improve in the future.", - )(window, cx) - } - _ => { - Tooltip::with_meta( - "Not Helpful Response", - None, - tooltip_meta(), - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Negative, window, cx); - })), - ); - } - - if let Some(project) = self.project.upgrade() - && let Some(server_view) = self.server_view.upgrade() - && cx.has_flag::() - && project.read(cx).client().status().borrow().is_connected() - { - let button = if self.is_imported_thread(cx) { - IconButton::new("sync-thread", IconName::ArrowCircle) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Sync with source thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.sync_thread(project.clone(), server_view.clone(), window, cx); - })) - } else { - IconButton::new("share-thread", IconName::ArrowUpRight) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Share Thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.share_thread(window, cx); - })) - }; - - container = container.child(button); - } - - container - .child(open_as_markdown) - .child(scroll_to_recent_user_prompt) - .child(scroll_to_top) - .into_any_element() - } - - pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { - let entries = self.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) { - let entry_count = self.thread.read(cx).entries().len(); - self.list_state.reset(entry_count); - cx.notify(); - } - - fn handle_feedback_click( - &mut self, - feedback: ThreadFeedback, - window: &mut Window, - cx: &mut Context, - ) { - self.thread_feedback - .submit(self.thread.clone(), feedback, window, cx); - cx.notify(); - } - - fn submit_feedback_message(&mut self, cx: &mut Context) { - let thread = self.thread.clone(); - self.thread_feedback.submit_comments(thread, cx); - cx.notify(); - } - - pub(crate) fn scroll_to_top(&mut self, cx: &mut Context) { - self.list_state.scroll_to(ListOffset::default()); - cx.notify(); - } - - pub fn open_thread_as_markdown( - &self, - workspace: Entity, - window: &mut Window, - cx: &mut App, - ) -> Task> { - let markdown_language_task = workspace - .read(cx) - .app_state() - .languages - .language_for_name("Markdown"); - - let thread = self.thread.read(cx); - let thread_title = thread.title().to_string(); - let markdown = thread.to_markdown(cx); - - let project = workspace.read(cx).project().clone(); - window.spawn(cx, async move |cx| { - let markdown_language = markdown_language_task.await?; - - let buffer = project - .update(cx, |project, cx| { - project.create_buffer(Some(markdown_language), false, cx) - }) - .await?; - - buffer.update(cx, |buffer, cx| { - buffer.set_text(markdown, cx); - buffer.set_capability(language::Capability::ReadWrite, cx); - }); - - workspace.update_in(cx, |workspace, window, cx| { - let buffer = cx - .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone())); - - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - let mut editor = - Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); - editor.set_breadcrumb_header(thread_title); - editor - })), - None, - true, - window, - cx, - ); - })?; - anyhow::Ok(()) - }) - } - - fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { - let show_stats = AgentSettings::get_global(cx).show_turn_stats; - let elapsed_label = show_stats - .then(|| { - self.turn_fields.turn_started_at.and_then(|started_at| { - let elapsed = started_at.elapsed(); - (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed)) - }) - }) - .flatten(); - - let is_waiting = confirmation || self.thread.read(cx).has_in_progress_tool_calls(); - - let turn_tokens_label = elapsed_label - .is_some() - .then(|| { - self.turn_fields - .turn_tokens - .filter(|&tokens| tokens > TOKEN_THRESHOLD) - .map(|tokens| crate::text_thread_editor::humanize_token_count(tokens)) - }) - .flatten(); - - let arrow_icon = if is_waiting { - IconName::ArrowUp - } else { - IconName::ArrowDown - }; - - h_flex() - .id("generating-spinner") - .py_2() - .px(rems_from_px(22.)) - .gap_2() - .map(|this| { - if confirmation { - this.child( - h_flex() - .w_2() - .child(SpinnerLabel::sand().size(LabelSize::Small)), - ) - .child( - div().min_w(rems(8.)).child( - LoadingLabel::new("Awaiting Confirmation") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - } else { - this.child(SpinnerLabel::new().size(LabelSize::Small)) - } - }) - .when_some(elapsed_label, |this, elapsed| { - this.child( - Label::new(elapsed) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) - .when_some(turn_tokens_label, |this, tokens| { - this.child( - h_flex() - .gap_0p5() - .child( - Icon::new(arrow_icon) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(format!("{} tokens", tokens)) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - .into_any_element() - } - - fn render_thinking_block( - &self, - entry_ix: usize, - chunk_ix: usize, - chunk: Entity, - window: &Window, - cx: &Context, - ) -> AnyElement { - let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); - let card_header_id = SharedString::from("inner-card-header"); - - let key = (entry_ix, chunk_ix); - - let is_open = self.expanded_thinking_blocks.contains(&key); - - let scroll_handle = self - .entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); - - let thinking_content = { - div() - .id(("thinking-content", chunk_ix)) - .when_some(scroll_handle, |this, scroll_handle| { - this.track_scroll(&scroll_handle) - }) - .text_ui_sm(cx) - .overflow_hidden() - .child(self.render_markdown( - chunk, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )) - }; - - v_flex() - .gap_1() - .child( - h_flex() - .id(header_id) - .group(&card_header_id) - .relative() - .w_full() - .pr_1() - .justify_between() - .child( - h_flex() - .h(window.line_height() - px(2.)) - .gap_1p5() - .overflow_hidden() - .child( - Icon::new(IconName::ToolThink) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - div() - .text_size(self.tool_name_font_size()) - .text_color(cx.theme().colors().text_muted) - .child("Thinking"), - ), - ) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&card_header_id) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } - })), - ) - .on_click(cx.listener(move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); - })), - ) - .when(is_open, |this| { - this.child( - div() - .ml_1p5() - .pl_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .child(thinking_content), - ) - }) - .into_any_element() - } - - fn render_message_context_menu( - &self, - entry_ix: usize, - message_body: AnyElement, - cx: &Context, - ) -> AnyElement { - let entity = cx.entity(); - let workspace = self.workspace.clone(); - - right_click_menu(format!("agent_context_menu-{}", entry_ix)) - .trigger(move |_, _, _| message_body) - .menu(move |window, cx| { - let focus = window.focused(cx); - let entity = entity.clone(); - let workspace = workspace.clone(); - - ContextMenu::build(window, cx, move |menu, _, cx| { - let this = entity.read(cx); - let is_at_top = this.list_state.logical_scroll_top().item_ix == 0; - - let has_selection = this - .thread - .read(cx) - .entries() - .get(entry_ix) - .and_then(|entry| match &entry { - AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), - _ => None, - }) - .map(|chunks| { - chunks.iter().any(|chunk| { - let md = match chunk { - AssistantMessageChunk::Message { block } => block.markdown(), - AssistantMessageChunk::Thought { block } => block.markdown(), - }; - md.map_or(false, |m| m.read(cx).selected_text().is_some()) - }) - }) - .unwrap_or(false); - - let copy_this_agent_response = - ContextMenuEntry::new("Copy This Agent Response").handler({ - let entity = entity.clone(); - move |_, cx| { - entity.update(cx, |this, cx| { - let entries = this.thread.read(cx).entries(); - if let Some(text) = - Self::get_agent_message_content(entries, entry_ix, cx) - { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - } - }); - } - }); - - let scroll_item = if is_at_top { - ContextMenuEntry::new("Scroll to Bottom").handler({ - let entity = entity.clone(); - move |_, cx| { - entity.update(cx, |this, cx| { - this.scroll_to_bottom(cx); - }); - } - }) - } else { - ContextMenuEntry::new("Scroll to Top").handler({ - let entity = entity.clone(); - move |_, cx| { - entity.update(cx, |this, cx| { - this.scroll_to_top(cx); - }); - } - }) - }; - - let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown") - .handler({ - let entity = entity.clone(); - let workspace = workspace.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - entity - .update(cx, |this, cx| { - this.open_thread_as_markdown(workspace, window, cx) - }) - .detach_and_log_err(cx); - } - } - }); - - menu.when_some(focus, |menu, focus| menu.context(focus)) - .action_disabled_when( - !has_selection, - "Copy Selection", - Box::new(markdown::CopyAsMarkdown), - ) - .item(copy_this_agent_response) - .separator() - .item(scroll_item) - .item(open_thread_as_markdown) - }) - }) - .into_any_element() - } - - fn get_agent_message_content( - entries: &[AgentThreadEntry], - entry_index: usize, - cx: &App, - ) -> Option { - let entry = entries.get(entry_index)?; - if matches!(entry, AgentThreadEntry::UserMessage(_)) { - return None; - } - - let start_index = (0..entry_index) - .rev() - .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) - .map(|i| i + 1) - .unwrap_or(0); - - let end_index = (entry_index + 1..entries.len()) - .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) - .map(|i| i - 1) - .unwrap_or(entries.len() - 1); - - let parts: Vec = (start_index..=end_index) - .filter_map(|i| entries.get(i)) - .filter_map(|entry| { - if let AgentThreadEntry::AssistantMessage(message) = entry { - let text: String = message - .chunks - .iter() - .filter_map(|chunk| match chunk { - AssistantMessageChunk::Message { block } => { - let markdown = block.to_markdown(cx); - if markdown.trim().is_empty() { - None - } else { - Some(markdown.to_string()) - } - } - AssistantMessageChunk::Thought { .. } => None, - }) - .collect::>() - .join("\n\n"); - - if text.is_empty() { None } else { Some(text) } - } else { - None - } - }) - .collect(); - - let text = parts.join("\n\n"); - if text.is_empty() { None } else { Some(text) } - } - - fn render_collapsible_command( - &self, - is_preview: bool, - command_source: &str, - tool_call_id: &acp::ToolCallId, - cx: &Context, - ) -> Div { - let command_group = - SharedString::from(format!("collapsible-command-group-{}", tool_call_id)); - - v_flex() - .group(command_group.clone()) - .bg(self.tool_card_header_bg(cx)) - .child( - v_flex() - .p_1p5() - .when(is_preview, |this| { - this.pt_1().child( - // Wrapping this label on a container with 24px height to avoid - // layout shift when it changes from being a preview label - // to the actual path where the command will run in - h_flex().h_6().child( - Label::new("Run Command") - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - }) - .children(command_source.lines().map(|line| { - let text: SharedString = if line.is_empty() { - " ".into() - } else { - line.to_string().into() - }; - - Label::new(text).buffer_font(cx).size(LabelSize::Small) - })) - .child( - div().absolute().top_1().right_1().child( - CopyButton::new("copy-command", command_source.to_string()) - .tooltip_label("Copy Command") - .visible_on_hover(command_group), - ), - ), - ) - } - - fn render_terminal_tool_call( - &self, - active_session_id: &acp::SessionId, - entry_ix: usize, - terminal: &Entity, - tool_call: &ToolCall, - focus_handle: &FocusHandle, - window: &Window, - cx: &Context, - ) -> AnyElement { - let terminal_data = terminal.read(cx); - let working_dir = terminal_data.working_dir(); - let command = terminal_data.command(); - let started_at = terminal_data.started_at(); - - let tool_failed = matches!( - &tool_call.status, - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed - ); - - let confirmation_options = match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => Some(options), - _ => None, - }; - let needs_confirmation = confirmation_options.is_some(); - - let output = terminal_data.output(); - let command_finished = output.is_some(); - let truncated_output = - output.is_some_and(|output| output.original_content_len > output.content.len()); - let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); - - let command_failed = command_finished - && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success())); - - let time_elapsed = if let Some(output) = output { - output.ended_at.duration_since(started_at) - } else { - started_at.elapsed() - }; - - let header_id = - SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); - let header_group = SharedString::from(format!( - "terminal-tool-header-group-{}", - terminal.entity_id() - )); - let header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - let border_color = cx.theme().colors().border.opacity(0.6); - - let working_dir = working_dir - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "current directory".to_string()); - - // Since the command's source is wrapped in a markdown code block - // (```\n...\n```), we need to strip that so we're left with only the - // command's content. - let command_source = command.read(cx).source(); - let command_content = command_source - .strip_prefix("```\n") - .and_then(|s| s.strip_suffix("\n```")) - .unwrap_or(&command_source); - - let command_element = - self.render_collapsible_command(false, command_content, &tool_call.id, cx); - - let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); - - let header = h_flex() - .id(header_id) - .px_1p5() - .pt_1() - .flex_none() - .gap_1() - .justify_between() - .rounded_t_md() - .child( - div() - .id(("command-target-path", terminal.entity_id())) - .w_full() - .max_w_full() - .overflow_x_scroll() - .child( - Label::new(working_dir) - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .when(!command_finished && !needs_confirmation, |header| { - header - .gap_1p5() - .child( - Button::new( - SharedString::from(format!("stop-terminal-{}", terminal.entity_id())), - "Stop", - ) - .icon(IconName::Stop) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .label_size(LabelSize::Small) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Stop This Command", - None, - "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", - cx, - ) - }) - .on_click({ - let terminal = terminal.clone(); - cx.listener(move |this, _event, _window, cx| { - terminal.update(cx, |terminal, cx| { - terminal.stop_by_user(cx); - }); - if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { - this.cancel_generation(cx); - } - }) - }), - ) - .child(Divider::vertical()) - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_rotate_animation(2) - ) - }) - .when(truncated_output, |header| { - let tooltip = if let Some(output) = output { - if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { - format!("Output exceeded terminal max lines and was \ - truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true)) - } else { - format!( - "Output is {} long, and to avoid unexpected token usage, \ - only {} was sent back to the agent.", - format_file_size(output.original_content_len as u64, true), - format_file_size(output.content.len() as u64, true) - ) - } - } else { - "Output was truncated".to_string() - }; - - header.child( - h_flex() - .id(("terminal-tool-truncated-label", terminal.entity_id())) - .gap_1() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Ignored), - ) - .child( - Label::new("Truncated") - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .tooltip(Tooltip::text(tooltip)), - ) - }) - .when(time_elapsed > Duration::from_secs(10), |header| { - header.child( - Label::new(format!("({})", duration_alt_display(time_elapsed))) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", terminal.entity_id())) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when_some(output.and_then(|o| o.exit_status), |this, status| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - status.code().unwrap_or(-1), - ))) - }), - ) - }) - .child( - Disclosure::new( - SharedString::from(format!( - "terminal-tool-disclosure-{}", - terminal.entity_id() - )), - is_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&header_group) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this, _event, _window, cx| { - if is_expanded { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })), - ); - - let terminal_view = self - .entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.terminal(terminal)); - - v_flex() - .my_1p5() - .mx_5() - .border_1() - .when(tool_failed || command_failed, |card| card.border_dashed()) - .border_color(border_color) - .rounded_md() - .overflow_hidden() - .child( - v_flex() - .group(&header_group) - .bg(header_bg) - .text_xs() - .child(header) - .child(command_element), - ) - .when(is_expanded && terminal_view.is_some(), |this| { - this.child( - div() - .pt_2() - .border_t_1() - .when(tool_failed || command_failed, |card| card.border_dashed()) - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .rounded_b_md() - .text_ui_sm(cx) - .h_full() - .children(terminal_view.map(|terminal_view| { - let element = if terminal_view - .read(cx) - .content_mode(window, cx) - .is_scrollable() - { - div().h_72().child(terminal_view).into_any_element() - } else { - terminal_view.into_any_element() - }; - - div() - .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| { - window.dispatch_action(NewThread.boxed_clone(), cx); - cx.stop_propagation(); - })) - .child(element) - .into_any_element() - })), - ) - }) - .when_some(confirmation_options, |this, options| { - let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx); - this.child(self.render_permission_buttons( - self.id.clone(), - is_first, - options, - entry_ix, - tool_call.id.clone(), - focus_handle, - cx, - )) - }) - .into_any() - } - - fn is_first_tool_call( - &self, - active_session_id: &acp::SessionId, - tool_call_id: &acp::ToolCallId, - cx: &App, - ) -> bool { - self.conversation - .read(cx) - .pending_tool_call(active_session_id, cx) - .map_or(false, |(pending_session_id, pending_tool_call_id, _)| { - self.id == pending_session_id && tool_call_id == &pending_tool_call_id - }) - } - - fn render_any_tool_call( - &self, - active_session_id: &acp::SessionId, - entry_ix: usize, - tool_call: &ToolCall, - focus_handle: &FocusHandle, - window: &Window, - cx: &Context, - ) -> Div { - let has_terminals = tool_call.terminals().next().is_some(); - - div().w_full().map(|this| { - if tool_call.is_subagent() { - this.child(self.render_subagent_tool_call( - active_session_id, - entry_ix, - tool_call, - tool_call.subagent_session_id.clone(), - focus_handle, - window, - cx, - )) - } else if has_terminals { - this.children(tool_call.terminals().map(|terminal| { - self.render_terminal_tool_call( - active_session_id, - entry_ix, - terminal, - tool_call, - focus_handle, - window, - cx, - ) - })) - } else { - this.child(self.render_tool_call( - active_session_id, - entry_ix, - tool_call, - focus_handle, - window, - cx, - )) - } - }) - } - - fn render_tool_call( - &self, - active_session_id: &acp::SessionId, - entry_ix: usize, - tool_call: &ToolCall, - focus_handle: &FocusHandle, - window: &Window, - cx: &Context, - ) -> Div { - let has_location = tool_call.locations.len() == 1; - let card_header_id = SharedString::from("inner-tool-call-header"); - - let failed_or_canceled = match &tool_call.status { - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, - _ => false, - }; - - let needs_confirmation = matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ); - let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute); - - let is_edit = - matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); - - let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled); - let has_revealed_diff = tool_call.diffs().next().is_some_and(|diff| { - self.entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.editor_for_diff(diff)) - .is_some() - && diff.read(cx).has_revealed_range(cx) - }); - - let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; - - let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); - let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; - let mut is_open = self.expanded_tool_calls.contains(&tool_call.id); - - is_open |= needs_confirmation; - - let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; - - 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 { - match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() - .w_full() - .children( - tool_call - .content - .iter() - .enumerate() - .map(|(content_ix, content)| { - div() - .child(self.render_tool_call_content( - active_session_id, - entry_ix, - content, - content_ix, - tool_call, - use_card_layout, - has_image_content, - failed_or_canceled, - focus_handle, - window, - cx, - )) - .into_any_element() - }), - ) - .when(should_show_raw_input, |this| { - let is_raw_input_expanded = - self.expanded_tool_call_raw_inputs.contains(&tool_call.id); - - let input_header = if is_raw_input_expanded { - "Raw Input:" - } else { - "View Raw Input" - }; - - this.child( - v_flex() - .p_2() - .gap_1() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .id("disclosure_container") - .pl_0p5() - .gap_1() - .justify_between() - .rounded_xs() - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child(input_output_header(input_header.into())) - .child( - Disclosure::new( - ("raw-input-disclosure", entry_ix), - is_raw_input_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - - move |this: &mut Self, _, _, cx| { - if this.expanded_tool_call_raw_inputs.contains(&id) - { - this.expanded_tool_call_raw_inputs.remove(&id); - } else { - this.expanded_tool_call_raw_inputs - .insert(id.clone()); - } - cx.notify(); - } - })), - ) - .when(is_raw_input_expanded, |this| { - this.children(tool_call.raw_input_markdown.clone().map( - |input| { - self.render_markdown( - input, - MarkdownStyle::themed( - MarkdownFont::Agent, - window, - cx, - ), - ) - }, - )) - }), - ) - }) - .child(self.render_permission_buttons( - self.id.clone(), - self.is_first_tool_call(active_session_id, &tool_call.id, cx), - options, - entry_ix, - tool_call.id.clone(), - focus_handle, - cx, - )) - .into_any(), - ToolCallStatus::Pending | ToolCallStatus::InProgress - if is_edit - && tool_call.content.is_empty() - && self.as_native_connection(cx).is_some() => - { - self.render_diff_loading(cx) - } - ToolCallStatus::Pending - | ToolCallStatus::InProgress - | ToolCallStatus::Completed - | ToolCallStatus::Failed - | ToolCallStatus::Canceled => v_flex() - .when(should_show_raw_input, |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| { - div().id(("tool-call-raw-input-markdown", entry_ix)).child( - self.render_markdown( - input, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - ), - ) - })) - .child(input_output_header("Output:".into())), - ) - }) - .children( - tool_call - .content - .iter() - .enumerate() - .map(|(content_ix, content)| { - div().id(("tool-call-output", entry_ix)).child( - self.render_tool_call_content( - active_session_id, - entry_ix, - content, - content_ix, - tool_call, - use_card_layout, - has_image_content, - failed_or_canceled, - focus_handle, - window, - cx, - ), - ) - }), - ) - .into_any(), - ToolCallStatus::Rejected => Empty.into_any(), - } - .into() - } else { - None - }; - - v_flex() - .map(|this| { - if use_card_layout { - this.my_1p5() - .rounded_md() - .border_1() - .when(failed_or_canceled, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() - } else { - this.my_1() - } - }) - .map(|this| { - if has_location && !use_card_layout { - this.ml_4() - } else { - this.ml_5() - } - }) - .mr_5() - .map(|this| { - if is_terminal_tool { - let label_source = tool_call.label.read(cx).source(); - this.child(self.render_collapsible_command(true, label_source, &tool_call.id, cx)) - } else { - this.child( - h_flex() - .group(&card_header_id) - .relative() - .w_full() - .gap_1() - .justify_between() - .when(use_card_layout, |this| { - this.p_0p5() - .rounded_t(rems_from_px(5.)) - .bg(self.tool_card_header_bg(cx)) - }) - .child(self.render_tool_call_label( - entry_ix, - tool_call, - is_edit, - is_cancelled_edit, - has_revealed_diff, - use_card_layout, - window, - cx, - )) - .when(is_collapsible || failed_or_canceled, |this| { - let diff_for_discard = - if has_revealed_diff && is_cancelled_edit && cx.has_flag::() { - tool_call.diffs().next().cloned() - } else { - None - }; - this.child( - h_flex() - .px_1() - .when_some(diff_for_discard.clone(), |this, _| this.pr_0p5()) - .gap_1() - .when(is_collapsible, |this| { - this.child( - Disclosure::new(("expand-output", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&card_header_id) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - if is_open { - this - .expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })), - ) - }) - .when(failed_or_canceled, |this| { - if is_cancelled_edit && !has_revealed_diff { - this.child( - div() - .id(entry_ix) - .tooltip(Tooltip::text( - "Interrupted Edit", - )) - .child( - Icon::new(IconName::XCircle) - .color(Color::Muted) - .size(IconSize::Small), - ), - ) - } else if is_cancelled_edit { - this - } else { - this.child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), - ) - } - }) - .when_some(diff_for_discard, |this, diff| { - let tool_call_id = tool_call.id.clone(); - let is_discarded = self.discarded_partial_edits.contains(&tool_call_id); - this.when(!is_discarded, |this| { - this.child( - IconButton::new( - ("discard-partial-edit", entry_ix), - IconName::Undo, - ) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| Tooltip::with_meta( - "Discard Interrupted Edit", - None, - "You can discard this interrupted partial edit and restore the original file content.", - cx - )) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - move |this, _, _window, cx| { - let diff_data = diff.read(cx); - let base_text = diff_data.base_text().clone(); - let buffer = diff_data.buffer().clone(); - buffer.update(cx, |buffer, cx| { - buffer.set_text(base_text.as_ref(), cx); - }); - this.discarded_partial_edits.insert(tool_call_id.clone()); - cx.notify(); - } - })), - ) - }) - }) - - ) - }), - ) - } - }) - .children(tool_output_display) - } - - fn render_permission_buttons( - &self, - session_id: acp::SessionId, - is_first: bool, - options: &PermissionOptions, - entry_ix: usize, - tool_call_id: acp::ToolCallId, - focus_handle: &FocusHandle, - cx: &Context, - ) -> Div { - match options { - PermissionOptions::Flat(options) => self.render_permission_buttons_flat( - session_id, - is_first, - options, - entry_ix, - tool_call_id, - focus_handle, - cx, - ), - PermissionOptions::Dropdown(options) => self.render_permission_buttons_dropdown( - session_id, - is_first, - options, - entry_ix, - tool_call_id, - focus_handle, - cx, - ), - } - } - - fn render_permission_buttons_dropdown( - &self, - session_id: acp::SessionId, - is_first: bool, - choices: &[PermissionOptionChoice], - entry_ix: usize, - tool_call_id: acp::ToolCallId, - focus_handle: &FocusHandle, - cx: &Context, - ) -> Div { - // Get the selected granularity index, defaulting to the last option ("Only this time") - let selected_index = self - .selected_permission_granularity - .get(&tool_call_id) - .copied() - .unwrap_or_else(|| choices.len().saturating_sub(1)); - - let selected_choice = choices.get(selected_index).or(choices.last()); - - let dropdown_label: SharedString = selected_choice - .map(|choice| choice.label()) - .unwrap_or_else(|| "Only this time".into()); - - let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) = - if let Some(choice) = selected_choice { - ( - choice.allow.option_id.clone(), - choice.allow.kind, - choice.deny.option_id.clone(), - choice.deny.kind, - ) - } else { - ( - acp::PermissionOptionId::new("allow"), - acp::PermissionOptionKind::AllowOnce, - acp::PermissionOptionId::new("deny"), - acp::PermissionOptionKind::RejectOnce, - ) - }; - - h_flex() - .w_full() - .p_1() - .gap_2() - .justify_between() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .gap_0p5() - .child( - Button::new(("allow-btn", entry_ix), "Allow") - .icon(IconName::Check) - .icon_color(Color::Success) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .when(is_first, |this| { - this.key_binding( - KeyBinding::for_action_in( - &AllowOnce as &dyn Action, - focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - }) - .on_click(cx.listener({ - let session_id = session_id.clone(); - let tool_call_id = tool_call_id.clone(); - let option_id = allow_option_id; - let option_kind = allow_option_kind; - move |this, _, window, cx| { - this.authorize_tool_call( - session_id.clone(), - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })), - ) - .child( - Button::new(("deny-btn", entry_ix), "Deny") - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .when(is_first, |this| { - this.key_binding( - KeyBinding::for_action_in( - &RejectOnce as &dyn Action, - focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - }) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - let option_id = deny_option_id; - let option_kind = deny_option_kind; - move |this, _, window, cx| { - this.authorize_tool_call( - session_id.clone(), - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })), - ), - ) - .child(self.render_permission_granularity_dropdown( - choices, - dropdown_label, - entry_ix, - tool_call_id, - selected_index, - is_first, - cx, - )) - } - - fn render_permission_granularity_dropdown( - &self, - choices: &[PermissionOptionChoice], - current_label: SharedString, - entry_ix: usize, - tool_call_id: acp::ToolCallId, - selected_index: usize, - is_first: bool, - cx: &Context, - ) -> AnyElement { - let menu_options: Vec<(usize, SharedString)> = choices - .iter() - .enumerate() - .map(|(i, choice)| (i, choice.label())) - .collect(); - - let permission_dropdown_handle = self.permission_dropdown_handle.clone(); - - PopoverMenu::new(("permission-granularity", entry_ix)) - .with_handle(permission_dropdown_handle) - .trigger( - Button::new(("granularity-trigger", entry_ix), current_label) - .icon(IconName::ChevronDown) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .label_size(LabelSize::Small) - .when(is_first, |this| { - this.key_binding( - KeyBinding::for_action_in( - &crate::OpenPermissionDropdown as &dyn Action, - &self.focus_handle(cx), - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - }), - ) - .menu(move |window, cx| { - let tool_call_id = tool_call_id.clone(); - let options = menu_options.clone(); - - Some(ContextMenu::build(window, cx, move |mut menu, _, _| { - for (index, display_name) in options.iter() { - let display_name = display_name.clone(); - let index = *index; - let tool_call_id_for_entry = tool_call_id.clone(); - let is_selected = index == selected_index; - - menu = menu.toggleable_entry( - display_name, - is_selected, - IconPosition::End, - None, - move |window, cx| { - window.dispatch_action( - SelectPermissionGranularity { - tool_call_id: tool_call_id_for_entry.0.to_string(), - index, - } - .boxed_clone(), - cx, - ); - }, - ); - } - - menu - })) - }) - .into_any_element() - } - - fn render_permission_buttons_flat( - &self, - session_id: acp::SessionId, - is_first: bool, - options: &[acp::PermissionOption], - entry_ix: usize, - tool_call_id: acp::ToolCallId, - focus_handle: &FocusHandle, - cx: &Context, - ) -> Div { - let mut seen_kinds: ArrayVec = ArrayVec::new(); - - div() - .p_1() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .w_full() - .v_flex() - .gap_0p5() - .children(options.iter().map(move |option| { - 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 { - acp::PermissionOptionKind::AllowOnce => ( - this.icon(IconName::Check).icon_color(Color::Success), - Some(&AllowOnce as &dyn Action), - ), - acp::PermissionOptionKind::AllowAlways => ( - this.icon(IconName::CheckDouble).icon_color(Color::Success), - Some(&AllowAlways as &dyn Action), - ), - acp::PermissionOptionKind::RejectOnce => ( - this.icon(IconName::Close).icon_color(Color::Error), - Some(&RejectOnce as &dyn Action), - ), - acp::PermissionOptionKind::RejectAlways | _ => { - (this.icon(IconName::Close).icon_color(Color::Error), None) - } - }; - - let Some(action) = action else { - return this; - }; - - if !is_first || seen_kinds.contains(&option.kind) { - return this; - } - - seen_kinds.push(option.kind); - - this.key_binding( - KeyBinding::for_action_in(action, focus_handle, cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) - }) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let session_id = session_id.clone(); - let tool_call_id = tool_call_id.clone(); - let option_id = option.option_id.clone(); - let option_kind = option.kind; - move |this, _, window, cx| { - this.authorize_tool_call( - session_id.clone(), - tool_call_id.clone(), - option_id.clone(), - option_kind, - window, - cx, - ); - } - })) - })) - } - - fn render_diff_loading(&self, cx: &Context) -> AnyElement { - let bar = |n: u64, width_class: &str| { - let bg_color = cx.theme().colors().element_active; - let base = h_flex().h_1().rounded_full(); - - let modified = match width_class { - "w_4_5" => base.w_3_4(), - "w_1_4" => base.w_1_4(), - "w_2_4" => base.w_2_4(), - "w_3_5" => base.w_3_5(), - "w_2_5" => base.w_2_5(), - _ => base.w_1_2(), - }; - - modified.with_animation( - ElementId::Integer(n), - Animation::new(Duration::from_secs(2)).repeat(), - move |tab, delta| { - let delta = (delta - 0.15 * n as f32) / 0.7; - let delta = 1.0 - (0.5 - delta).abs() * 2.; - let delta = ease_in_out(delta.clamp(0., 1.)); - let delta = 0.1 + 0.9 * delta; - - tab.bg(bg_color.opacity(delta)) - }, - ) - }; - - v_flex() - .p_3() - .gap_1() - .rounded_b_md() - .bg(cx.theme().colors().editor_background) - .child(bar(0, "w_4_5")) - .child(bar(1, "w_1_4")) - .child(bar(2, "w_2_4")) - .child(bar(3, "w_3_5")) - .child(bar(4, "w_2_5")) - .into_any_element() - } - - fn render_tool_call_label( - &self, - entry_ix: usize, - tool_call: &ToolCall, - is_edit: bool, - has_failed: bool, - has_revealed_diff: bool, - use_card_layout: bool, - window: &Window, - cx: &Context, - ) -> Div { - let has_location = tool_call.locations.len() == 1; - let is_file = tool_call.kind == acp::ToolKind::Edit && has_location; - let is_subagent_tool_call = tool_call.is_subagent(); - - let file_icon = if has_location { - FileIcons::get_icon(&tool_call.locations[0].path, cx) - .map(Icon::from_path) - .unwrap_or(Icon::new(IconName::ToolPencil)) - } else { - Icon::new(IconName::ToolPencil) - }; - - let tool_icon = if is_file && has_failed && has_revealed_diff { - div() - .id(entry_ix) - .tooltip(Tooltip::text("Interrupted Edit")) - .child(DecoratedIcon::new( - file_icon, - Some( - IconDecoration::new( - IconDecorationKind::Triangle, - self.tool_card_header_bg(cx), - cx, - ) - .color(cx.theme().status().warning) - .position(gpui::Point { - x: px(-2.), - y: px(-2.), - }), - ), - )) - .into_any_element() - } else if is_file { - div().child(file_icon).into_any_element() - } else if is_subagent_tool_call { - Icon::new(self.agent_icon) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element() - } else { - Icon::new(match tool_call.kind { - acp::ToolKind::Read => IconName::ToolSearch, - acp::ToolKind::Edit => IconName::ToolPencil, - acp::ToolKind::Delete => IconName::ToolDeleteFile, - acp::ToolKind::Move => IconName::ArrowRightLeft, - acp::ToolKind::Search => IconName::ToolSearch, - acp::ToolKind::Execute => IconName::ToolTerminal, - acp::ToolKind::Think => IconName::ToolThink, - acp::ToolKind::Fetch => IconName::ToolWeb, - acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, - acp::ToolKind::Other | _ => IconName::ToolHammer, - }) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element() - }; - - let gradient_overlay = { - div() - .absolute() - .top_0() - .right_0() - .w_12() - .h_full() - .map(|this| { - if use_card_layout { - this.bg(linear_gradient( - 90., - linear_color_stop(self.tool_card_header_bg(cx), 1.), - linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.), - )) - } else { - this.bg(linear_gradient( - 90., - linear_color_stop(cx.theme().colors().panel_background, 1.), - linear_color_stop( - cx.theme().colors().panel_background.opacity(0.2), - 0., - ), - )) - } - }) - }; - - h_flex() - .relative() - .w_full() - .h(window.line_height() - px(2.)) - .text_size(self.tool_name_font_size()) - .gap_1p5() - .when(has_location || use_card_layout, |this| this.px_1()) - .when(has_location, |this| { - this.cursor(CursorStyle::PointingHand) - .rounded(rems_from_px(3.)) // Concentric border radius - .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) - }) - .overflow_hidden() - .child(tool_icon) - .child(if has_location { - h_flex() - .id(("open-tool-call-location", entry_ix)) - .w_full() - .map(|this| { - if use_card_layout { - this.text_color(cx.theme().colors().text) - } else { - this.text_color(cx.theme().colors().text_muted) - } - }) - .child( - self.render_markdown( - tool_call.label.clone(), - MarkdownStyle { - prevent_mouse_interaction: true, - ..MarkdownStyle::themed(MarkdownFont::Agent, window, cx) - .with_muted_text(cx) - }, - ), - ) - .tooltip(Tooltip::text("Go to File")) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tool_call_location(entry_ix, 0, window, cx); - })) - .into_any_element() - } else { - h_flex() - .w_full() - .child(self.render_markdown( - tool_call.label.clone(), - MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx), - )) - .into_any() - }) - .when(!is_edit, |this| this.child(gradient_overlay)) - } - - fn open_tool_call_location( - &self, - entry_ix: usize, - location_ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let (tool_call_location, agent_location) = self - .thread - .read(cx) - .entries() - .get(entry_ix)? - .location(location_ix)?; - - let project_path = self - .project - .upgrade()? - .read(cx) - .find_project_path(&tool_call_location.path, cx)?; - - let open_task = self - .workspace - .update(cx, |workspace, cx| { - workspace.open_path(project_path, None, true, window, cx) - }) - .log_err()?; - window - .spawn(cx, async move |cx| { - let item = open_task.await?; - - let Some(active_editor) = item.downcast::() else { - return anyhow::Ok(()); - }; - - active_editor.update_in(cx, |editor, window, cx| { - let singleton = editor - .buffer() - .read(cx) - .read(cx) - .as_singleton() - .map(|(a, b, _)| (a, b)); - if let Some((excerpt_id, buffer_id)) = singleton - && let Some(agent_buffer) = agent_location.buffer.upgrade() - && agent_buffer.read(cx).remote_id() == buffer_id - { - let anchor = editor::Anchor::in_buffer(excerpt_id, agent_location.position); - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_anchor_ranges([anchor..anchor]); - }) - } else { - let row = tool_call_location.line.unwrap_or_default(); - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]); - }) - } - })?; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - - None - } - - fn render_tool_call_content( - &self, - session_id: &acp::SessionId, - entry_ix: usize, - content: &ToolCallContent, - context_ix: usize, - tool_call: &ToolCall, - card_layout: bool, - is_image_tool_call: bool, - has_failed: bool, - focus_handle: &FocusHandle, - window: &Window, - cx: &Context, - ) -> AnyElement { - match content { - ToolCallContent::ContentBlock(content) => { - if let Some(resource_link) = content.resource_link() { - self.render_resource_link(resource_link, cx) - } else if let Some(markdown) = content.markdown() { - self.render_markdown_output( - markdown.clone(), - tool_call.id.clone(), - context_ix, - card_layout, - window, - cx, - ) - } else if let Some(image) = content.image() { - let location = tool_call.locations.first().cloned(); - self.render_image_output( - entry_ix, - image.clone(), - location, - card_layout, - is_image_tool_call, - cx, - ) - } else { - Empty.into_any_element() - } - } - ToolCallContent::Diff(diff) => { - self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx) - } - ToolCallContent::Terminal(terminal) => self.render_terminal_tool_call( - session_id, - entry_ix, - terminal, - tool_call, - focus_handle, - window, - cx, - ), - } - } - - fn render_resource_link( - &self, - resource_link: &acp::ResourceLink, - cx: &Context, - ) -> AnyElement { - let uri: SharedString = resource_link.uri.clone().into(); - let is_file = resource_link.uri.strip_prefix("file://"); - - let Some(project) = self.project.upgrade() else { - return Empty.into_any_element(); - }; - - let label: SharedString = if let Some(abs_path) = is_file { - if let Some(project_path) = project - .read(cx) - .project_path_for_absolute_path(&Path::new(abs_path), cx) - && let Some(worktree) = project - .read(cx) - .worktree_for_id(project_path.worktree_id, cx) - { - worktree - .read(cx) - .full_path(&project_path.path) - .to_string_lossy() - .to_string() - .into() - } else { - abs_path.to_string().into() - } - } else { - uri.clone() - }; - - let button_id = SharedString::from(format!("item-{}", uri)); - - div() - .ml(rems(0.4)) - .pl_2p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .overflow_hidden() - .child( - Button::new(button_id, label) - .label_size(LabelSize::Small) - .color(Color::Muted) - .truncate(true) - .when(is_file.is_none(), |this| { - this.icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - }) - .on_click(cx.listener({ - let workspace = self.workspace.clone(); - move |_, _, window, cx: &mut Context| { - open_link(uri.clone(), &workspace, window, cx); - } - })), - ) - .into_any_element() - } - - fn render_diff_editor( - &self, - entry_ix: usize, - diff: &Entity, - tool_call: &ToolCall, - has_failed: bool, - cx: &Context, - ) -> AnyElement { - let tool_progress = matches!( - &tool_call.status, - ToolCallStatus::InProgress | ToolCallStatus::Pending - ); - - let revealed_diff_editor = if let Some(entry) = - self.entry_view_state.read(cx).entry(entry_ix) - && let Some(editor) = entry.editor_for_diff(diff) - && diff.read(cx).has_revealed_range(cx) - { - Some(editor) - } else { - None - }; - - let show_top_border = !has_failed || revealed_diff_editor.is_some(); - - v_flex() - .h_full() - .when(show_top_border, |this| { - this.border_t_1() - .when(has_failed, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - }) - .child(if let Some(editor) = revealed_diff_editor { - editor.into_any_element() - } else if tool_progress && self.as_native_connection(cx).is_some() { - self.render_diff_loading(cx) - } else { - Empty.into_any() - }) - .into_any() - } - - fn render_markdown_output( - &self, - markdown: Entity, - tool_call_id: acp::ToolCallId, - context_ix: usize, - card_layout: bool, - window: &Window, - cx: &Context, - ) -> AnyElement { - let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); - - v_flex() - .gap_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) - .child(self.render_markdown( - markdown, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )) - .when(!card_layout, |this| { - this.child( - IconButton::new(button_id, IconName::ChevronUp) - .full_width() - .style(ButtonStyle::Outlined) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |this: &mut Self, _, _, cx: &mut Context| { - this.expanded_tool_calls.remove(&tool_call_id); - cx.notify(); - } - })), - ) - }) - .into_any_element() - } - - fn render_image_output( - &self, - entry_ix: usize, - image: Arc, - location: Option, - card_layout: bool, - show_dimensions: bool, - cx: &Context, - ) -> AnyElement { - let dimensions_label = if show_dimensions { - let format_name = match image.format() { - gpui::ImageFormat::Png => "PNG", - gpui::ImageFormat::Jpeg => "JPEG", - gpui::ImageFormat::Webp => "WebP", - gpui::ImageFormat::Gif => "GIF", - gpui::ImageFormat::Svg => "SVG", - gpui::ImageFormat::Bmp => "BMP", - gpui::ImageFormat::Tiff => "TIFF", - gpui::ImageFormat::Ico => "ICO", - }; - let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes())) - .with_guessed_format() - .ok() - .and_then(|reader| reader.into_dimensions().ok()); - dimensions.map(|(w, h)| format!("{}×{} {}", w, h, format_name)) - } else { - None - }; - - v_flex() - .gap_2() - .map(|this| { - if card_layout { - this - } else { - this.ml(rems(0.4)) - .px_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - } - }) - .when(dimensions_label.is_some() || location.is_some(), |this| { - this.child( - h_flex() - .w_full() - .justify_between() - .items_center() - .children(dimensions_label.map(|label| { - Label::new(label) - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx) - })) - .when_some(location, |this, _loc| { - this.child( - Button::new(("go-to-file", entry_ix), "Go to File") - .label_size(LabelSize::Small) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tool_call_location(entry_ix, 0, window, cx); - })), - ) - }), - ) - }) - .child( - img(image) - .max_w_96() - .max_h_96() - .object_fit(ObjectFit::ScaleDown), - ) - .into_any_element() - } - - fn render_subagent_tool_call( - &self, - active_session_id: &acp::SessionId, - entry_ix: usize, - tool_call: &ToolCall, - subagent_session_id: Option, - focus_handle: &FocusHandle, - window: &Window, - cx: &Context, - ) -> Div { - let subagent_thread_view = subagent_session_id.and_then(|id| { - self.server_view - .upgrade() - .and_then(|server_view| server_view.read(cx).as_connected()) - .and_then(|connected| connected.threads.get(&id)) - }); - - let content = self.render_subagent_card( - active_session_id, - entry_ix, - subagent_thread_view, - tool_call, - focus_handle, - window, - cx, - ); - - v_flex().mx_5().my_1p5().gap_3().child(content) - } - - fn render_subagent_card( - &self, - active_session_id: &acp::SessionId, - entry_ix: usize, - thread_view: Option<&Entity>, - tool_call: &ToolCall, - focus_handle: &FocusHandle, - window: &Window, - cx: &Context, - ) -> AnyElement { - let thread = thread_view - .as_ref() - .map(|view| view.read(cx).thread.clone()); - let subagent_session_id = thread - .as_ref() - .map(|thread| thread.read(cx).session_id().clone()); - let action_log = thread.as_ref().map(|thread| thread.read(cx).action_log()); - let changed_buffers = action_log - .map(|log| log.read(cx).changed_buffers(cx)) - .unwrap_or_default(); - - let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); - let files_changed = changed_buffers.len(); - let diff_stats = DiffStats::all_files(&changed_buffers, cx); - - let is_running = matches!( - tool_call.status, - ToolCallStatus::Pending | ToolCallStatus::InProgress - ); - let is_canceled_or_failed = matches!( - tool_call.status, - ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected - ); - - let has_title = thread - .as_ref() - .is_some_and(|t| !t.read(cx).title().is_empty()); - let has_no_title_or_canceled = !has_title || is_canceled_or_failed; - - let title = thread - .as_ref() - .map(|t| t.read(cx).title()) - .unwrap_or_else(|| { - if is_canceled_or_failed { - "Subagent Canceled" - } else { - "Spawning Subagent…" - } - .into() - }); - - let card_header_id = format!("subagent-header-{}", entry_ix); - let diff_stat_id = format!("subagent-diff-{}", entry_ix); - - let icon = h_flex().w_4().justify_center().child(if is_running { - SpinnerLabel::new() - .size(LabelSize::Small) - .into_any_element() - } else if is_canceled_or_failed { - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error) - .into_any_element() - } else { - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success) - .into_any_element() - }); - - let has_expandable_content = thread - .as_ref() - .map_or(false, |thread| !thread.read(cx).entries().is_empty()); - - let tooltip_meta_description = if is_expanded { - "Click to Collapse" - } else { - "Click to Preview" - }; - - v_flex() - .w_full() - .rounded_md() - .border_1() - .when(has_no_title_or_canceled, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - .overflow_hidden() - .child( - h_flex() - .group(&card_header_id) - .h_8() - .p_1() - .w_full() - .justify_between() - .when(!has_no_title_or_canceled, |this| { - this.bg(self.tool_card_header_bg(cx)) - }) - .child( - h_flex() - .id(format!("subagent-title-{}", entry_ix)) - .px_1() - .min_w_0() - .size_full() - .gap_2() - .justify_between() - .rounded_sm() - .overflow_hidden() - .child( - h_flex() - .min_w_0() - .w_full() - .gap_1p5() - .child(icon) - .child( - Label::new(title.to_string()) - .size(LabelSize::Custom(self.tool_name_font_size())) - .truncate(), - ) - .when(files_changed > 0, |this| { - this.child( - Label::new(format!( - "— {} {} changed", - files_changed, - if files_changed == 1 { "file" } else { "files" } - )) - .size(LabelSize::Custom(self.tool_name_font_size())) - .color(Color::Muted), - ) - .child( - DiffStat::new( - diff_stat_id.clone(), - diff_stats.lines_added as usize, - diff_stats.lines_removed as usize, - ) - .label_size(LabelSize::Custom( - self.tool_name_font_size(), - )), - ) - }), - ) - .when(!has_no_title_or_canceled, |this| { - this.tooltip(move |_, cx| { - Tooltip::with_meta( - title.to_string(), - None, - tooltip_meta_description, - cx, - ) - }) - }) - .when(has_expandable_content, |this| { - this.cursor_pointer() - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child( - div().visible_on_hover(card_header_id).child( - Icon::new(if is_expanded { - IconName::ChevronUp - } else { - IconName::ChevronDown - }) - .color(Color::Muted) - .size(IconSize::Small), - ), - ) - .on_click(cx.listener({ - let tool_call_id = tool_call.id.clone(); - move |this, _, _, cx| { - if this.expanded_tool_calls.contains(&tool_call_id) { - this.expanded_tool_calls.remove(&tool_call_id); - } else { - this.expanded_tool_calls - .insert(tool_call_id.clone()); - } - cx.notify(); - } - })) - }), - ) - .when(is_running && subagent_session_id.is_some(), |buttons| { - buttons.child( - IconButton::new(format!("stop-subagent-{}", entry_ix), IconName::Stop) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .tooltip(Tooltip::text("Stop Subagent")) - .when_some( - thread_view - .as_ref() - .map(|view| view.read(cx).thread.clone()), - |this, thread| { - this.on_click(cx.listener( - move |_this, _event, _window, cx| { - thread.update(cx, |thread, cx| { - thread.cancel(cx).detach(); - }); - }, - )) - }, - ), - ) - }), - ) - .when_some(thread_view, |this, thread_view| { - let thread = &thread_view.read(cx).thread; - let pending_tool_call = self - .conversation - .read(cx) - .pending_tool_call(thread.read(cx).session_id(), cx); - - if let Some((_, subagent_tool_call_id, _)) = pending_tool_call { - if let Some((entry_ix, tool_call)) = - thread.read(cx).tool_call(&subagent_tool_call_id) - { - this.child(thread_view.read(cx).render_any_tool_call( - active_session_id, - entry_ix, - tool_call, - focus_handle, - window, - cx, - )) - } else { - this - } - } else { - let session_id = thread.read(cx).session_id().clone(); - this.when(is_expanded, |this| { - this.child(self.render_subagent_expanded_content( - active_session_id, - entry_ix, - thread_view, - is_running, - tool_call, - focus_handle, - window, - cx, - )) - .child( - h_flex() - .id(entry_ix) - .py_1() - .w_full() - .justify_center() - .border_t_1() - .when(is_canceled_or_failed, |this| this.border_dashed()) - .border_color(cx.theme().colors().border_variant) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child( - Icon::new(IconName::Maximize) - .color(Color::Muted) - .size(IconSize::Small), - ) - .tooltip(Tooltip::text("Make Subagent Full Screen")) - .on_click(cx.listener(move |this, _event, window, cx| { - this.server_view - .update(cx, |this, cx| { - this.navigate_to_session( - session_id.clone(), - window, - cx, - ); - }) - .ok(); - })), - ) - }) - } - }) - .into_any_element() - } - - fn render_subagent_expanded_content( - &self, - active_session_id: &acp::SessionId, - entry_ix: usize, - thread_view: &Entity, - is_running: bool, - tool_call: &ToolCall, - focus_handle: &FocusHandle, - window: &Window, - cx: &Context, - ) -> impl IntoElement { - const MAX_PREVIEW_ENTRIES: usize = 8; - - let subagent_view = thread_view.read(cx); - let session_id = subagent_view.thread.read(cx).session_id().clone(); - - let base_container = || { - div() - .id(format!("subagent-content-{}", session_id)) - .relative() - .w_full() - .h_56() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .overflow_hidden() - }; - - let editor_bg = cx.theme().colors().editor_background; - let overlay = || { - div() - .absolute() - .inset_0() - .size_full() - .bg(linear_gradient( - 180., - linear_color_stop(editor_bg, 0.), - linear_color_stop(editor_bg.opacity(0.), 0.1), - )) - .block_mouse_except_scroll() - }; - - let show_thread_entries = is_running || tool_call.content.is_empty(); - - if show_thread_entries { - let scroll_handle = self - .subagent_scroll_handles - .borrow_mut() - .entry(session_id.clone()) - .or_default() - .clone(); - if is_running { - scroll_handle.scroll_to_bottom(); - } - - let entries = subagent_view.thread.read(cx).entries(); - let total_entries = entries.len(); - let start_ix = total_entries.saturating_sub(MAX_PREVIEW_ENTRIES); - - let rendered_entries: Vec = entries[start_ix..] - .iter() - .enumerate() - .map(|(i, entry)| { - let actual_ix = start_ix + i; - subagent_view.render_entry(actual_ix, total_entries + 1, entry, window, cx) - }) - .collect(); - - base_container() - .child( - div() - .id(format!("subagent-entries-{}", session_id)) - .size_full() - .track_scroll(&scroll_handle) - .pb_1() - .children(rendered_entries), - ) - .child(overlay()) - .into_any_element() - } else { - base_container() - .child( - v_flex() - .id(format!("subagent-done-content-{}", session_id)) - .size_full() - .justify_end() - .children(tool_call.content.iter().enumerate().map( - |(content_ix, content)| { - div().p_2().child(self.render_tool_call_content( - active_session_id, - entry_ix, - content, - content_ix, - tool_call, - true, - false, - matches!( - tool_call.status, - ToolCallStatus::Failed - | ToolCallStatus::Rejected - | ToolCallStatus::Canceled - ), - focus_handle, - window, - cx, - )) - }, - )), - ) - .child(overlay()) - .into_any_element() - } - } - - fn render_rules_item(&self, cx: &Context) -> Option { - let project_context = self - .as_native_thread(cx)? - .read(cx) - .project_context() - .read(cx); - - let user_rules_text = if project_context.user_rules.is_empty() { - None - } else if project_context.user_rules.len() == 1 { - let user_rules = &project_context.user_rules[0]; - - match user_rules.title.as_ref() { - Some(title) => Some(format!("Using \"{title}\" user rule")), - None => Some("Using user rule".into()), - } - } else { - Some(format!( - "Using {} user rules", - project_context.user_rules.len() - )) - }; - - let first_user_rules_id = project_context - .user_rules - .first() - .map(|user_rules| user_rules.uuid.0); - - let rules_files = project_context - .worktrees - .iter() - .filter_map(|worktree| worktree.rules_file.as_ref()) - .collect::>(); - - let rules_file_text = match rules_files.as_slice() { - &[] => None, - &[rules_file] => Some(format!( - "Using project {:?} file", - rules_file.path_in_worktree - )), - rules_files => Some(format!("Using {} project rules files", rules_files.len())), - }; - - if user_rules_text.is_none() && rules_file_text.is_none() { - return None; - } - - let has_both = user_rules_text.is_some() && rules_file_text.is_some(); - - Some( - h_flex() - .px_2p5() - .child( - Icon::new(IconName::Attach) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .when_some(user_rules_text, |parent, user_rules_text| { - parent.child( - h_flex() - .id("user-rules") - .ml_1() - .mr_1p5() - .child( - Label::new(user_rules_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate(), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View User Rules")) - .on_click(move |_event, window, cx| { - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: first_user_rules_id, - }), - cx, - ) - }), - ) - }) - .when(has_both, |this| { - this.child( - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Disabled), - ) - }) - .when_some(rules_file_text, |parent, rules_file_text| { - parent.child( - h_flex() - .id("project-rules") - .ml_1p5() - .child( - Label::new(rules_file_text) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View Project Rules")) - .on_click(cx.listener(Self::handle_open_rules)), - ) - }) - .into_any(), - ) - } - - fn tool_card_header_bg(&self, cx: &Context) -> Hsla { - cx.theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)) - } - - fn tool_card_border_color(&self, cx: &Context) -> Hsla { - cx.theme().colors().border.opacity(0.8) - } - - fn tool_name_font_size(&self) -> Rems { - rems_from_px(13.) - } - - pub(crate) fn render_thread_error( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option
{ - let content = match self.thread_error.as_ref()? { - ThreadError::Other { message, .. } => { - self.render_any_thread_error(message.clone(), window, cx) - } - ThreadError::Refusal => self.render_refusal_error(cx), - ThreadError::AuthenticationRequired(error) => { - self.render_authentication_required_error(error.clone(), cx) - } - ThreadError::PaymentRequired => self.render_payment_required_error(cx), - }; - - Some(div().child(content)) - } - - fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout { - let model_or_agent_name = self.current_model_name(cx); - let refusal_message = format!( - "{} refused to respond to this prompt. \ - This can happen when a model believes the prompt violates its content policy \ - or safety guidelines, so rephrasing it can sometimes address the issue.", - model_or_agent_name - ); - - Callout::new() - .severity(Severity::Error) - .title("Request Refused") - .icon(IconName::XCircle) - .description(refusal_message.clone()) - .actions_slot(self.create_copy_button(&refusal_message)) - .dismiss_action(self.dismiss_error_button(cx)) - } - - fn render_authentication_required_error( - &self, - error: SharedString, - cx: &mut Context, - ) -> Callout { - Callout::new() - .severity(Severity::Error) - .title("Authentication Required") - .icon(IconName::XCircle) - .description(error.clone()) - .actions_slot( - h_flex() - .gap_0p5() - .child(self.authenticate_button(cx)) - .child(self.create_copy_button(error)), - ) - .dismiss_action(self.dismiss_error_button(cx)) - } - - fn render_payment_required_error(&self, cx: &mut Context) -> Callout { - const ERROR_MESSAGE: &str = - "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; - - Callout::new() - .severity(Severity::Error) - .icon(IconName::XCircle) - .title("Free Usage Exceeded") - .description(ERROR_MESSAGE) - .actions_slot( - h_flex() - .gap_0p5() - .child(self.upgrade_button(cx)) - .child(self.create_copy_button(ERROR_MESSAGE)), - ) - .dismiss_action(self.dismiss_error_button(cx)) - } - - fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { - Button::new("upgrade", "Upgrade") - .label_size(LabelSize::Small) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.clear_thread_error(cx); - cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)); - } - })) - } - - fn authenticate_button(&self, cx: &mut Context) -> impl IntoElement { - Button::new("authenticate", "Authenticate") - .label_size(LabelSize::Small) - .style(ButtonStyle::Filled) - .on_click(cx.listener({ - move |this, _, window, cx| { - let server_view = this.server_view.clone(); - let agent_name = this.agent_name.clone(); - - this.clear_thread_error(cx); - if let Some(message) = this.in_flight_prompt.take() { - this.message_editor.update(cx, |editor, cx| { - editor.set_message(message, window, cx); - }); - } - let connection = this.thread.read(cx).connection().clone(); - window.defer(cx, |window, cx| { - AcpServerView::handle_auth_required( - server_view, - AuthRequired::new(), - agent_name, - connection, - window, - cx, - ); - }) - } - })) - } - - fn current_model_name(&self, cx: &App) -> SharedString { - // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet") - // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI") - // This provides better clarity about what refused the request - if self.as_native_connection(cx).is_some() { - self.model_selector - .clone() - .and_then(|selector| selector.read(cx).active_model(cx)) - .map(|model| model.name.clone()) - .unwrap_or_else(|| SharedString::from("The model")) - } else { - // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") - self.agent_name.clone() - } - } - - fn render_any_thread_error( - &mut self, - error: SharedString, - window: &mut Window, - cx: &mut Context<'_, Self>, - ) -> Callout { - let can_resume = self.thread.read(cx).can_retry(cx); - - let markdown = if let Some(markdown) = &self.thread_error_markdown { - markdown.clone() - } else { - let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx)); - self.thread_error_markdown = Some(markdown.clone()); - markdown - }; - - let markdown_style = - MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx); - let description = self - .render_markdown(markdown, markdown_style) - .into_any_element(); - - Callout::new() - .severity(Severity::Error) - .icon(IconName::XCircle) - .title("An Error Happened") - .description_slot(description) - .actions_slot( - h_flex() - .gap_0p5() - .when(can_resume, |this| { - this.child( - IconButton::new("retry", IconName::RotateCw) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Retry Generation")) - .on_click(cx.listener(|this, _, _window, cx| { - this.retry_generation(cx); - })), - ) - }) - .child(self.create_copy_button(error.to_string())), - ) - .dismiss_action(self.dismiss_error_button(cx)) - } - - fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { - let workspace = self.workspace.clone(); - MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { - open_link(text, &workspace, window, cx); - }) - } - - fn create_copy_button(&self, message: impl Into) -> impl IntoElement { - let message = message.into(); - - CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message") - } - - fn dismiss_error_button(&self, cx: &mut Context) -> impl IntoElement { - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.clear_thread_error(cx); - cx.notify(); - } - })) - } - - fn render_resume_notice(_cx: &Context) -> AnyElement { - let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off."; - - div() - .px_2() - .pt_2() - .pb_3() - .w_full() - .child( - Callout::new() - .severity(Severity::Info) - .icon(IconName::Info) - .title("Resumed Session") - .description(description), - ) - .into_any_element() - } - - fn update_recent_history_from_cache( - &mut self, - history: &Entity, - cx: &mut Context, - ) { - self.recent_history_entries = history.read(cx).get_recent_sessions(3); - self.hovered_recent_history_item = None; - cx.notify(); - } - - fn render_empty_state_section_header( - &self, - label: impl Into, - action_slot: Option, - cx: &mut Context, - ) -> impl IntoElement { - div().pl_1().pr_1p5().child( - h_flex() - .mt_2() - .pl_1p5() - .pb_1() - .w_full() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new(label.into()) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .children(action_slot), - ) - } - - fn render_recent_history(&self, cx: &mut Context) -> AnyElement { - let render_history = !self.recent_history_entries.is_empty(); - - v_flex() - .size_full() - .when(render_history, |this| { - let recent_history = self.recent_history_entries.clone(); - this.justify_end().child( - v_flex() - .child( - self.render_empty_state_section_header( - "Recent", - Some( - Button::new("view-history", "View All") - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &OpenHistory, - &self.focus_handle(cx), - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(move |_event, window, cx| { - window.dispatch_action(OpenHistory.boxed_clone(), cx); - }) - .into_any_element(), - ), - cx, - ), - ) - .child(v_flex().p_1().pr_1p5().gap_1().children({ - let supports_delete = self.history.read(cx).supports_delete(); - recent_history - .into_iter() - .enumerate() - .map(move |(index, entry)| { - // TODO: Add keyboard navigation. - let is_hovered = - self.hovered_recent_history_item == Some(index); - crate::acp::thread_history::AcpHistoryEntryElement::new( - entry, - self.server_view.clone(), - ) - .hovered(is_hovered) - .supports_delete(supports_delete) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_recent_history_item = Some(index); - } else if this.hovered_recent_history_item == Some(index) { - this.hovered_recent_history_item = None; - } - cx.notify(); - })) - .into_any_element() - }) - })), - ) - }) - .into_any() - } - - 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(); - } - })), - ) - } - - fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context) -> Div { - let server_view = self.server_view.clone(); - v_flex().w_full().justify_end().child( - h_flex() - .p_2() - .pr_3() - .w_full() - .gap_1p5() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().element_background) - .child( - h_flex() - .flex_1() - .gap_1p5() - .child( - Icon::new(IconName::Download) - .color(Color::Accent) - .size(IconSize::Small), - ) - .child(Label::new("New version available").size(LabelSize::Small)), - ) - .child( - Button::new("update-button", format!("Update to v{}", version)) - .label_size(LabelSize::Small) - .style(ButtonStyle::Tinted(TintColor::Accent)) - .on_click(move |_, window, cx| { - server_view - .update(cx, |view, cx| view.reset(window, cx)) - .ok(); - }), - ), - ) - } - - fn render_token_limit_callout(&self, cx: &mut Context) -> Option { - if self.token_limit_callout_dismissed { - return None; - } - - let token_usage = self.thread.read(cx).token_usage()?; - let ratio = token_usage.ratio(); - - let (severity, icon, title) = match ratio { - acp_thread::TokenUsageRatio::Normal => return None, - acp_thread::TokenUsageRatio::Warning => ( - Severity::Warning, - IconName::Warning, - "Thread reaching the token limit soon", - ), - acp_thread::TokenUsageRatio::Exceeded => ( - Severity::Error, - IconName::XCircle, - "Thread reached the token limit", - ), - }; - - let description = "To continue, start a new thread from a summary."; - - Some( - Callout::new() - .severity(severity) - .icon(icon) - .title(title) - .description(description) - .actions_slot( - h_flex().gap_0p5().child( - Button::new("start-new-thread", "Start New Thread") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - let session_id = this.thread.read(cx).session_id().clone(); - window.dispatch_action( - crate::NewNativeAgentThreadFromSummary { - from_session_id: session_id, - } - .boxed_clone(), - cx, - ); - })), - ), - ) - .dismiss_action(self.dismiss_error_button(cx)), - ) - } - - fn open_permission_dropdown( - &mut self, - _: &crate::OpenPermissionDropdown, - window: &mut Window, - cx: &mut Context, - ) { - self.permission_dropdown_handle.clone().toggle(window, cx); - } - - fn open_add_context_menu( - &mut self, - _action: &OpenAddContextMenu, - window: &mut Window, - cx: &mut Context, - ) { - let menu_handle = self.add_context_menu_handle.clone(); - window.defer(cx, move |window, cx| { - menu_handle.toggle(window, cx); - }); - } - - fn cycle_thinking_effort(&mut self, cx: &mut Context) { - let Some(thread) = self.as_native_thread(cx) else { - return; - }; - - let (effort_levels, current_effort) = { - let thread_ref = thread.read(cx); - let Some(model) = thread_ref.model() else { - return; - }; - if !model.supports_thinking() || !thread_ref.thinking_enabled() { - return; - } - let effort_levels = model.supported_effort_levels(); - if effort_levels.is_empty() { - return; - } - let current_effort = thread_ref.thinking_effort().cloned(); - (effort_levels, current_effort) - }; - - let current_index = current_effort.and_then(|current| { - effort_levels - .iter() - .position(|level| level.value == current) - }); - let next_index = match current_index { - Some(index) => (index + 1) % effort_levels.len(), - None => 0, - }; - let next_effort = effort_levels[next_index].value.to_string(); - - thread.update(cx, |thread, cx| { - thread.set_thinking_effort(Some(next_effort.clone()), cx); - - let fs = thread.project().read(cx).fs().clone(); - update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = agent.default_model.as_mut() - { - default_model.effort = Some(next_effort); - } - }); - }); - } - - fn toggle_thinking_effort_menu( - &mut self, - _action: &ToggleThinkingEffortMenu, - window: &mut Window, - cx: &mut Context, - ) { - let menu_handle = self.thinking_effort_menu_handle.clone(); - window.defer(cx, move |window, cx| { - menu_handle.toggle(window, cx); - }); - } -} - -impl Render for AcpThreadView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_messages = self.list_state.item_count() > 0; - - let conversation = v_flex().flex_1().map(|this| { - let this = this.when(self.resumed_without_history, |this| { - this.child(Self::render_resume_notice(cx)) - }); - if has_messages { - let list_state = self.list_state.clone(); - this.child(self.render_entries(cx)) - .vertical_scrollbar_for(&list_state, window, cx) - .into_any() - } else { - this.child(self.render_recent_history(cx)).into_any() - } - }); - - v_flex() - .key_context("AcpThread") - .track_focus(&self.focus_handle) - .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| { - if this.parent_id.is_none() { - this.cancel_generation(cx); - } - })) - .on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| { - if let Some(parent_session_id) = this.parent_id.clone() { - this.server_view - .update(cx, |view, cx| { - view.navigate_to_session(parent_session_id, window, cx); - }) - .ok(); - } - })) - .on_action(cx.listener(Self::keep_all)) - .on_action(cx.listener(Self::reject_all)) - .on_action(cx.listener(Self::undo_last_reject)) - .on_action(cx.listener(Self::allow_always)) - .on_action(cx.listener(Self::allow_once)) - .on_action(cx.listener(Self::reject_once)) - .on_action(cx.listener(Self::handle_authorize_tool_call)) - .on_action(cx.listener(Self::handle_select_permission_granularity)) - .on_action(cx.listener(Self::open_permission_dropdown)) - .on_action(cx.listener(Self::open_add_context_menu)) - .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| { - if let Some(thread) = this.as_native_thread(cx) { - thread.update(cx, |thread, cx| { - thread.set_thinking_enabled(!thread.thinking_enabled(), cx); - }); - } - })) - .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| { - this.cycle_thinking_effort(cx); - })) - .on_action(cx.listener(Self::toggle_thinking_effort_menu)) - .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| { - this.send_queued_message_at_index(0, true, window, cx); - })) - .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| { - this.remove_from_queue(0, cx); - cx.notify(); - })) - .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| { - if let Some(editor) = this.queued_message_editors.first() { - window.focus(&editor.focus_handle(cx), cx); - } - })) - .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| { - this.local_queued_messages.clear(); - this.sync_queue_flag_to_native_thread(cx); - this.can_fast_track_queue = false; - cx.notify(); - })) - .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { - if let Some(config_options_view) = this.config_options_view.clone() { - let handled = config_options_view.update(cx, |view, cx| { - view.toggle_category_picker( - acp::SessionConfigOptionCategory::Mode, - window, - cx, - ) - }); - if handled { - return; - } - } - - if let Some(profile_selector) = this.profile_selector.clone() { - profile_selector.read(cx).menu_handle().toggle(window, cx); - } else if let Some(mode_selector) = this.mode_selector.clone() { - mode_selector.read(cx).menu_handle().toggle(window, cx); - } - })) - .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { - if let Some(config_options_view) = this.config_options_view.clone() { - let handled = config_options_view.update(cx, |view, cx| { - view.cycle_category_option( - acp::SessionConfigOptionCategory::Mode, - false, - cx, - ) - }); - if handled { - return; - } - } - - if let Some(profile_selector) = this.profile_selector.clone() { - profile_selector.update(cx, |profile_selector, cx| { - profile_selector.cycle_profile(cx); - }); - } else if let Some(mode_selector) = this.mode_selector.clone() { - mode_selector.update(cx, |mode_selector, cx| { - mode_selector.cycle_mode(window, cx); - }); - } - })) - .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { - if let Some(config_options_view) = this.config_options_view.clone() { - let handled = config_options_view.update(cx, |view, cx| { - view.toggle_category_picker( - acp::SessionConfigOptionCategory::Model, - window, - cx, - ) - }); - if handled { - return; - } - } - - if let Some(model_selector) = this.model_selector.clone() { - model_selector - .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); - } - })) - .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { - if let Some(config_options_view) = this.config_options_view.clone() { - let handled = config_options_view.update(cx, |view, cx| { - view.cycle_category_option( - acp::SessionConfigOptionCategory::Model, - true, - cx, - ) - }); - if handled { - return; - } - } - - if let Some(model_selector) = this.model_selector.clone() { - model_selector.update(cx, |model_selector, cx| { - model_selector.cycle_favorite_models(window, cx); - }); - } - })) - .size_full() - .children(self.render_subagent_titlebar(cx)) - .child(conversation) - .children(self.render_activity_bar(window, cx)) - .when(self.show_codex_windows_warning, |this| { - this.child(self.render_codex_windows_warning(cx)) - }) - .children(self.render_thread_retry_status_callout()) - .children(self.render_thread_error(window, cx)) - .when_some( - match has_messages { - true => None, - false => self.new_server_version_available.clone(), - }, - |this, version| this.child(self.render_new_version_callout(&version, cx)), - ) - .children(self.render_token_limit_callout(cx)) - .child(self.render_message_editor(window, cx)) - } -} - -pub(crate) fn open_link( - url: SharedString, - workspace: &WeakEntity, - window: &mut Window, - cx: &mut App, -) { - let Some(workspace) = workspace.upgrade() else { - cx.open_url(&url); - return; - }; - - if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() { - workspace.update(cx, |workspace, cx| match mention { - MentionUri::File { abs_path } => { - let project = workspace.project(); - let Some(path) = - project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) - else { - return; - }; - - workspace - .open_path(path, None, true, window, cx) - .detach_and_log_err(cx); - } - MentionUri::PastedImage => {} - MentionUri::Directory { abs_path } => { - let project = workspace.project(); - let Some(entry_id) = project.update(cx, |project, cx| { - let path = project.find_project_path(abs_path, cx)?; - project.entry_for_path(&path, cx).map(|entry| entry.id) - }) else { - return; - }; - - project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry_id)); - }); - } - MentionUri::Symbol { - abs_path: path, - line_range, - .. - } - | MentionUri::Selection { - abs_path: Some(path), - line_range, - } => { - let project = workspace.project(); - let Some(path) = - project.update(cx, |project, cx| project.find_project_path(path, cx)) - else { - return; - }; - - let item = workspace.open_path(path, None, true, window, cx); - window - .spawn(cx, async move |cx| { - let Some(editor) = item.await?.downcast::() else { - return Ok(()); - }; - let range = - Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0); - editor - .update_in(cx, |editor, window, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |s| s.select_ranges(vec![range]), - ); - }) - .ok(); - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - MentionUri::Selection { abs_path: None, .. } => {} - MentionUri::Thread { id, name } => { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.open_thread( - AgentSessionInfo { - session_id: id, - cwd: None, - title: Some(name.into()), - updated_at: None, - meta: None, - }, - window, - cx, - ) - }); - } - } - MentionUri::TextThread { path, .. } => { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel - .open_saved_text_thread(path.as_path().into(), window, cx) - .detach_and_log_err(cx); - }); - } - } - MentionUri::Rule { id, .. } => { - let PromptId::User { uuid } = id else { - return; - }; - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(uuid.0), - }), - cx, - ) - } - MentionUri::Fetch { url } => { - cx.open_url(url.as_str()); - } - MentionUri::Diagnostics { .. } => {} - MentionUri::TerminalSelection { .. } => {} - MentionUri::GitDiff { .. } => {} - }) - } else { - cx.open_url(&url); - } -} diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 65ddcafff48b03..ec700744438c0f 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1,1673 +1,6 @@ -mod add_llm_provider_modal; pub mod configure_context_server_modal; -mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; -use std::{ops::Range, rc::Rc, sync::Arc}; - -use agent::ContextServerRegistry; -use anyhow::Result; -use cloud_api_types::Plan; -use collections::HashMap; -use context_server::ContextServerId; -use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll}; -use extension::ExtensionManifest; -use extension_host::ExtensionStore; -use fs::Fs; -use gpui::{ - Action, Anchor, AnyView, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, - ScrollHandle, Subscription, Task, TaskExt, WeakEntity, -}; -use itertools::Itertools; -use language::LanguageRegistry; -use language_model::{ - IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, - ZED_CLOUD_PROVIDER_ID, -}; -use language_models::AllLanguageModelSettings; -use notifications::status_toast::StatusToast; -use project::{ - agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, - context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, -}; -use settings::{Settings, SettingsContent, SettingsStore, update_settings_file}; -use ui::{ - AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu, - ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu, - Switch, Tooltip, WithScrollbar, prelude::*, -}; -use util::ResultExt as _; -use workspace::{Workspace, create_and_open_local_file}; -use zed_actions::{ExtensionCategoryFilter, OpenBrowser}; - pub(crate) use configure_context_server_modal::ConfigureContextServerModal; -pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; - -use crate::{ - Agent, - agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, - agent_connection_store::{AgentConnectionStatus, AgentConnectionStore}, -}; - -pub struct AgentConfiguration { - fs: Arc, - language_registry: Arc, - agent_server_store: Entity, - agent_connection_store: Entity, - workspace: WeakEntity, - focus_handle: FocusHandle, - configuration_views_by_provider: HashMap, - context_server_store: Entity, - expanded_provider_configurations: HashMap, - context_server_registry: Entity, - _subscriptions: Vec, - scroll_handle: ScrollHandle, -} - -impl AgentConfiguration { - pub fn new( - fs: Arc, - agent_server_store: Entity, - agent_connection_store: Entity, - context_server_store: Entity, - context_server_registry: Entity, - language_registry: Arc, - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - - let subscriptions = vec![ - cx.subscribe_in( - &LanguageModelRegistry::global(cx), - window, - |this, _, event: &language_model::Event, window, cx| match event { - language_model::Event::AddedProvider(provider_id) => { - let provider = LanguageModelRegistry::read_global(cx).provider(provider_id); - if let Some(provider) = provider { - this.add_provider_configuration_view(&provider, window, cx); - } - } - language_model::Event::RemovedProvider(provider_id) => { - this.remove_provider_configuration_view(provider_id); - } - _ => {} - }, - ), - cx.subscribe(&agent_server_store, |_, _, _, cx| cx.notify()), - cx.observe(&agent_connection_store, |_, _, cx| cx.notify()), - cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()), - ]; - - let mut this = Self { - fs, - language_registry, - workspace, - focus_handle, - configuration_views_by_provider: HashMap::default(), - agent_server_store, - agent_connection_store, - context_server_store, - expanded_provider_configurations: HashMap::default(), - context_server_registry, - _subscriptions: subscriptions, - scroll_handle: ScrollHandle::new(), - }; - - this.build_provider_configuration_views(window, cx); - this - } - - fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context) { - let providers = LanguageModelRegistry::read_global(cx).visible_providers(); - for provider in providers { - self.add_provider_configuration_view(&provider, window, cx); - } - } - - fn remove_provider_configuration_view(&mut self, provider_id: &LanguageModelProviderId) { - self.configuration_views_by_provider.remove(provider_id); - self.expanded_provider_configurations.remove(provider_id); - } - - fn add_provider_configuration_view( - &mut self, - provider: &Arc, - window: &mut Window, - cx: &mut Context, - ) { - let configuration_view = provider.configuration_view( - language_model::ConfigurationViewTargetAgent::ZedAgent, - window, - cx, - ); - self.configuration_views_by_provider - .insert(provider.id(), configuration_view); - } -} - -impl Focusable for AgentConfiguration { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -pub enum AssistantConfigurationEvent { - NewThread(Arc), -} - -impl EventEmitter for AgentConfiguration {} - -enum AgentIcon { - Name(IconName), - Path(SharedString), -} - -impl AgentConfiguration { - fn render_section_title( - &mut self, - title: impl Into, - description: impl Into, - menu: AnyElement, - ) -> impl IntoElement { - h_flex() - .p_4() - .pb_0() - .mb_2p5() - .items_start() - .justify_between() - .child( - v_flex() - .w_full() - .gap_0p5() - .child( - h_flex() - .pr_1() - .w_full() - .gap_2() - .justify_between() - .flex_wrap() - .child(Headline::new(title.into())) - .child(menu), - ) - .child(Label::new(description.into()).color(Color::Muted)), - ) - } - - fn render_provider_configuration_block( - &mut self, - provider: &Arc, - cx: &mut Context, - ) -> impl IntoElement + use<> { - let provider_id = provider.id().0; - let provider_name = provider.name().0; - let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}")); - - let configuration_view = self - .configuration_views_by_provider - .get(&provider.id()) - .cloned(); - - let is_expanded = self - .expanded_provider_configurations - .get(&provider.id()) - .copied() - .unwrap_or(false); - - let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID; - let current_plan = if is_zed_provider { - self.workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan()) - } else { - None - }; - - let is_signed_in = self - .workspace - .read_with(cx, |workspace, _| { - !workspace.client().status().borrow().is_signed_out() - }) - .unwrap_or(false); - - v_flex() - .min_w_0() - .w_full() - .when(is_expanded, |this| this.mb_2()) - .child( - div() - .px_2() - .child(Divider::horizontal().color(DividerColor::BorderFaded)), - ) - .child( - h_flex() - .map(|this| { - if is_expanded { - this.mt_2().mb_1() - } else { - this.my_2() - } - }) - .w_full() - .justify_between() - .child( - h_flex() - .id(provider_id_string.clone()) - .px_2() - .py_0p5() - .w_full() - .justify_between() - .rounded_sm() - .hover(|hover| hover.bg(cx.theme().colors().element_hover)) - .child( - h_flex() - .w_full() - .gap_1p5() - .child( - 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() - .w_full() - .gap_1() - .child(Label::new(provider_name.clone())) - .map(|this| { - if is_zed_provider && is_signed_in { - this.child( - self.render_zed_plan_info(current_plan, cx), - ) - } else { - this.when( - provider.is_authenticated(cx) - && !is_expanded, - |parent| { - parent.child( - Icon::new(IconName::Check) - .color(Color::Success), - ) - }, - ) - } - }), - ), - ) - .child( - Disclosure::new(provider_id_string, is_expanded) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let provider_id = provider.id(); - move |this, _event, _window, _cx| { - let is_expanded = this - .expanded_provider_configurations - .entry(provider_id.clone()) - .or_insert(false); - - *is_expanded = !*is_expanded; - } - })), - ), - ) - .child( - v_flex() - .min_w_0() - .w_full() - .px_2() - .gap_1() - .when(is_expanded, |parent| match configuration_view { - Some(configuration_view) => parent.child(configuration_view), - None => parent.child(Label::new(format!( - "No configuration view for {provider_name}", - ))), - }) - .when(is_expanded && provider.is_authenticated(cx), |parent| { - parent.child( - Button::new( - SharedString::from(format!("new-thread-{provider_id}")), - "Start New Thread", - ) - .full_width() - .style(ButtonStyle::Outlined) - .layer(ElevationIndex::ModalSurface) - .start_icon( - Icon::new(IconName::Thread) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let provider = provider.clone(); - move |_this, _event, _window, cx| { - cx.emit(AssistantConfigurationEvent::NewThread( - provider.clone(), - )) - } - })), - ) - }) - .when( - is_expanded && is_removable_provider(&provider.id(), cx), - |this| { - this.child( - Button::new( - SharedString::from(format!("delete-provider-{provider_id}")), - "Remove Provider", - ) - .full_width() - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Trash) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let provider = provider.clone(); - move |this, _event, window, cx| { - this.delete_provider(provider.clone(), window, cx); - } - })), - ) - }, - ), - ) - } - - fn delete_provider( - &mut self, - provider: Arc, - window: &mut Window, - cx: &mut Context, - ) { - let fs = self.fs.clone(); - let provider_id = provider.id(); - - cx.spawn_in(window, async move |_, cx| { - cx.update(|_window, cx| { - update_settings_file(fs.clone(), cx, { - let provider_id = provider_id.clone(); - move |settings, _| { - remove_compatible_provider(settings, provider_id.0.as_ref()); - } - }); - }) - .log_err(); - - cx.update(|_window, cx| { - LanguageModelRegistry::global(cx).update(cx, { - let provider_id = provider_id.clone(); - move |registry, cx| { - registry.unregister_provider(provider_id, cx); - } - }) - }) - .log_err(); - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - fn render_provider_configuration_section( - &mut self, - cx: &mut Context, - ) -> impl IntoElement { - let providers = LanguageModelRegistry::read_global(cx).visible_providers(); - - let popover_menu = PopoverMenu::new("add-provider-popover") - .trigger( - Button::new("add-provider", "Add Provider") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - let workspace = self.workspace.clone(); - move |window, cx| { - let open_modal = |provider: LlmCompatibleProvider| { - let workspace = workspace.clone(); - move |window: &mut Window, cx: &mut App| { - workspace - .update(cx, |workspace, cx| { - AddLlmProviderModal::toggle(provider, workspace, window, cx); - }) - .log_err(); - } - }; - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.header("Compatible APIs") - .entry("OpenAI", None, open_modal(LlmCompatibleProvider::OpenAi)) - .entry( - "Anthropic", - None, - open_modal(LlmCompatibleProvider::Anthropic), - ) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .w_full() - .child(self.render_section_title( - "LLM Providers", - "Add at least one provider to use AI-powered features with Zed's native agent.", - popover_menu.into_any_element(), - )) - .child( - div() - .w_full() - .pl(DynamicSpacing::Base08.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .children( - providers.into_iter().map(|provider| { - self.render_provider_configuration_block(&provider, cx) - }), - ), - ) - } - - fn render_zed_plan_info(&self, plan: Option, cx: &mut Context) -> impl IntoElement { - if let Some(plan) = plan { - let free_chip_bg = cx - .theme() - .colors() - .editor_background - .opacity(0.5) - .blend(cx.theme().colors().text_accent.opacity(0.05)); - - let pro_chip_bg = cx - .theme() - .colors() - .editor_background - .opacity(0.5) - .blend(cx.theme().colors().text_accent.opacity(0.2)); - - let (plan_name, label_color, bg_color) = match plan { - Plan::ZedFree => ("Free", Color::Default, free_chip_bg), - Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), - Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), - Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), - Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), - }; - - Chip::new(plan_name.to_string()) - .bg_color(bg_color) - .label_color(label_color) - .into_any_element() - } else { - div().into_any_element() - } - } - - fn render_context_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { - let context_server_ids = self.context_server_store.read(cx).server_ids(); - - let add_server_popover = PopoverMenu::new("add-server-popover") - .trigger( - Button::new("add-server", "Add Server") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Add Custom Server", None, { - |window, cx| { - window.dispatch_action(crate::AddContextServer.boxed_clone(), cx) - } - }) - .entry("Install from Extensions", None, { - |window, cx| { - window.dispatch_action( - zed_actions::Extensions { - category_filter: Some( - ExtensionCategoryFilter::ContextServers, - ), - id: None, - } - .boxed_clone(), - cx, - ) - } - }) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .border_b_1() - .border_color(cx.theme().colors().border) - .child(self.render_section_title( - "Model Context Protocol (MCP) Servers", - "All MCP servers connected directly or via a Zed extension.", - add_server_popover.into_any_element(), - )) - .child( - v_flex() - .pl_4() - .pb_4() - .pr_5() - .w_full() - .gap_1() - .map(|parent| { - if context_server_ids.is_empty() { - parent.child( - h_flex() - .p_4() - .justify_center() - .border_1() - .border_dashed() - .border_color(cx.theme().colors().border.opacity(0.6)) - .rounded_sm() - .child( - Label::new("No MCP servers added yet.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - } else { - parent.children(itertools::intersperse_with( - context_server_ids.iter().cloned().map(|context_server_id| { - self.render_context_server(context_server_id, cx) - .into_any_element() - }), - || { - Divider::horizontal() - .color(DividerColor::BorderFaded) - .into_any_element() - }, - )) - } - }), - ) - } - - fn render_context_server( - &self, - context_server_id: ContextServerId, - cx: &Context, - ) -> impl use<> + IntoElement { - let server_status = self - .context_server_store - .read(cx) - .status_for_server(&context_server_id) - .unwrap_or(ContextServerStatus::Stopped); - let server_configuration = self - .context_server_store - .read(cx) - .configuration_for_server(&context_server_id); - - let is_running = matches!(server_status, ContextServerStatus::Running); - let item_id = SharedString::from(context_server_id.0.clone()); - // Servers without a configuration can only be provided by extensions. - let provided_by_extension = server_configuration.as_ref().is_none_or(|config| { - matches!( - config.as_ref(), - ContextServerConfiguration::Extension { .. } - ) - }); - - let display_name = if provided_by_extension { - resolve_extension_for_context_server(&context_server_id, cx) - .map(|(_, manifest)| { - let name = manifest.name.as_str(); - let stripped = name - .strip_suffix(" MCP Server") - .or_else(|| name.strip_suffix(" MCP")) - .or_else(|| name.strip_suffix(" Context Server")) - .unwrap_or(name); - SharedString::from(stripped.to_string()) - }) - .unwrap_or_else(|| item_id.clone()) - } else { - item_id.clone() - }; - - let error = if let ContextServerStatus::Error(error) = server_status.clone() { - Some(error) - } else { - None - }; - let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); - let client_secret_required = matches!( - server_status, - ContextServerStatus::ClientSecretRequired { .. } - ); - let authenticating = matches!(server_status, ContextServerStatus::Authenticating); - let context_server_store = self.context_server_store.clone(); - let workspace = self.workspace.clone(); - let language_registry = self.language_registry.clone(); - - let tool_count = self - .context_server_registry - .read(cx) - .tools_for_server(&context_server_id) - .count(); - - let source = if provided_by_extension { - AiSettingItemSource::Extension - } else { - AiSettingItemSource::Custom - }; - - let status = match server_status { - ContextServerStatus::Starting => AiSettingItemStatus::Starting, - ContextServerStatus::Running => AiSettingItemStatus::Running, - ContextServerStatus::Error(_) => AiSettingItemStatus::Error, - ContextServerStatus::Stopped => AiSettingItemStatus::Stopped, - ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired, - ContextServerStatus::ClientSecretRequired { .. } => { - AiSettingItemStatus::ClientSecretRequired - } - ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, - }; - - let is_remote = server_configuration - .as_ref() - .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. })) - .unwrap_or(false); - - let should_show_logout_button = server_configuration.as_ref().is_some_and(|config| { - matches!(config.as_ref(), ContextServerConfiguration::Http { .. }) - && !config.has_static_auth_header() - }); - - let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") - .trigger_with_tooltip( - IconButton::new("context-server-config-menu", IconName::Settings) - .icon_color(Color::Muted) - .icon_size(IconSize::Small), - Tooltip::text("Configure MCP Server"), - ) - .anchor(Anchor::TopRight) - .menu({ - let fs = self.fs.clone(); - let context_server_id = context_server_id.clone(); - let language_registry = self.language_registry.clone(); - let workspace = self.workspace.clone(); - let context_server_registry = self.context_server_registry.clone(); - let context_server_store = context_server_store.clone(); - - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Configure Server", None, { - let context_server_id = context_server_id.clone(); - let language_registry = language_registry.clone(); - let workspace = workspace.clone(); - move |window, cx| { - if is_remote { - crate::agent_configuration::configure_context_server_modal::ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } else { - ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } - } - }).when(tool_count > 0, |this| this.entry("View Tools", None, { - let context_server_id = context_server_id.clone(); - let context_server_registry = context_server_registry.clone(); - let workspace = workspace.clone(); - move |window, cx| { - let context_server_id = context_server_id.clone(); - workspace.update(cx, |workspace, cx| { - ConfigureContextServerToolsModal::toggle( - context_server_id, - context_server_registry.clone(), - workspace, - window, - cx, - ); - }) - .ok(); - } - })) - .when(should_show_logout_button, |this| { - this.entry("Log Out", None, { - let context_server_store = context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |_window, cx| { - context_server_store.update(cx, |store, cx| { - store.logout_server(&context_server_id, cx).log_err(); - }); - } - }) - }) - .separator() - .entry("Uninstall", None, { - let fs = fs.clone(); - let context_server_id = context_server_id.clone(); - let workspace = workspace.clone(); - move |_, cx| { - let uninstall_extension_task = match ( - provided_by_extension, - resolve_extension_for_context_server(&context_server_id, cx), - ) { - (true, Some((id, manifest))) => { - if extension_only_provides_context_server(manifest.as_ref()) - { - ExtensionStore::global(cx).update(cx, |store, cx| { - store.uninstall_extension(id, cx) - }) - } else { - workspace.update(cx, |workspace, cx| { - show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx); - }).log_err(); - Task::ready(Ok(())) - } - } - _ => Task::ready(Ok(())), - }; - - cx.spawn({ - let fs = fs.clone(); - let context_server_id = context_server_id.clone(); - async move |cx| { - uninstall_extension_task.await?; - cx.update(|cx| { - update_settings_file( - fs.clone(), - cx, - { - let context_server_id = - context_server_id.clone(); - move |settings, _| { - settings.project - .context_servers - .remove(&context_server_id.0); - } - }, - ) - }); - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - } - }) - })) - } - }); - - let feedback_base_container = - || h_flex().py_1().min_w_0().w_full().gap_1().justify_between(); - - let details: Option = if let Some(error) = error { - Some( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::XCircle) - .size(IconSize::XSmall) - .color(Color::Error), - ) - .child(div().min_w_0().flex_1().child( - Label::new(error).color(Color::Muted).size(LabelSize::Small), - )), - ) - .when(should_show_logout_button, |this| { - this.child( - Button::new("error-logout-server", "Log Out") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_store = context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |_event, _window, cx| { - context_server_store.update(cx, |store, cx| { - store.logout_server(&context_server_id, cx).log_err(); - }); - } - }), - ) - }) - .into_any_element(), - ) - } else if auth_required { - Some( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new("Authenticate to connect this server") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - .child( - Button::new("authenticate-server", "Authenticate") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_id = context_server_id.clone(); - move |_event, _window, cx| { - context_server_store.update(cx, |store, cx| { - store.authenticate_server(&context_server_id, cx).log_err(); - }); - } - }), - ) - .into_any_element(), - ) - } else if client_secret_required { - Some( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new("Enter a client secret to connect this server") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - .child( - Button::new("enter-client-secret", "Enter Client Secret") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_id = context_server_id.clone(); - move |_event, window, cx| { - ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } - }), - ) - .into_any_element(), - ) - } else if authenticating { - Some( - h_flex() - .mt_1() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child(div().size_3().flex_shrink_0()) - .child( - Label::new("Authenticating…") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .into_any_element(), - ) - } else { - None - }; - - let tool_label = if is_running { - Some(if tool_count == 1 { - SharedString::from("1 tool") - } else { - SharedString::from(format!("{} tools", tool_count)) - }) - } else { - None - }; - - AiSettingItem::new(item_id, display_name, status, source) - .action(context_server_configuration_menu) - .action( - Switch::new("context-server-switch", is_running.into()).on_click({ - let context_server_manager = self.context_server_store.clone(); - let fs = self.fs.clone(); - - move |state, _window, cx| { - let is_enabled = match state { - ToggleState::Unselected | ToggleState::Indeterminate => { - context_server_manager.update(cx, |this, cx| { - this.stop_server(&context_server_id, cx).log_err(); - }); - false - } - ToggleState::Selected => { - context_server_manager.update(cx, |this, cx| { - if let Some(server) = this.get_server(&context_server_id) { - this.start_server(server, cx); - } - }); - true - } - }; - update_settings_file(fs.clone(), cx, { - let context_server_id = context_server_id.clone(); - - move |settings, _| { - settings - .project - .context_servers - .entry(context_server_id.0) - .or_insert_with(|| { - settings::ContextServerSettingsContent::Extension { - enabled: is_enabled, - remote: false, - settings: serde_json::json!({}), - } - }) - .set_enabled(is_enabled); - } - }); - } - }), - ) - .when_some(tool_label, |this, label| this.detail_label(label)) - .when_some(details, |this, details| this.details(details)) - } - - fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { - let agent_server_store = self.agent_server_store.read(cx); - - let agents = agent_server_store - .external_agents() - .cloned() - .collect::>(); - - let agents: Vec<_> = agents - .into_iter() - .map(|name| { - let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { - AgentIcon::Path(icon_path) - } else { - AgentIcon::Name(IconName::Sparkle) - }; - let display_name = agent_server_store - .agent_display_name(&name) - .unwrap_or_else(|| name.0.clone()); - let source = agent_server_store.agent_source(&name).unwrap_or_default(); - (name, icon, display_name, source) - }) - .sorted_unstable_by_key(|(_, _, display_name, _)| display_name.to_lowercase()) - .collect(); - - let add_agent_popover = PopoverMenu::new("add-agent-server-popover") - .trigger( - Button::new("add-agent", "Add Agent") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Install from Registry", None, { - |window, cx| { - window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx) - } - }) - .entry("Add Custom Agent", None, { - move |window, cx| { - if let Some(workspace) = Workspace::for_window(window, cx) { - let workspace = workspace.downgrade(); - window - .spawn(cx, async |cx| { - open_new_agent_servers_entry_in_settings_editor( - workspace, cx, - ) - .await - }) - .detach_and_log_err(cx); - } - } - }) - .separator() - .header("Learn More") - .item( - ContextMenuEntry::new("ACP Docs") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) - .handler({ - move |window, cx| { - window.dispatch_action( - Box::new(OpenBrowser { - url: "https://agentclientprotocol.com/".into(), - }), - cx, - ); - } - }), - ) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - v_flex() - .child(self.render_section_title( - "External Agents", - "All agents connected through the Agent Client Protocol.", - add_agent_popover.into_any_element(), - )) - .child( - v_flex() - .p_4() - .pt_0() - .gap_2() - .children(Itertools::intersperse_with( - agents - .into_iter() - .map(|(name, icon, display_name, source)| { - self.render_agent_server( - icon, - name, - display_name, - source, - cx, - ) - .into_any_element() - }), - || { - Divider::horizontal() - .color(DividerColor::BorderFaded) - .into_any_element() - }, - )), - ), - ) - } - - fn render_agent_server( - &self, - icon: AgentIcon, - id: impl Into, - display_name: impl Into, - source: ExternalAgentSource, - cx: &mut Context, - ) -> impl IntoElement { - 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) - .color(Color::Muted), - AgentIcon::Path(icon_path) => Icon::from_external_svg(icon_path) - .size(IconSize::Small) - .color(Color::Muted), - }; - - let source_kind = match source { - ExternalAgentSource::Registry => AiSettingItemSource::Registry, - ExternalAgentSource::Custom => AiSettingItemSource::Custom, - }; - - let agent_server_name = AgentId(id.clone()); - let agent = Agent::Custom { - id: agent_server_name.clone(), - }; - - let (connection_status, running_version) = { - let connection_store = self.agent_connection_store.read(cx); - ( - connection_store.connection_status(&agent, cx), - connection_store.agent_version(&agent, cx), - ) - }; - - let restart_button = matches!( - connection_status, - AgentConnectionStatus::Connected | AgentConnectionStatus::Connecting - ) - .then(|| { - IconButton::new( - SharedString::from(format!("restart-{}", id)), - IconName::RotateCw, - ) - .disabled(connection_status == AgentConnectionStatus::Connecting) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Restart Agent Connection")) - .on_click(cx.listener({ - let agent = agent.clone(); - move |this, _, _window, cx| { - let server: Rc = - Rc::new(agent_servers::CustomAgentServer::new(agent.id())); - this.agent_connection_store.update(cx, |store, cx| { - store.restart_connection(agent.clone(), server, cx); - }); - } - })) - }); - - let uninstall_button = match source { - ExternalAgentSource::Registry => { - let fs = self.fs.clone(); - Some( - IconButton::new( - SharedString::from(format!("uninstall-{}", id)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove Registry Agent")) - .on_click(cx.listener(move |_, _, _window, cx| { - let agent_name = agent_server_name.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let Some(agent_servers) = settings.agent_servers.as_mut() else { - return; - }; - if let Some(entry) = agent_servers.get(agent_name.0.as_ref()) - && matches!( - entry, - settings::CustomAgentServerSettings::Registry { .. } - ) - { - agent_servers.remove(agent_name.0.as_ref()); - } - }); - })), - ) - } - ExternalAgentSource::Custom => { - let fs = self.fs.clone(); - Some( - IconButton::new( - SharedString::from(format!("uninstall-{}", id)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove Custom Agent")) - .on_click(cx.listener(move |_, _, _window, cx| { - let agent_name = agent_server_name.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let Some(agent_servers) = settings.agent_servers.as_mut() else { - return; - }; - if let Some(entry) = agent_servers.get(agent_name.0.as_ref()) - && matches!( - entry, - settings::CustomAgentServerSettings::Custom { .. } - ) - { - agent_servers.remove(agent_name.0.as_ref()); - } - }); - })), - ) - } - }; - - let status = match connection_status { - AgentConnectionStatus::Disconnected => AiSettingItemStatus::Stopped, - AgentConnectionStatus::Connecting => AiSettingItemStatus::Starting, - AgentConnectionStatus::Connected => AiSettingItemStatus::Running, - }; - - AiSettingItem::new(id, display_name, status, source_kind) - .icon(icon) - .when_some(running_version, |this, version| this.detail_label(version)) - .when_some(restart_button, |this, button| this.action(button)) - .when_some(uninstall_button, |this, button| this.action(button)) - } -} - -impl Render for AgentConfiguration { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("assistant-configuration") - .key_context("AgentConfiguration") - .track_focus(&self.focus_handle(cx)) - .relative() - .size_full() - .pb_8() - .bg(cx.theme().colors().panel_background) - .child( - div() - .size_full() - .child( - v_flex() - .id("assistant-configuration-content") - .track_scroll(&self.scroll_handle) - .size_full() - .min_w_0() - .overflow_y_scroll() - .child(self.render_agent_servers_section(cx)) - .child(self.render_context_servers_section(cx)) - .child(self.render_provider_configuration_section(cx)), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx), - ) - } -} - -fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool { - manifest.context_servers.len() == 1 - && manifest.themes.is_empty() - && manifest.icon_themes.is_empty() - && manifest.languages.is_empty() - && manifest.grammars.is_empty() - && manifest.language_servers.is_empty() - && manifest.slash_commands.is_empty() - && manifest.snippets.is_none() - && manifest.debug_locators.is_empty() -} - -pub(crate) fn resolve_extension_for_context_server( - id: &ContextServerId, - cx: &App, -) -> Option<(Arc, Arc)> { - ExtensionStore::global(cx) - .read(cx) - .installed_extensions() - .iter() - .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0)) - .map(|(id, entry)| (id.clone(), entry.manifest.clone())) -} - -// This notification appears when trying to delete -// an MCP server extension that not only provides -// the server, but other things, too, like language servers and more. -fn show_unable_to_uninstall_extension_with_context_server( - workspace: &mut Workspace, - id: ContextServerId, - cx: &mut App, -) { - let workspace_handle = workspace.weak_handle(); - let context_server_id = id.clone(); - - let status_toast = StatusToast::new( - format!( - "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?", - id.0 - ), - cx, - move |this, _cx| { - let workspace_handle = workspace_handle.clone(); - - this.icon( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .dismiss_button(true) - .action("Uninstall", move |_, _cx| { - if let Some((extension_id, _)) = - resolve_extension_for_context_server(&context_server_id, _cx) - { - ExtensionStore::global(_cx).update(_cx, |store, cx| { - store - .uninstall_extension(extension_id, cx) - .detach_and_log_err(cx); - }); - - workspace_handle - .update(_cx, |workspace, cx| { - let fs = workspace.app_state().fs.clone(); - cx.spawn({ - let context_server_id = context_server_id.clone(); - async move |_workspace_handle, cx| { - cx.update(|cx| { - update_settings_file(fs, cx, move |settings, _| { - settings - .project - .context_servers - .remove(&context_server_id.0); - }); - }); - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - }) - .log_err(); - } - }) - }, - ); - - workspace.toggle_status_toast(status_toast, cx); -} - -async fn open_new_agent_servers_entry_in_settings_editor( - workspace: WeakEntity, - cx: &mut AsyncWindowContext, -) -> Result<()> { - let settings_editor = workspace - .update_in(cx, |_, window, cx| { - create_and_open_local_file(paths::settings_file(), window, cx, || { - settings::initial_user_settings_content().as_ref().into() - }) - })? - .await? - .downcast::() - .unwrap(); - - settings_editor - .downgrade() - .update_in(cx, |item, window, cx| { - let text = item.buffer().read(cx).snapshot(cx).text(); - - let settings = cx.global::(); - - let mut unique_server_name = None; - let Some(edits) = settings - .edits_for_update(&text, |settings| { - let server_name: Option = (0..u8::MAX) - .map(|i| { - if i == 0 { - "your_agent".to_string() - } else { - format!("your_agent_{}", i) - } - }) - .find(|name| { - !settings - .agent_servers - .as_ref() - .is_some_and(|agent_servers| { - agent_servers.contains_key(name.as_str()) - }) - }); - if let Some(server_name) = server_name { - unique_server_name = Some(SharedString::from(server_name.clone())); - settings.agent_servers.get_or_insert_default().insert( - server_name, - settings::CustomAgentServerSettings::Custom { - path: "path_to_executable".into(), - args: vec![], - env: HashMap::default(), - default_mode: None, - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }, - ); - } - }) - .log_err() - else { - return; - }; - - if edits.is_empty() { - return; - } - - let ranges = edits - .iter() - .map(|(range, _)| range.clone()) - .collect::>(); - - item.edit( - edits.into_iter().map(|(range, s)| { - ( - MultiBufferOffset(range.start)..MultiBufferOffset(range.end), - s, - ) - }), - cx, - ); - if let Some((unique_server_name, buffer)) = - unique_server_name.zip(item.buffer().read(cx).as_singleton()) - { - let snapshot = buffer.read(cx).snapshot(); - if let Some(range) = - find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot) - { - item.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_ranges(vec![ - MultiBufferOffset(range.start)..MultiBufferOffset(range.end), - ]); - }, - ); - } - } - }) -} - -fn find_text_in_buffer( - text: &str, - start: usize, - snapshot: &language::BufferSnapshot, -) -> Option> { - let chars = text.chars().collect::>(); - - let mut offset = start; - let mut char_offset = 0; - for c in snapshot.chars_at(start) { - if char_offset >= chars.len() { - break; - } - offset += 1; - - if c == chars[char_offset] { - char_offset += 1; - } else { - char_offset = 0; - } - } - - if char_offset == chars.len() { - Some(offset.saturating_sub(chars.len())..offset) - } else { - None - } -} - -// API-compatible providers are user-configured and can be removed, -// whereas built-in providers (like Anthropic, OpenAI, Google, etc.) can't. -// -// If in the future we have more "API-compatible-type" of providers, -// they should be included here as removable providers. -fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> bool { - let settings = AllLanguageModelSettings::get_global(cx); - settings - .openai_compatible - .contains_key(provider_id.0.as_ref()) - || settings - .anthropic_compatible - .contains_key(provider_id.0.as_ref()) -} - -fn remove_compatible_provider(settings: &mut SettingsContent, provider_id: &str) { - // Mirrors the OpenAI-wins precedence used at registration time: only the - // entry that is actually registered gets removed. A shadowed - // `anthropic_compatible` entry with the same name takes over instead of - // being silently deleted. - let Some(language_models) = settings.language_models.as_mut() else { - return; - }; - let removed_from_openai = language_models - .openai_compatible - .as_mut() - .and_then(|providers| providers.remove(provider_id)) - .is_some(); - if !removed_from_openai && let Some(providers) = language_models.anthropic_compatible.as_mut() { - providers.remove(provider_id); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use settings::{AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent}; - - fn settings_with_compatible_providers(openai: &[&str], anthropic: &[&str]) -> SettingsContent { - let mut settings = SettingsContent::default(); - let language_models = settings.language_models.get_or_insert_default(); - language_models.openai_compatible = Some( - openai - .iter() - .map(|id| { - ( - Arc::from(*id), - OpenAiCompatibleSettingsContent { - api_url: "https://example.com".to_string(), - available_models: Vec::new(), - custom_headers: None, - }, - ) - }) - .collect(), - ); - language_models.anthropic_compatible = Some( - anthropic - .iter() - .map(|id| { - ( - Arc::from(*id), - AnthropicCompatibleSettingsContent { - api_url: "https://example.com".to_string(), - available_models: Vec::new(), - custom_headers: None, - }, - ) - }) - .collect(), - ); - settings - } - - fn compatible_provider_keys(settings: &SettingsContent) -> (Vec<&str>, Vec<&str>) { - fn keys(providers: Option<&HashMap, T>>) -> Vec<&str> { - providers - .map(|providers| providers.keys().map(|key| key.as_ref()).collect()) - .unwrap_or_default() - } - - let language_models = settings - .language_models - .as_ref() - .expect("language_models settings should exist"); - ( - keys(language_models.openai_compatible.as_ref()), - keys(language_models.anthropic_compatible.as_ref()), - ) - } - - #[test] - fn test_remove_compatible_provider_openai_only() { - let mut settings = settings_with_compatible_providers(&["acme"], &[]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, Vec::<&str>::new()); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_anthropic_only() { - let mut settings = settings_with_compatible_providers(&[], &["acme"]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, Vec::<&str>::new()); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_collision_removes_only_openai_entry() { - let mut settings = settings_with_compatible_providers(&["acme"], &["acme"]); - - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!( - openai, - Vec::<&str>::new(), - "the registered (OpenAI-compatible) entry should be removed" - ); - assert_eq!( - anthropic, - vec!["acme"], - "the shadowed anthropic_compatible entry should survive" - ); - - // A second removal deletes the entry that took over. - remove_compatible_provider(&mut settings, "acme"); - let (_, anthropic) = compatible_provider_keys(&settings); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_leaves_other_providers_untouched() { - let mut settings = settings_with_compatible_providers(&["acme", "globex"], &["initech"]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, vec!["globex"]); - assert_eq!(anthropic, vec!["initech"]); - } -} 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 deleted file mode 100644 index 86e556e2e5d67c..00000000000000 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ /dev/null @@ -1,1040 +0,0 @@ -use std::sync::Arc; - -use anyhow::Result; -use fs::Fs; -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt, -}; -use itertools::Itertools as _; -use language_model::LanguageModelRegistry; -use language_models::provider::open_ai_compatible::{ - AvailableModel as OpenAiCompatibleAvailableModel, - ModelCapabilities as OpenAiCompatibleModelCapabilities, -}; -use settings::{ - AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities, - AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, update_settings_file, -}; -use ui::{ - Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, - WithScrollbar, prelude::*, -}; -use ui_input::InputField; -use workspace::{ModalView, Workspace}; - -fn single_line_input( - label: impl Into, - placeholder: &str, - text: Option<&str>, - tab_index: isize, - window: &mut Window, - cx: &mut App, -) -> Entity { - cx.new(|cx| { - let input = InputField::new(window, cx, placeholder) - .label(label) - .tab_index(tab_index) - .tab_stop(true); - - if let Some(text) = text { - input.set_text(text, window, cx); - } - input - }) -} - -#[derive(Clone, Copy)] -pub enum LlmCompatibleProvider { - OpenAi, - Anthropic, -} - -impl LlmCompatibleProvider { - fn name(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "OpenAI", - LlmCompatibleProvider::Anthropic => "Anthropic", - } - } - - fn api_url(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1", - LlmCompatibleProvider::Anthropic => "https://api.anthropic.com", - } - } - - fn description(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "This provider will use an OpenAI compatible API.", - LlmCompatibleProvider::Anthropic => { - "This provider will use an Anthropic Messages compatible API." - } - } - } - - fn is_open_ai(&self) -> bool { - matches!(self, LlmCompatibleProvider::OpenAi) - } -} - -struct AddLlmProviderInput { - provider_name: Entity, - api_url: Entity, - api_key: Entity, - models: Vec, -} - -impl AddLlmProviderInput { - fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) -> Self { - let provider_name = - single_line_input("Provider Name", provider.name(), None, 1, window, cx); - let api_url = single_line_input("API URL", provider.api_url(), None, 2, window, cx); - let api_key = cx.new(|cx| { - InputField::new( - window, - cx, - "000000000000000000000000000000000000000000000000", - ) - .label("API Key") - .tab_index(3) - .tab_stop(true) - .masked(true) - }); - - Self { - provider_name, - api_url, - api_key, - models: vec![ModelInput::new(0, window, cx)], - } - } - - fn add_model(&mut self, window: &mut Window, cx: &mut App) { - let model_index = self.models.len(); - self.models.push(ModelInput::new(model_index, window, cx)); - } - - fn remove_model(&mut self, index: usize) { - self.models.remove(index); - } -} - -struct ModelCapabilityToggles { - pub supports_tools: ToggleState, - pub supports_images: ToggleState, - pub supports_parallel_tool_calls: ToggleState, - pub supports_prompt_cache_key: ToggleState, - pub supports_chat_completions: ToggleState, -} - -struct ModelInput { - name: Entity, - max_completion_tokens: Entity, - max_output_tokens: Entity, - max_tokens: Entity, - capabilities: ModelCapabilityToggles, -} - -impl ModelInput { - fn new(model_index: usize, window: &mut Window, cx: &mut App) -> Self { - let base_tab_index = (3 + (model_index * 4)) as isize; - - let model_name = single_line_input( - "Model Name", - "e.g. gpt-5, claude-opus-4, gemini-2.5-pro", - None, - base_tab_index + 1, - window, - cx, - ); - let max_completion_tokens = single_line_input( - "Max Completion Tokens", - "200000", - Some("200000"), - base_tab_index + 2, - window, - cx, - ); - let max_output_tokens = single_line_input( - "Max Output Tokens", - "Max Output Tokens", - Some("32000"), - base_tab_index + 3, - window, - cx, - ); - let max_tokens = single_line_input( - "Max Tokens", - "Max Tokens", - Some("200000"), - base_tab_index + 4, - window, - cx, - ); - - let OpenAiCompatibleModelCapabilities { - tools, - images, - parallel_tool_calls, - prompt_cache_key, - chat_completions, - .. - } = OpenAiCompatibleModelCapabilities::default(); - - Self { - name: model_name, - max_completion_tokens, - max_output_tokens, - max_tokens, - capabilities: ModelCapabilityToggles { - supports_tools: tools.into(), - supports_images: images.into(), - supports_parallel_tool_calls: parallel_tool_calls.into(), - supports_prompt_cache_key: prompt_cache_key.into(), - supports_chat_completions: chat_completions.into(), - }, - } - } - - fn parse_name(&self, cx: &App) -> Result { - let name = self.name.read(cx).text(cx); - if name.is_empty() { - return Err(SharedString::from("Model Name cannot be empty")); - } - Ok(name) - } - - fn parse_open_ai_compatible( - &self, - cx: &App, - ) -> Result { - Ok(OpenAiCompatibleAvailableModel { - name: self.parse_name(cx)?, - display_name: None, - max_completion_tokens: Some(parse_u64_field( - &self.max_completion_tokens, - "Max Completion Tokens", - cx, - )?), - max_output_tokens: Some(parse_u64_field( - &self.max_output_tokens, - "Max Output Tokens", - cx, - )?), - max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, - reasoning_effort: None, - capabilities: OpenAiCompatibleModelCapabilities { - tools: self.capabilities.supports_tools.selected(), - images: self.capabilities.supports_images.selected(), - parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), - prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(), - chat_completions: self.capabilities.supports_chat_completions.selected(), - interleaved_reasoning: false, - }, - }) - } - - fn parse_anthropic_compatible( - &self, - cx: &App, - ) -> Result { - Ok(AnthropicCompatibleAvailableModel { - name: self.parse_name(cx)?, - display_name: None, - max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, - tool_override: None, - max_output_tokens: Some(parse_u64_field( - &self.max_output_tokens, - "Max Output Tokens", - cx, - )?), - default_temperature: None, - extra_beta_headers: Vec::new(), - mode: None, - capabilities: AnthropicCompatibleModelCapabilities { - tools: self.capabilities.supports_tools.selected(), - images: self.capabilities.supports_images.selected(), - prompt_caching: false, - }, - }) - } -} - -fn parse_u64_field( - field: &Entity, - field_name: &str, - cx: &App, -) -> Result { - field - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from(format!("{field_name} must be a number"))) -} - -enum ParsedModels { - OpenAi(Vec), - Anthropic(Vec), -} - -impl ParsedModels { - fn model_names(&self) -> impl Iterator { - match self { - ParsedModels::OpenAi(models) => { - itertools::Either::Left(models.iter().map(|model| model.name.as_str())) - } - ParsedModels::Anthropic(models) => { - itertools::Either::Right(models.iter().map(|model| model.name.as_str())) - } - } - } -} - -fn save_provider_to_settings( - provider: LlmCompatibleProvider, - input: &AddLlmProviderInput, - cx: &mut App, -) -> Task> { - let provider_name: Arc = input.provider_name.read(cx).text(cx).into(); - if provider_name.is_empty() { - return Task::ready(Err("Provider Name cannot be empty".into())); - } - - if LanguageModelRegistry::read_global(cx) - .providers() - .iter() - .any(|provider| { - provider.id().0.as_ref() == provider_name.as_ref() - || provider.name().0.as_ref() == provider_name.as_ref() - }) - { - return Task::ready(Err( - "Provider Name is already taken by another provider".into() - )); - } - - let api_url = input.api_url.read(cx).text(cx); - if api_url.is_empty() { - return Task::ready(Err("API URL cannot be empty".into())); - } - - let api_key = input.api_key.read(cx).text(cx); - if api_key.is_empty() { - return Task::ready(Err("API Key cannot be empty".into())); - } - - let models = match provider { - LlmCompatibleProvider::OpenAi => input - .models - .iter() - .map(|model| model.parse_open_ai_compatible(cx)) - .collect::, _>>() - .map(ParsedModels::OpenAi), - LlmCompatibleProvider::Anthropic => input - .models - .iter() - .map(|model| model.parse_anthropic_compatible(cx)) - .collect::, _>>() - .map(ParsedModels::Anthropic), - }; - let models = match models { - Ok(models) => models, - Err(error) => return Task::ready(Err(error)), - }; - - if !models.model_names().all_unique() { - return Task::ready(Err("Model Names must be unique".into())); - } - - let fs = ::global(cx); - let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes()); - cx.spawn(async move |cx| { - task.await - .map_err(|_| SharedString::from("Failed to write API key to keychain"))?; - cx.update(|cx| { - update_settings_file(fs, cx, move |settings, _cx| { - let language_models = settings.language_models.get_or_insert_default(); - match models { - ParsedModels::OpenAi(available_models) => { - language_models - .openai_compatible - .get_or_insert_default() - .insert( - provider_name, - OpenAiCompatibleSettingsContent { - api_url, - available_models, - custom_headers: None, - }, - ); - } - ParsedModels::Anthropic(available_models) => { - language_models - .anthropic_compatible - .get_or_insert_default() - .insert( - provider_name, - AnthropicCompatibleSettingsContent { - api_url, - available_models, - custom_headers: None, - }, - ); - } - } - }); - }); - Ok(()) - }) -} - -pub struct AddLlmProviderModal { - provider: LlmCompatibleProvider, - input: AddLlmProviderInput, - scroll_handle: ScrollHandle, - focus_handle: FocusHandle, - last_error: Option, -} - -impl AddLlmProviderModal { - pub fn toggle( - provider: LlmCompatibleProvider, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - workspace.toggle_modal(window, cx, |window, cx| Self::new(provider, window, cx)); - } - - fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut Context) -> Self { - Self { - input: AddLlmProviderInput::new(provider, window, cx), - provider, - last_error: None, - focus_handle: cx.focus_handle(), - scroll_handle: ScrollHandle::new(), - } - } - - fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) { - let task = save_provider_to_settings(self.provider, &self.input, cx); - cx.spawn(async move |this, cx| { - let result = task.await; - this.update(cx, |this, cx| match result { - Ok(_) => { - cx.emit(DismissEvent); - } - Err(error) => { - this.last_error = Some(error); - cx.notify(); - } - }) - }) - .detach_and_log_err(cx); - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn render_model_section(&self, cx: &mut Context) -> impl IntoElement { - v_flex() - .mt_1() - .gap_2() - .child( - h_flex() - .justify_between() - .child(Label::new("Models").size(LabelSize::Small)) - .child( - Button::new("add-model", "Add Model") - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.input.add_model(window, cx); - cx.notify(); - })), - ), - ) - .children( - self.input - .models - .iter() - .enumerate() - .map(|(ix, _)| self.render_model(ix, cx)), - ) - } - - fn render_model(&self, ix: usize, cx: &mut Context) -> impl IntoElement + use<> { - let has_more_than_one_model = self.input.models.len() > 1; - let is_open_ai = self.provider.is_open_ai(); - let model = &self.input.models[ix]; - - v_flex() - .p_2() - .gap_2() - .rounded_sm() - .border_1() - .border_dashed() - .border_color(cx.theme().colors().border.opacity(0.6)) - .bg(cx.theme().colors().element_active.opacity(0.15)) - .child(model.name.clone()) - .child( - h_flex() - .gap_2() - .when(is_open_ai, |parent| { - parent.child(model.max_completion_tokens.clone()) - }) - .child(model.max_output_tokens.clone()), - ) - .child(model.max_tokens.clone()) - .child( - v_flex() - .gap_1() - .child( - Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools) - .label("Supports tools") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_tools = *checked; - cx.notify(); - })), - ) - .child( - Checkbox::new(("supports-images", ix), model.capabilities.supports_images) - .label("Supports images") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_images = *checked; - cx.notify(); - })), - ) - .when(is_open_ai, |parent| { - parent - .child( - Checkbox::new( - ("supports-parallel-tool-calls", ix), - model.capabilities.supports_parallel_tool_calls, - ) - .label("Supports parallel_tool_calls") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_parallel_tool_calls = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-prompt-cache-key", ix), - model.capabilities.supports_prompt_cache_key, - ) - .label("Supports prompt_cache_key") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_prompt_cache_key = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-chat-completions", ix), - model.capabilities.supports_chat_completions, - ) - .label("Supports /chat/completions") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_chat_completions = *checked; - cx.notify(); - }, - )), - ) - }), - ) - .when(has_more_than_one_model, |this| { - this.child( - Button::new(("remove-model", ix), "Remove Model") - .start_icon( - Icon::new(IconName::Trash) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .full_width() - .on_click(cx.listener(move |this, _, _window, cx| { - this.input.remove_model(ix); - cx.notify(); - })), - ) - }) - } - - 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, - cx: &mut Context, - ) { - window.focus_prev(cx); - } -} - -impl EventEmitter for AddLlmProviderModal {} - -impl Focusable for AddLlmProviderModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl ModalView for AddLlmProviderModal {} - -impl Render for AddLlmProviderModal { - fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - let window_size = window.viewport_size(); - let rem_size = window.rem_size(); - let is_large_window = window_size.height / rem_size > rems_from_px(600.).0; - - let modal_max_height = if is_large_window { - rems_from_px(450.) - } else { - rems_from_px(200.) - }; - - v_flex() - .id("add-llm-provider-modal") - .key_context("AddLlmProviderModal") - .w(rems(34.)) - .elevation_3(cx) - .on_action(cx.listener(Self::cancel)) - .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, cx); - })) - .child( - Modal::new("configure-context-server", None) - .header( - ModalHeader::new() - .headline("Add LLM Provider") - .description(self.provider.description()), - ) - .when_some(self.last_error.clone(), |this, error| { - this.section( - Section::new().child( - Banner::new() - .severity(Severity::Warning) - .child(div().text_xs().child(error)), - ), - ) - }) - .child( - div() - .size_full() - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - .child( - v_flex() - .id("modal_content") - .size_full() - .tab_group() - .max_h(modal_max_height) - .pl_3() - .pr_4() - .pb_2() - .gap_2() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .child(self.input.provider_name.clone()) - .child(self.input.api_url.clone()) - .child(self.input.api_key.clone()) - .child(self.render_model_section(cx)), - ), - ) - .footer( - ModalFooter::new().end_slot( - h_flex() - .gap_1() - .child( - Button::new("cancel", "Cancel") - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.cancel(&menu::Cancel, window, cx) - })), - ) - .child( - Button::new("save-server", "Save Provider") - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.confirm(&menu::Confirm, window, cx) - })), - ), - ), - ), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use fs::FakeFs; - use gpui::{TestAppContext, VisualTestContext}; - use language_model::{ - LanguageModelProviderId, LanguageModelProviderName, - fake_provider::FakeLanguageModelProvider, - }; - use project::Project; - use settings::SettingsStore; - use util::path; - use workspace::MultiWorkspace; - - #[gpui::test] - async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - for provider in [ - LlmCompatibleProvider::OpenAi, - LlmCompatibleProvider::Anthropic, - ] { - assert_eq!( - save_provider_validation_errors(provider, "", "someurl", "somekey", vec![], cx) - .await, - Some("Provider Name cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "", - "somekey", - vec![], - cx - ) - .await, - Some("API URL cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "", - vec![], - cx - ) - .await, - Some("API Key cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("", "200000", "200000", "32000")], - cx, - ) - .await, - Some("Model Name cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "abc", "200000", "32000")], - cx, - ) - .await, - Some("Max Tokens must be a number".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "200000", "abc")], - cx, - ) - .await, - Some("Max Output Tokens must be a number".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![ - ("somemodel", "200000", "200000", "32000"), - ("somemodel", "200000", "200000", "32000"), - ], - cx, - ) - .await, - Some("Model Names must be unique".into()) - ); - } - - // Max Completion Tokens is only used by OpenAI-compatible providers. - assert_eq!( - save_provider_validation_errors( - LlmCompatibleProvider::OpenAi, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "abc", "32000")], - cx, - ) - .await, - Some("Max Completion Tokens must be a number".into()) - ); - } - - #[gpui::test] - async fn test_save_provider_name_conflict(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|_window, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.register_provider( - Arc::new(FakeLanguageModelProvider::new( - LanguageModelProviderId::new("someprovider"), - LanguageModelProviderName::new("Some Provider"), - )), - cx, - ); - }); - }); - - assert_eq!( - save_provider_validation_errors( - LlmCompatibleProvider::OpenAi, - "someprovider", - "someurl", - "someapikey", - vec![("somemodel", "200000", "200000", "32000")], - cx, - ) - .await, - Some("Provider Name is already taken by another provider".into()) - ); - } - - #[gpui::test] - async fn test_model_input_default_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - assert_eq!( - model_input.capabilities.supports_tools, - ToggleState::Selected - ); - assert_eq!( - model_input.capabilities.supports_images, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_parallel_tool_calls, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_prompt_cache_key, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_chat_completions, - ToggleState::Selected - ); - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(!parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(parsed_model.capabilities.chat_completions); - }); - } - - #[gpui::test] - async fn test_model_input_deselected_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - model_input.capabilities.supports_tools = ToggleState::Unselected; - model_input.capabilities.supports_images = ToggleState::Unselected; - model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected; - model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; - model_input.capabilities.supports_chat_completions = ToggleState::Unselected; - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert!(!parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(!parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(!parsed_model.capabilities.chat_completions); - }); - } - - #[gpui::test] - async fn test_model_input_with_name_and_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - model_input.capabilities.supports_tools = ToggleState::Selected; - model_input.capabilities.supports_images = ToggleState::Unselected; - model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected; - model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; - model_input.capabilities.supports_chat_completions = ToggleState::Selected; - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert_eq!(parsed_model.name, "somemodel"); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(parsed_model.capabilities.chat_completions); - }); - } - - #[gpui::test] - async fn test_model_input_parse_anthropic_compatible(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); - assert_eq!(parsed_model.name, "somemodel"); - assert_eq!(parsed_model.max_tokens, 200000); - assert_eq!(parsed_model.max_output_tokens, Some(32000)); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - - model_input.capabilities.supports_tools = ToggleState::Unselected; - model_input.capabilities.supports_images = ToggleState::Selected; - - let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); - assert!(!parsed_model.capabilities.tools); - assert!(parsed_model.capabilities.images); - }); - } - - async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext { - cx.update(|cx| { - let store = SettingsStore::test(cx); - cx.set_global(store); - theme_settings::init(theme::LoadThemes::JustBase, cx); - - language_model::init(cx); - editor::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - cx - } - - async fn save_provider_validation_errors( - provider: LlmCompatibleProvider, - provider_name: &str, - api_url: &str, - api_key: &str, - models: Vec<(&str, &str, &str, &str)>, - cx: &mut VisualTestContext, - ) -> Option { - fn set_text(input: &Entity, text: &str, window: &mut Window, cx: &mut App) { - input.update(cx, |input, cx| { - input.set_text(text, window, cx); - }); - } - - let task = cx.update(|window, cx| { - let mut input = AddLlmProviderInput::new(provider, window, cx); - set_text(&input.provider_name, provider_name, window, cx); - set_text(&input.api_url, api_url, window, cx); - set_text(&input.api_key, api_key, window, cx); - - for (i, (name, max_tokens, max_completion_tokens, max_output_tokens)) in - models.iter().enumerate() - { - if i >= input.models.len() { - input.models.push(ModelInput::new(i, window, cx)); - } - let model = &mut input.models[i]; - set_text(&model.name, name, window, cx); - set_text(&model.max_tokens, max_tokens, window, cx); - set_text( - &model.max_completion_tokens, - max_completion_tokens, - window, - cx, - ); - set_text(&model.max_output_tokens, max_output_tokens, window, cx); - } - save_provider_to_settings(provider, &input, cx) - }); - - task.await.err() - } -} 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 5ccc901b4a451a..6a32b488605f6b 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 @@ -3,10 +3,10 @@ use collections::HashMap; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; +use extension_host::ExtensionStore; use gpui::{ AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, - Subscription, Task, TaskExt, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, - prelude::*, + Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; @@ -31,10 +31,7 @@ use ui::{ use util::ResultExt as _; use workspace::{ModalView, Workspace}; -use crate::AddContextServer; - enum ConfigurationTarget { - New, Existing { id: ContextServerId, command: ContextServerCommand, @@ -53,14 +50,15 @@ enum ConfigurationTarget { }, } +enum ExistingServerType { + Local, + Remote, +} + enum ConfigurationSource { - New { - editor: Entity, - is_http: bool, - }, Existing { editor: Entity, - is_http: bool, + server_type: ExistingServerType, }, Extension { id: ContextServerId, @@ -76,10 +74,6 @@ impl ConfigurationSource { !matches!(self, ConfigurationSource::Extension { editor: None, .. }) } - fn is_new(&self) -> bool { - matches!(self, ConfigurationSource::New { .. }) - } - fn from_target( target: ConfigurationTarget, language_registry: Arc, @@ -106,10 +100,6 @@ impl ConfigurationSource { } match target { - ConfigurationTarget::New => ConfigurationSource::New { - editor: create_editor(context_server_input(None), jsonc_language, window, cx), - is_http: false, - }, ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing { editor: create_editor( context_server_input(Some((id, command))), @@ -117,7 +107,7 @@ impl ConfigurationSource { window, cx, ), - is_http: false, + server_type: ExistingServerType::Local, }, ConfigurationTarget::ExistingHttp { id, @@ -131,7 +121,7 @@ impl ConfigurationSource { window, cx, ), - is_http: true, + server_type: ExistingServerType::Remote, }, ConfigurationTarget::Extension { @@ -169,9 +159,11 @@ impl ConfigurationSource { fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> { match self { - ConfigurationSource::New { editor, is_http } - | ConfigurationSource::Existing { editor, is_http } => { - if *is_http { + ConfigurationSource::Existing { + editor, + server_type, + } => match *server_type { + ExistingServerType::Remote => { parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| { ( id, @@ -184,7 +176,8 @@ impl ConfigurationSource { }, ) }) - } else { + } + ExistingServerType::Local => { parse_input(&editor.read(cx).text(cx)).map(|(id, command)| { ( id, @@ -196,7 +189,7 @@ impl ConfigurationSource { ) }) } - } + }, ConfigurationSource::Extension { id, editor, @@ -385,7 +378,12 @@ fn resolve_context_server_extension( return Task::ready(None); }; - let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx); + let extension = ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .iter() + .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0)) + .map(|(id, entry)| (id.clone(), entry.manifest.clone())); cx.spawn(async move |cx| { let installation = descriptor .configuration(worktree_store, cx) @@ -436,13 +434,10 @@ impl ConfigureContextServerModal { target: &ConfigurationTarget, cx: &App, ) -> State { - let Some(server_id) = (match target { + let server_id = match target { ConfigurationTarget::Existing { id, .. } | ConfigurationTarget::ExistingHttp { id, .. } - | ConfigurationTarget::Extension { id, .. } => Some(id), - ConfigurationTarget::New => None, - }) else { - return State::Idle; + | ConfigurationTarget::Extension { id, .. } => id, }; match context_server_store.read(cx).status_for_server(server_id) { @@ -467,31 +462,6 @@ impl ConfigureContextServerModal { } } - pub fn register( - workspace: &mut Workspace, - language_registry: Arc, - _window: Option<&mut Window>, - _cx: &mut Context, - ) { - workspace.register_action({ - move |_workspace, _: &AddContextServer, window, cx| { - let workspace_handle = cx.weak_entity(); - let language_registry = language_registry.clone(); - window - .spawn(cx, async move |cx| { - Self::show_modal( - ConfigurationTarget::New, - language_registry, - workspace_handle, - cx, - ) - .await - }) - .detach_and_log_err(cx); - } - }); - } - pub fn show_modal_for_existing_server( server_id: ContextServerId, language_registry: Arc, @@ -576,12 +546,11 @@ impl ConfigureContextServerModal { workspace: workspace_handle, state: Self::initial_state(&context_server_store, &target, cx), - original_server_id: match &target { - ConfigurationTarget::Existing { id, .. } => Some(id.clone()), - ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), - ConfigurationTarget::Extension { id, .. } => Some(id.clone()), - ConfigurationTarget::New => None, - }, + original_server_id: Some(match &target { + ConfigurationTarget::Existing { id, .. } + | ConfigurationTarget::ExistingHttp { id, .. } + | ConfigurationTarget::Extension { id, .. } => id.clone(), + }), source: ConfigurationSource::from_target( target, language_registry, @@ -811,7 +780,6 @@ impl ModalView for ConfigureContextServerModal {} impl Focusable for ConfigureContextServerModal { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.source { - ConfigurationSource::New { editor, .. } => editor.focus_handle(cx), ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx), ConfigurationSource::Extension { editor, .. } => editor .as_ref() @@ -826,7 +794,6 @@ impl EventEmitter for ConfigureContextServerModal {} impl ConfigureContextServerModal { fn render_modal_header(&self) -> ModalHeader { let text: SharedString = match &self.source { - ConfigurationSource::New { .. } => "Add MCP Server".into(), ConfigurationSource::Existing { .. } => "Configure MCP Server".into(), ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(), }; @@ -857,70 +824,8 @@ impl ConfigureContextServerModal { } } - fn render_tab_bar(&self, cx: &mut Context) -> Option { - let is_http = match &self.source { - ConfigurationSource::New { is_http, .. } => *is_http, - _ => return None, - }; - - let tab = |label: &'static str, active: bool| { - div() - .id(label) - .cursor_pointer() - .p_1() - .text_sm() - .border_b_1() - .when(active, |this| { - this.border_color(cx.theme().colors().border_focused) - }) - .when(!active, |this| { - this.border_color(gpui::transparent_black()) - .text_color(cx.theme().colors().text_muted) - .hover(|s| s.text_color(cx.theme().colors().text)) - }) - .child(label) - }; - - Some( - h_flex() - .pt_1() - .mb_2p5() - .gap_1() - .border_b_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .child( - tab("Local", !is_http).on_click(cx.listener(|this, _, window, cx| { - if let ConfigurationSource::New { editor, is_http } = &mut this.source { - if *is_http { - *is_http = false; - let new_text = context_server_input(None); - editor.update(cx, |editor, cx| { - editor.set_text(new_text, window, cx); - }); - } - } - })), - ) - .child( - tab("Remote", is_http).on_click(cx.listener(|this, _, window, cx| { - if let ConfigurationSource::New { editor, is_http } = &mut this.source { - if !*is_http { - *is_http = true; - let new_text = context_server_http_input(None); - editor.update(cx, |editor, cx| { - editor.set_text(new_text, window, cx); - }); - } - } - })), - ) - .into_any_element(), - ) - } - fn render_modal_content(&self, cx: &App) -> AnyElement { let editor = match &self.source { - ConfigurationSource::New { editor, .. } => editor, ConfigurationSource::Existing { editor, .. } => editor, ConfigurationSource::Extension { editor, .. } => { let Some(editor) = editor else { @@ -1020,24 +925,15 @@ impl ConfigureContextServerModal { ), ) .children(self.source.has_configuration_options().then(|| { - Button::new( - "add-server", - if self.source.is_new() { - "Add Server" - } else { - "Configure Server" - }, - ) - .disabled(is_busy) - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click( - cx.listener(|this, _event, _window, cx| { + Button::new("configure-server", "Configure Server") + .disabled(is_busy) + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _event, _window, cx| { this.confirm(&menu::Confirm, cx) - }), - ) + })) })), ) } @@ -1245,7 +1141,6 @@ impl Render for ConfigureContextServerModal { .overflow_y_scroll() .track_scroll(&self.scroll_handle) .child(self.render_modal_description(window, cx)) - .children(self.render_tab_bar(cx)) .child(self.render_modal_content(cx)) .child(match &self.state { State::Idle => div(), 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 deleted file mode 100644 index 5115e2f70c0ae8..00000000000000 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs +++ /dev/null @@ -1,175 +0,0 @@ -use agent::ContextServerRegistry; -use collections::HashMap; -use context_server::ContextServerId; -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Window, prelude::*, -}; -use ui::{Divider, DividerColor, Modal, ModalHeader, WithScrollbar, prelude::*}; -use workspace::{ModalView, Workspace}; - -pub struct ConfigureContextServerToolsModal { - context_server_id: ContextServerId, - context_server_registry: Entity, - focus_handle: FocusHandle, - expanded_tools: HashMap, - scroll_handle: ScrollHandle, -} - -impl ConfigureContextServerToolsModal { - fn new( - context_server_id: ContextServerId, - context_server_registry: Entity, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - context_server_id, - context_server_registry, - focus_handle: cx.focus_handle(), - expanded_tools: HashMap::default(), - scroll_handle: ScrollHandle::new(), - } - } - - pub fn toggle( - context_server_id: ContextServerId, - context_server_registry: Entity, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - workspace.toggle_modal(window, cx, |window, cx| { - Self::new(context_server_id, context_server_registry, window, cx) - }); - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent) - } - - fn render_modal_content( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let tools = self - .context_server_registry - .read(cx) - .tools_for_server(&self.context_server_id) - .collect::>(); - - div() - .size_full() - .pb_2() - .child( - v_flex() - .id("modal_content") - .px_2() - .gap_1() - .max_h_128() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .children(tools.iter().enumerate().flat_map(|(index, tool)| { - let tool_name = tool.name(); - let is_expanded = self - .expanded_tools - .get(tool_name.as_ref()) - .copied() - .unwrap_or(false); - - let icon = if is_expanded { - IconName::ChevronUp - } else { - IconName::ChevronDown - }; - - let mut items = vec![ - v_flex() - .child( - h_flex() - .id(format!("tool-header-{}", index)) - .py_1() - .pl_1() - .pr_2() - .w_full() - .justify_between() - .rounded_sm() - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child( - Label::new(tool_name.clone()) - .buffer_font(cx) - .size(LabelSize::Small), - ) - .child( - Icon::new(icon) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(cx.listener({ - move |this, _event, _window, _cx| { - let current = this - .expanded_tools - .get(tool_name.as_ref()) - .copied() - .unwrap_or(false); - this.expanded_tools - .insert(tool_name.clone(), !current); - _cx.notify(); - } - })), - ) - .when(is_expanded, |this| { - this.child( - Label::new(tool.description()).color(Color::Muted).mx_1(), - ) - }) - .into_any_element(), - ]; - - if index < tools.len() - 1 { - items.push( - h_flex() - .w_full() - .child(Divider::horizontal().color(DividerColor::BorderVariant)) - .into_any_element(), - ); - } - - items - })), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - .into_any_element() - } -} - -impl ModalView for ConfigureContextServerToolsModal {} - -impl Focusable for ConfigureContextServerToolsModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for ConfigureContextServerToolsModal {} - -impl Render for ConfigureContextServerToolsModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .key_context("ContextServerToolsModal") - .occlude() - .elevation_3(cx) - .w(rems(34.)) - .on_action(cx.listener(Self::cancel)) - .track_focus(&self.focus_handle) - .child( - Modal::new("configure-context-server-tools", None::) - .header( - ModalHeader::new() - .headline(format!("Tools from {}", self.context_server_id.0)) - .show_dismiss_button(true), - ) - .child(self.render_modal_content(window, cx)), - ) - } -} 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 7cafc7ad57bf7d..c01939a31ace7b 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -295,7 +295,7 @@ impl ManageProfilesModal { window, cx, ) - .modal(false) + .embedded() }); let dismiss_subscription = cx.subscribe_in(&model_picker, window, { diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index 9fc17944197794..edb510d6f7e2b1 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -25,7 +25,7 @@ impl ToolPicker { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); Self { picker } } @@ -34,7 +34,7 @@ impl ToolPicker { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::list(delegate, window, cx).embedded()); Self { picker } } } @@ -49,7 +49,7 @@ impl Focusable for ToolPicker { impl Render for ToolPicker { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + v_flex().child(self.picker.clone()) } } @@ -155,6 +155,10 @@ impl ToolPickerDelegate { impl PickerDelegate for ToolPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "tool picker" + } + fn match_count(&self) -> usize { self.filtered_items.len() } diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 316c7aaeeb54d1..524cb0b07473f4 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -6,8 +6,8 @@ use anyhow::Result; use buffer_diff::DiffHunkStatus; use collections::{HashMap, HashSet}; use editor::{ - Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, MultiBufferSnapshot, - SelectionEffects, SplittableEditor, ToPoint, + DiffHunkDelegate, Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, + MultiBufferSnapshot, ResolvedDiffHunks, SelectionEffects, SplittableEditor, ToPoint, actions::{GoToHunk, GoToPreviousHunk}, multibuffer_context_lines, scroll::Autoscroll, @@ -28,12 +28,12 @@ use std::{ ops::Range, sync::Arc, }; -use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider}; -use util::ResultExt; +use ui::{CommonAnimationExt, Divider, IconButtonShape, KeyBinding, Tooltip, prelude::*}; +use util::{ResultExt, truncate_and_trailoff}; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, - item::{ItemEvent, SaveOptions, TabContentParams}, + item::{ItemEvent, SaveOptions, TabContentParams, TabTooltipContent}, searchable::SearchableItemHandle, }; use zed_actions::assistant::ToggleFocus; @@ -101,8 +101,7 @@ impl AgentDiffPane { cx, ); diff_display_editor - .set_render_diff_hunk_controls(diff_hunk_controls(&thread, workspace.clone()), cx); - diff_display_editor.set_render_diff_hunks_as_unstaged(cx); + .set_diff_hunk_delegate(Some(agent_diff_delegate(&thread, workspace.clone())), cx); diff_display_editor.update_editors(cx, |editor, _cx| { editor.register_addon(AgentDiffAddon); }); @@ -529,23 +528,33 @@ impl Item for AgentDiffPane { .update(cx, |editor, cx| editor.navigate(data, window, cx)) } - fn tab_tooltip_text(&self, _: &App) -> Option { - Some("Agent Diff".into()) + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + let label_content = self.tab_content_text(params.detail.unwrap_or_default(), cx); + + Label::new(label_content) + .when(!params.selected, |this| this.color(Color::Muted)) + .into_any_element() } - fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + fn tab_tooltip_content(&self, cx: &App) -> Option { let title = self.thread.read(cx).title(); - Label::new(if let Some(title) = title { - format!("Review: {}", title) - } else { - "Review".to_string() - }) - .color(if params.selected { - Color::Default - } else { - Color::Muted - }) - .into_any_element() + + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + let title = title.map(|title| title.to_string()); + + move |_, _| { + v_flex() + .child(Label::new( + title.clone().unwrap_or_else(|| "Review".to_string()), + )) + .child( + Label::new("Agent Diff") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } + })))) } fn telemetry_event_text(&self) -> Option<&'static str> { @@ -667,8 +676,11 @@ impl Item for AgentDiffPane { }); } - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Agent Diff".into() + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + match self.thread.read(cx).title() { + Some(title) => format!("Review: {}", truncate_and_trailoff(&title, 20)).into(), + None => "Review".into(), + } } } @@ -722,29 +734,68 @@ impl Render for AgentDiffPane { } } -fn diff_hunk_controls( +struct AgentDiffDelegate { + thread: Entity, + workspace: WeakEntity, +} + +fn agent_diff_delegate( thread: &Entity, workspace: WeakEntity, -) -> editor::RenderDiffHunkControlsFn { - let thread = thread.clone(); +) -> Arc { + Arc::new(AgentDiffDelegate { + thread: thread.clone(), + workspace, + }) +} - Arc::new( - move |row, status, hunk_range, is_created_file, line_height, editor, _, cx| { - { - render_diff_hunk_controls( - row, - status, - hunk_range, - is_created_file, - line_height, - &thread, - editor, - workspace.clone(), - cx, - ) - } - }, - ) +impl DiffHunkDelegate for AgentDiffDelegate { + fn toggle( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn stage_or_unstage( + &self, + _stage: bool, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + render_diff_hunk_controls( + row, + status, + hunk_range, + is_created_file, + line_height, + &self.thread, + editor, + self.workspace.clone(), + cx, + ) + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } } fn render_diff_hunk_controls( @@ -1099,7 +1150,7 @@ impl Render for AgentDiffToolbar { }), ) .into_any_element(), - vertical_divider().into_any_element(), + Divider::vertical().into_any_element(), h_flex() .gap_0p5() .child( @@ -1141,7 +1192,7 @@ impl Render for AgentDiffToolbar { .mr_1() .gap_1() .children(content) - .child(vertical_divider()) + .child(Divider::vertical()) .when_some(editor.read(cx).workspace(), |this, _workspace| { this.child( IconButton::new("review", IconName::ListTodo) @@ -1158,7 +1209,7 @@ impl Render for AgentDiffToolbar { }), ) }) - .child(vertical_divider()) + .child(Divider::vertical()) .on_action({ let editor = editor.clone(); move |_action: &OpenAgentDiff, window, cx| { @@ -1431,11 +1482,14 @@ impl AgentDiff { self.update_reviewing_editors(workspace, window, cx); } AcpThreadEvent::TitleUpdated + | AcpThreadEvent::StatusChanged | AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::SubagentSpawned(_) | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::ToolAuthorizationRequested(_) | AcpThreadEvent::ToolAuthorizationReceived(_) + | AcpThreadEvent::ElicitationRequested(_) + | AcpThreadEvent::ElicitationResponded(_) | AcpThreadEvent::PromptCapabilitiesUpdated | AcpThreadEvent::AvailableCommandsUpdated(_) | AcpThreadEvent::Retry(_) @@ -1525,7 +1579,7 @@ impl AgentDiff { for (editor, _) in self.reviewing_editors.drain() { editor .update(cx, |editor, cx| { - editor.end_temporary_diff_override(cx); + editor.set_diff_hunk_delegate(None, cx); editor.unregister_addon::(); }) .ok(); @@ -1574,12 +1628,10 @@ impl AgentDiff { if previous_state.is_none() { editor.update(cx, |editor, cx| { - editor.start_temporary_diff_override(); - editor.set_render_diff_hunk_controls( - diff_hunk_controls(&thread, workspace.clone()), + editor.set_diff_hunk_delegate( + Some(agent_diff_delegate(&thread, workspace.clone())), cx, ); - editor.set_render_diff_hunks_as_unstaged(true, cx); editor.set_expand_all_diff_hunks(cx); editor.register_addon(EditorAgentDiffAddon); }); @@ -1626,7 +1678,7 @@ impl AgentDiff { if in_workspace { editor .update(cx, |editor, cx| { - editor.end_temporary_diff_override(cx); + editor.set_diff_hunk_delegate(None, cx); editor.unregister_addon::(); }) .ok(); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 441524c249d084..8b4a69ffd0445f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -12,7 +12,7 @@ use std::{ use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus, line_range_suffix}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use agent_settings::UserAgentsMd; use collections::HashSet; @@ -20,14 +20,13 @@ use db::kvp::{Dismissable, KeyValueStore}; use itertools::Itertools; use project::{AgentId, ProjectItem}; use serde::{Deserialize, Serialize}; -use settings::{LanguageModelProviderSetting, LanguageModelSelection}; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, agent::{ AddSelectionToThread, ConflictContent, LogoutAgent, OpenSettings, ReauthenticateAgent, ResetAgentZoom, ResetOnboarding, ResolveConflictedFilesWithAgent, - ResolveConflictsWithAgent, ReviewBranchDiff, + ResolveConflictsWithAgent, ReviewBranchDiff, SelectAgent, }, assistant::{ FocusAgent, ManageSkills, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules, Toggle, @@ -45,20 +44,19 @@ use crate::terminal_thread_metadata_store::{ }; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent}; use crate::{ - AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, - LoadThreadFromClipboard, NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, - OpenAgentDiff, ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell, - ShowAllSidebarThreadMetadata, ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu, - agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, + Agent, AgentInitialContent, AgentThreadSource, ExternalSourcePrompt, NewExternalAgentThread, + NewNativeAgentThreadFromSummary, +}; +use crate::{ + AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, LoadThreadFromClipboard, + NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, ResetFastModeWarnings, + ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata, + ToggleNewThreadMenu, ToggleOptionsMenu, conversation_view::{ AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings, }, ui::{AgentNotification, AgentNotificationEvent, EndTrialUpsell}, }; -use crate::{ - Agent, AgentInitialContent, AgentThreadSource, ExternalSourcePrompt, NewExternalAgentThread, - NewNativeAgentThreadFromSummary, -}; #[cfg(feature = "external_websocket_sync")] use external_websocket_sync_dep as external_websocket_sync; #[cfg(feature = "external_websocket_sync")] @@ -74,11 +72,10 @@ use cloud_api_types::Plan; use collections::HashMap; use editor::{Editor, MultiBuffer}; use extension_host::ExtensionStore; -use feature_flags::{ - AgentSettingsUiFeatureFlag, CreateThreadToolFeatureFlag, FeatureFlagAppExt as _, -}; +use feature_flags::{CreateThreadToolFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; +use futures::FutureExt as _; use gpui::{ Action, Anchor, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, @@ -92,6 +89,7 @@ use project::{Project, ProjectPath, Worktree}; use settings::TerminalDockPosition; use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file}; +use search::{BufferSearchBar, buffer_search::Deploy as DeployBufferSearch}; use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings}; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::OffsetRangeExt; @@ -103,9 +101,9 @@ use ui::{ use util::ResultExt as _; use workspace::{ CollaboratorId, DraggedSelection, DraggedTab, MultiWorkspace, PathList, SerializedPathList, - ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, + ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, - item::ItemEvent, + item::{ItemEvent, ItemHandle}, }; const AGENT_PANEL_KEY: &str = "agent_panel"; @@ -113,6 +111,7 @@ const MIN_PANEL_WIDTH: Pixels = px(300.); const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; const LAST_CREATED_ENTRY_KIND_KEY: &str = "agent_panel__last_created_entry_kind"; const TERMINAL_AGENT_TELEMETRY_ID: &str = "terminal"; +const TERMINAL_INIT_COMMAND_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); const KNOWN_TERMINAL_AGENT_COMMANDS: &[&str] = &[ "agent", // Unfortunately, both Cursor cli + grok "agy", @@ -243,7 +242,7 @@ fn project_agents_md_path( require_existing_file: bool, cx: &App, ) -> Option { - let rel_path = util::rel_path::RelPath::unix("AGENTS.md").ok()?; + let rel_path = util::rel_path::RelPath::from_unix_str("AGENTS.md").ok()?; project .read(cx) .visible_worktrees(cx) @@ -428,6 +427,14 @@ pub fn init(cx: &mut App) { }); } }) + .register_action(|workspace, action: &SelectAgent, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + let agent = AgentId::new(action.agent.clone()).into(); + panel.select_agent(agent, window, cx); + }); + } + }) .register_action(|workspace, action: &ManageSkills, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -741,7 +748,17 @@ pub fn init(cx: &mut App) { }); }); }, - ); + ) + .register_action(|workspace, _: &menu::Cancel, _window, cx| { + if let Some(panel) = workspace.panel::(cx) { + let dismissed = + panel.update(cx, |panel, cx| panel.dismiss_all_notifications(cx)); + if dismissed { + return; + } + } + cx.propagate(); + }); }, ) .detach(); @@ -987,6 +1004,7 @@ struct AgentTerminal { working_directory: Option, created_at: DateTime, has_notification: bool, + search_bar: Option>, notification_windows: Vec>, notification_subscriptions: Vec, _subscriptions: Vec, @@ -1116,15 +1134,10 @@ impl From for BaseView { } } -enum OverlayView { - Configuration, -} - enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), Terminal(&'a Entity), - Configuration(Option<&'a Entity>), } enum WhichFontSize { @@ -1141,14 +1154,6 @@ impl BaseView { } } -impl OverlayView { - pub fn which_font_size_used(&self) -> WhichFontSize { - match self { - OverlayView::Configuration => WhichFontSize::None, - } - } -} - pub struct AgentPanel { workspace: WeakEntity, /// Workspace id is used as a database key @@ -1160,12 +1165,9 @@ pub struct AgentPanel { thread_store: Entity, connection_store: Entity, context_server_registry: Entity, - configuration: Option>, - configuration_subscription: Option, focus_handle: FocusHandle, base_view: BaseView, last_created_entry_kind: AgentPanelEntryKind, - overlay_view: Option, draft_thread: Option>, retained_threads: HashMap>, terminals: HashMap, @@ -1794,15 +1796,12 @@ impl AgentPanel { workspace_id, base_view, last_created_entry_kind: AgentPanelEntryKind::Thread, - overlay_view: None, workspace, user_store, project: project.clone(), fs: fs.clone(), language_registry, connection_store, - configuration: None, - configuration_subscription: None, focus_handle: cx.focus_handle(), context_server_registry, draft_thread: None, @@ -1978,7 +1977,6 @@ impl AgentPanel { pub fn clear_base_view(&mut self, window: &mut Window, cx: &mut Context) { let old_view = std::mem::replace(&mut self.base_view, BaseView::Uninitialized); self.retain_running_thread(old_view, cx); - self.clear_overlay_state(); self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); self.serialize(cx); cx.emit(AgentPanelEvent::ActiveViewChanged); @@ -2161,6 +2159,43 @@ impl AgentPanel { self.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); } + fn set_selected_agent_and_persist(&mut self, agent: Agent, cx: &mut Context) { + if self.selected_agent != agent { + self.selected_agent = agent.clone(); + self.serialize(cx); + } + + cx.background_spawn({ + let kvp = KeyValueStore::global(cx); + async move { + write_global_last_used_agent(kvp, agent).await; + } + }) + .detach(); + } + + /// Sets the panel's selected agent without opening the panel or focusing + /// it, so the agent is launched the next time the panel is opened (or + /// right away, if the panel is already showing the empty new-thread + /// draft). + pub fn select_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { + if self.project.read(cx).is_via_collab() && !agent.is_native() { + return; + } + + let showing_new_draft = matches!( + (&self.base_view, &self.draft_thread), + (BaseView::AgentThread { conversation_view }, Some(draft)) + if conversation_view.entity_id() == draft.entity_id() + ); + + if matches!(self.base_view, BaseView::AgentThread { .. }) && showing_new_draft { + self.set_selected_agent_and_persist(agent, cx); + self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + cx.notify(); + } + } + pub fn new_terminal( &mut self, workspace: Option<&Workspace>, @@ -2270,7 +2305,10 @@ impl AgentPanel { this.update_in(cx, |this, window, cx| { let terminal_for_init_command = terminal.clone(); let terminal_view = cx.new(|cx| { - TerminalView::new(terminal, workspace, workspace_id, project, window, cx) + let mut view = + TerminalView::new(terminal, workspace, workspace_id, project, window, cx); + view.set_show_workspace_actions(false, cx); + view }); this.insert_terminal( terminal_id, @@ -2296,25 +2334,63 @@ impl AgentPanel { run_init_command .then(|| AgentSettings::get_global(cx).terminal_init_command.clone()) .flatten() + .filter(|command| !command.trim().is_empty()) } fn write_terminal_init_command( terminal: &Entity, init_command: Option, - cx: &mut App, + cx: &mut Context, ) { let Some(command) = init_command else { return; }; + + if !terminal.read(cx).is_pty() { + terminal.update(cx, |terminal, _| { + terminal.write_init_command(Self::terminal_init_command_input(command)) + }); + return; + } + + let startup = terminal.update(cx, |terminal, _| { + terminal.start_init_command_startup_handshake() + }); + + let terminal = terminal.downgrade(); + cx.spawn(async move |_this, cx| { + // Fall back to the timeout so the init command is still delivered if + // the shell never echoes the marker. + let timeout = cx + .background_executor() + .timer(TERMINAL_INIT_COMMAND_STARTUP_TIMEOUT); + futures::select_biased! { + _ = startup.fuse() => {} + _ = timeout.fuse() => {} + } + + let input = Self::terminal_init_command_input(command); + if let Err(error) = terminal.update(cx, move |terminal, cx| { + if !terminal.write_init_command_after_startup(input, cx) { + log::debug!( + "skipping terminal init command because the terminal is no longer eligible" + ); + } + }) { + log::debug!("skipping terminal init command because the terminal closed: {error}"); + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn terminal_init_command_input(command: String) -> Vec { let mut input = command.into_bytes(); // CR, not "\r\n": "\r\n" puts PowerShell into continuation // mode (same convention as the activation-script writes in // `TerminalBuilder::new`). input.push(b'\x0d'); - // `write_init_command`, not `input`: the latter sets `keyboard_input_sent`, - // which would auto-close the terminal (hiding the error) if the shell - // fails to spawn after we've written the command. - terminal.update(cx, |terminal, _| terminal.write_init_command(input)); + input } fn insert_terminal( @@ -2360,7 +2436,7 @@ impl AgentPanel { } TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx), TerminalEvent::CloseTerminal => { - this.close_terminal_from_terminal_event(terminal_id, window, cx); + this.request_close_terminal_from_terminal_event(terminal_id, cx); } TerminalEvent::BlinkChanged(_) | TerminalEvent::SelectionsChanged @@ -2383,6 +2459,7 @@ impl AgentPanel { working_directory, created_at: created_at.unwrap_or_else(Utc::now), has_notification: false, + search_bar: None, notification_windows: Vec::new(), notification_subscriptions: Vec::new(), _subscriptions: vec![view_subscription, terminal_subscription], @@ -2430,7 +2507,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - self.close_terminal_internal(terminal_id, true, None, window, cx); + self.close_terminal_internal(terminal_id, true, window, cx); } pub fn close_terminal_without_activating_draft( @@ -2439,14 +2516,13 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - self.close_terminal_internal(terminal_id, false, None, window, cx); + self.close_terminal_internal(terminal_id, false, window, cx); } fn close_terminal_internal( &mut self, terminal_id: TerminalId, activate_draft_after_close: bool, - terminal_closed_metadata: Option, window: &mut Window, cx: &mut Context, ) { @@ -2472,21 +2548,18 @@ impl AgentPanel { } } - if let Some(metadata) = terminal_closed_metadata { - cx.emit(AgentPanelEvent::TerminalClosed { metadata }); - } cx.emit(AgentPanelEvent::EntryChanged); cx.notify(); } - fn close_terminal_from_terminal_event( + fn request_close_terminal_from_terminal_event( &mut self, terminal_id: TerminalId, - window: &mut Window, cx: &mut Context, ) { - let metadata = self.terminal_metadata(terminal_id, cx); - self.close_terminal_internal(terminal_id, false, metadata, window, cx); + if let Some(metadata) = self.terminal_metadata(terminal_id, cx) { + cx.emit(AgentPanelEvent::TerminalCloseRequested { metadata }); + } } fn emit_terminal_thread_started( @@ -2820,11 +2893,13 @@ impl AgentPanel { let settings = AgentSettings::get_global(cx); match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { + window.request_attention(); if let Some(primary) = cx.primary_display() { self.pop_up_terminal_notification(terminal_id, &title, primary, window, cx); } } NotifyWhenAgentWaiting::AllScreens => { + window.request_attention(); for screen in cx.displays() { self.pop_up_terminal_notification(terminal_id, &title, screen, window, cx); } @@ -2926,7 +3001,7 @@ impl AgentPanel { this.dismiss_terminal_pop_up_if_visible(terminal_id, &pop_up_weak, window, cx); } AgentPanelEvent::EntryChanged - | AgentPanelEvent::TerminalClosed { .. } + | AgentPanelEvent::TerminalCloseRequested { .. } | AgentPanelEvent::ThreadInteracted { .. } => {} } }); @@ -2972,6 +3047,22 @@ impl AgentPanel { } } + pub fn dismiss_all_notifications(&mut self, cx: &mut Context) -> bool { + let mut dismissed = false; + for conversation_view in self.conversation_views() { + dismissed |= conversation_view.update(cx, |view, cx| view.dismiss_notifications(cx)); + } + let had_terminal_notifications = self + .terminals + .values() + .any(|t| !t.notification_windows.is_empty()); + if had_terminal_notifications { + self.dismiss_all_terminal_notifications(cx); + dismissed = true; + } + dismissed + } + fn active_terminal_visible(&self, terminal_id: TerminalId, window: &Window, cx: &App) -> bool { if !window.is_window_active() { return false; @@ -3097,16 +3188,8 @@ impl AgentPanel { let draft = self.ensure_draft(source, window, cx); if let BaseView::AgentThread { conversation_view } = &self.base_view { if conversation_view.entity_id() == draft.entity_id() { - // If we're already viewing the draft as the base view but an - // overlay (e.g. Settings) is covering it, clear the overlay - // so the user actually sees the draft they asked for. - // Otherwise pressing "New Thread" from the Settings panel is - // a silent no-op because the early return below would leave - // the overlay on top of the draft. - if self.overlay_view.is_some() { - self.clear_overlay(focus, window, cx); - } else if focus { - self.focus_handle(cx).focus(window, cx); + if focus { + self.activation_focus_handle(cx).focus(window, cx); } return; } @@ -3378,18 +3461,7 @@ impl AgentPanel { cx, ); if let Some(original) = saved_selected_agent { - if self.selected_agent != original { - self.selected_agent = original.clone(); - self.serialize(cx); - // Restore the last-used-agent in persistent storage as well. - cx.background_spawn({ - let kvp = KeyValueStore::global(cx); - async move { - write_global_last_used_agent(kvp, original).await; - } - }) - .detach(); - } + self.set_selected_agent_and_persist(original, cx); } let thread_id = thread.conversation_view.read(cx).thread_id; self.retained_threads @@ -3471,7 +3543,6 @@ impl AgentPanel { } if self.active_thread_id(cx) == Some(id) { - self.clear_overlay_state(); if activate_draft_after_remove { self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); } else { @@ -3712,17 +3783,10 @@ impl AgentPanel { active_thread.update(cx, |active_thread, cx| { active_thread.expand_message_editor(&ExpandMessageEditor, window, cx); - active_thread.focus_handle(cx).focus(window, cx); + active_thread.activation_focus_handle(cx).focus(window, cx); }) } - pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { - if self.overlay_view.is_some() { - self.clear_overlay(true, window, cx); - cx.notify(); - } - } - pub fn toggle_options_menu( &mut self, _: &ToggleOptionsMenu, @@ -3833,64 +3897,20 @@ impl AgentPanel { cx.emit(PanelEvent::ZoomOut); } else { if !self.focus_handle(cx).contains_focused(window, cx) { - cx.focus_self(window); + self.activation_focus_handle(cx).focus(window, cx); } cx.emit(PanelEvent::ZoomIn); } } pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) { - // When the agent settings have been moved into the settings UI, the - // panel no longer shows its own configuration overlay. Instead, route to - // the settings UI at the LLM providers page (where the model selector's - // "Configure" button expects to land). - if cx.has_flag::() { - window.dispatch_action( - Box::new(zed_actions::OpenSettingsAt { - path: "llm_providers".to_string(), - target: None, - }), - cx, - ); - return; - } - - if matches!(self.overlay_view, Some(OverlayView::Configuration)) { - self.clear_overlay(true, window, cx); - return; - } - - let agent_server_store = self.project.read(cx).agent_server_store().clone(); - let context_server_store = self.project.read(cx).context_server_store(); - let fs = self.fs.clone(); - - self.configuration = Some(cx.new(|cx| { - AgentConfiguration::new( - fs, - agent_server_store, - self.connection_store.clone(), - context_server_store, - self.context_server_registry.clone(), - self.language_registry.clone(), - self.workspace.clone(), - window, - cx, - ) - })); - - if let Some(configuration) = self.configuration.as_ref() { - self.configuration_subscription = Some(cx.subscribe_in( - configuration, - window, - Self::handle_agent_configuration_event, - )); - } - - self.set_overlay(OverlayView::Configuration, true, window, cx); - - if let Some(configuration) = self.configuration.as_ref() { - configuration.focus_handle(cx).focus(window, cx); - } + window.dispatch_action( + Box::new(zed_actions::OpenSettingsPage { + page: "AI".to_string(), + target: None, + }), + cx, + ); } pub(crate) fn open_active_thread_as_markdown( @@ -4173,53 +4193,6 @@ impl AgentPanel { .detach_and_log_err(cx); } - fn handle_agent_configuration_event( - &mut self, - _entity: &Entity, - event: &AssistantConfigurationEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - AssistantConfigurationEvent::NewThread(provider) => { - if LanguageModelRegistry::read_global(cx) - .default_model() - .is_none_or(|model| model.provider.id() != provider.id()) - && let Some(model) = provider.default_model(cx) - { - update_settings_file(self.fs.clone(), cx, move |settings, _| { - let provider = model.provider_id().0.to_string(); - let enable_thinking = model.supports_thinking(); - let effort = model - .default_effort_level() - .map(|effort| effort.value.to_string()); - let model = model.id().0.to_string(); - settings - .agent - .get_or_insert_default() - .set_model(LanguageModelSelection { - provider: LanguageModelProviderSetting(provider), - model, - enable_thinking, - effort, - speed: None, - }) - }); - } - - self.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); - if let Some((thread, model)) = self - .active_native_agent_thread(cx) - .zip(provider.default_model(cx)) - { - thread.update(cx, |thread, cx| { - thread.set_model(model, cx); - }); - } - } - } - } - pub fn workspace_id(&self) -> Option { self.workspace_id } @@ -4242,6 +4215,44 @@ impl AgentPanel { } } + pub fn visible_terminal_view(&self) -> Option<&Entity> { + match self.visible_surface() { + VisibleSurface::Terminal(terminal_view) => Some(terminal_view), + _ => None, + } + } + + fn toggle_terminal_thread_search( + &mut self, + _: &crate::ToggleSearch, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get_mut(&terminal_id)) + else { + cx.propagate(); + return; + }; + + let terminal_view = terminal.view.clone(); + let search_bar = terminal + .search_bar + .get_or_insert_with(|| cx.new(|cx| BufferSearchBar::new(None, window, cx))) + .clone(); + let deployed = search_bar.update(cx, |search_bar, cx| { + let terminal_item: &dyn ItemHandle = &terminal_view; + search_bar.set_active_pane_item(Some(terminal_item), window, cx); + search_bar.deploy(&DeployBufferSearch::find(), None, window, cx) + }); + if deployed { + cx.stop_propagation(); + } else { + cx.propagate(); + } + } + pub fn conversation_view_for_id( &self, thread_id: &ThreadId, @@ -4445,8 +4456,6 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - self.clear_overlay_state(); - let old_view = std::mem::replace(&mut self.base_view, new_view); self.retain_running_thread(old_view, cx); @@ -4462,40 +4471,11 @@ impl AgentPanel { self.refresh_base_view_subscriptions(window, cx); if focus { - self.focus_handle(cx).focus(window, cx); - } - cx.emit(AgentPanelEvent::ActiveViewChanged); - } - - fn set_overlay( - &mut self, - overlay: OverlayView, - focus: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.overlay_view = Some(overlay); - if focus { - self.focus_handle(cx).focus(window, cx); - } - cx.emit(AgentPanelEvent::ActiveViewChanged); - } - - fn clear_overlay(&mut self, focus: bool, window: &mut Window, cx: &mut Context) { - self.clear_overlay_state(); - - if focus { - self.focus_handle(cx).focus(window, cx); + self.activation_focus_handle(cx).focus(window, cx); } cx.emit(AgentPanelEvent::ActiveViewChanged); } - fn clear_overlay_state(&mut self) { - self.overlay_view = None; - self.configuration_subscription = None; - self.configuration = None; - } - fn refresh_base_view_subscriptions(&mut self, window: &mut Window, cx: &mut Context) { self._base_view_observation = match &self.base_view { BaseView::AgentThread { conversation_view } => { @@ -4548,14 +4528,6 @@ impl AgentPanel { } fn visible_surface(&self) -> VisibleSurface<'_> { - if let Some(overlay_view) = &self.overlay_view { - return match overlay_view { - OverlayView::Configuration => { - VisibleSurface::Configuration(self.configuration.as_ref()) - } - }; - } - match &self.base_view { BaseView::Uninitialized => VisibleSurface::Uninitialized, BaseView::AgentThread { conversation_view } => { @@ -4569,15 +4541,8 @@ impl AgentPanel { } } - fn is_overlay_open(&self) -> bool { - self.overlay_view.is_some() - } - fn visible_font_size(&self) -> WhichFontSize { - self.overlay_view.as_ref().map_or_else( - || self.base_view.which_font_size_used(), - OverlayView::which_font_size_used, - ) + self.base_view.which_font_size_used() } fn subscribe_to_active_thread_view( @@ -4687,7 +4652,10 @@ impl AgentPanel { if let BaseView::AgentThread { conversation_view } = &self.base_view { if observes_live(conversation_view, cx) { - self.clear_overlay_state(); + // No-op: the panel already observes the live entity. + // (Upstream 40d20036af / PR #59860 removed the in-panel + // AI-config overlay and with it `clear_overlay_state()` + // — there is no overlay state left to dismiss here.) cx.emit(AgentPanelEvent::ActiveViewChanged); return; } @@ -4748,7 +4716,6 @@ impl AgentPanel { // Check if the active view already holds this thread. if let BaseView::AgentThread { conversation_view } = &self.base_view { if conversation_view.read(cx).thread_id == thread_id { - self.clear_overlay_state(); cx.emit(AgentPanelEvent::ActiveViewChanged); return; } @@ -4894,19 +4861,7 @@ impl AgentPanel { let workspace = self.workspace.clone(); let project = self.project.clone(); - if self.selected_agent != agent { - self.selected_agent = agent.clone(); - self.serialize(cx); - } - - cx.background_spawn({ - let kvp = KeyValueStore::global(cx); - let agent = agent.clone(); - async move { - write_global_last_used_agent(kvp, agent).await; - } - }) - .detach(); + self.set_selected_agent_and_persist(agent.clone(), cx); let server = server_override .unwrap_or_else(|| agent.server(self.fs.clone(), self.thread_store.clone())); @@ -5296,19 +5251,8 @@ impl agent::SiblingThreadHost for AgentPanelSiblingHost { } impl Focusable for AgentPanel { - fn focus_handle(&self, cx: &App) -> FocusHandle { - match self.visible_surface() { - VisibleSurface::Uninitialized => self.focus_handle.clone(), - VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), - VisibleSurface::Terminal(terminal_view) => terminal_view.focus_handle(cx), - VisibleSurface::Configuration(configuration) => { - if let Some(configuration) = configuration { - configuration.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } - } + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() } } @@ -5320,7 +5264,7 @@ pub enum AgentPanelEvent { ActiveViewChanged, ActiveViewFocused, EntryChanged, - TerminalClosed { metadata: TerminalThreadMetadata }, + TerminalCloseRequested { metadata: TerminalThreadMetadata }, ThreadInteracted { thread_id: ThreadId }, } @@ -5336,10 +5280,21 @@ impl Panel for AgentPanel { AGENT_PANEL_KEY } + // HELIX: honour the `auto_open_panel` agent setting (porting guide item #26). fn starts_open(&self, _window: &Window, cx: &App) -> bool { AgentSettings::get_global(cx).auto_open_panel } + fn activation_focus_handle(&self, cx: &App) -> FocusHandle { + match self.visible_surface() { + VisibleSurface::Uninitialized => self.focus_handle.clone(), + VisibleSurface::AgentThread(conversation_view) => { + conversation_view.read(cx).activation_focus_handle(cx) + } + VisibleSurface::Terminal(terminal_view) => terminal_view.focus_handle(cx), + } + } + fn position(&self, _window: &Window, cx: &App) -> DockPosition { agent_panel_dock_position(cx) } @@ -5571,10 +5526,7 @@ impl AgentPanel { } fn destination_has_meaningful_state(&self, cx: &App) -> bool { - if self.overlay_view.is_some() - || !self.retained_threads.is_empty() - || !self.terminals.is_empty() - { + if !self.retained_threads.is_empty() || !self.terminals.is_empty() { return true; } @@ -5739,9 +5691,9 @@ impl AgentPanel { let is_generating_title = native_thread .as_ref() .is_some_and(|thread| thread.read(cx).is_generating_title()); - let title_generation_failed = native_thread + let title_generation_error = native_thread .as_ref() - .is_some_and(|thread| thread.read(cx).has_failed_title_generation()); + .and_then(|thread| thread.read(cx).title_generation_error()); if let Some(title_editor) = server_view_ref .root_thread_view() @@ -5766,7 +5718,10 @@ impl AgentPanel { let conversation_view = conversation_view.downgrade(); move |_: &menu::Confirm, window, cx| { if let Some(conversation_view) = conversation_view.upgrade() { - conversation_view.focus_handle(cx).focus(window, cx); + conversation_view + .read(cx) + .activation_focus_handle(cx) + .focus(window, cx); } } }) @@ -5774,13 +5729,16 @@ impl AgentPanel { let conversation_view = conversation_view.downgrade(); move |_: &editor::actions::Cancel, window, cx| { if let Some(conversation_view) = conversation_view.upgrade() { - conversation_view.focus_handle(cx).focus(window, cx); + conversation_view + .read(cx) + .activation_focus_handle(cx) + .focus(window, cx); } } }) .child(title_editor); - if title_generation_failed { + if let Some(title_generation_error) = title_generation_error { h_flex() .w_full() .gap_1() @@ -5789,7 +5747,14 @@ impl AgentPanel { IconButton::new("retry-thread-title", IconName::XCircle) .icon_color(Color::Error) .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Title generation failed. Retry")) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + "Title generation failed. Click to retry.", + None, + title_generation_error.clone(), + cx, + ) + }) .on_click({ let conversation_view = conversation_view.clone(); let workspace = self.workspace.clone(); @@ -5855,9 +5820,7 @@ impl AgentPanel { Label::new("Terminal").into_any_element() } } - VisibleSurface::Configuration(_) => { - Label::new("Settings").truncate().into_any_element() - } + VisibleSurface::Uninitialized => Label::new("Agent").truncate().into_any_element(), }; @@ -5966,6 +5929,10 @@ impl AgentPanel { .is_some_and(|thread| !thread.read(cx).is_generating_title()) }); + let has_thread_messages = conversation_view.as_ref().is_some_and(|conversation_view| { + conversation_view.read(cx).has_user_submitted_prompt(cx) + }); + let has_auth_methods = match &self.base_view { BaseView::AgentThread { conversation_view } => { conversation_view.read(cx).has_auth_methods() @@ -6001,15 +5968,15 @@ impl AgentPanel { .with_handle(self.agent_panel_menu_handle.clone()) .menu({ move |window, cx| { - Some(ContextMenu::build(window, cx, |mut menu, _window, _| { + Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { menu = menu.context(menu_action_context.clone()); - if can_regenerate_thread_title { + if has_thread_messages { menu = menu.header("Current Thread"); if let Some(conversation_view) = conversation_view.as_ref() { - menu = menu - .entry("Regenerate Thread Title", None, { + if can_regenerate_thread_title { + menu = menu.entry("Regenerate Thread Title", None, { let conversation_view = conversation_view.clone(); let workspace = workspace.clone(); move |_, cx| { @@ -6019,15 +5986,42 @@ impl AgentPanel { cx, ); } - }) - .separator(); + }); + } + + let root_thread_view = + conversation_view.read(cx).root_thread_view(); + if let Some(thread_view) = root_thread_view { + let workspace = workspace.clone(); + menu = menu.entry("Open Thread as Markdown", None, { + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + thread_view.update(cx, |thread_view, cx| { + thread_view + .open_thread_as_markdown( + workspace, window, cx, + ) + .detach_and_log_err(cx); + }); + } + } + }); + } + + menu = menu.separator(); } } if !showing_terminal { menu = menu .header("MCP Servers") - .action("Add Custom Server…", Box::new(AddContextServer)) + .action( + "Add Server…", + Box::new(zed_actions::OpenSettingsAt { + path: "context_servers".to_string(), + target: None, + }), + ) .action( "Install New Servers…", Box::new(zed_actions::Extensions { @@ -6092,11 +6086,11 @@ impl AgentPanel { }, ); } - - menu = menu.separator(); } - menu = menu.action("Profiles", Box::new(ManageProfiles::default())); + menu = menu + .separator() + .action("Profiles", Box::new(ManageProfiles::default())); } menu = menu @@ -6120,21 +6114,6 @@ impl AgentPanel { }) } - fn render_toolbar_back_button(&self, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - IconButton::new("go-back", IconName::ArrowLeft) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.go_back(&workspace::GoBack, window, cx); - })) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx) - } - }) - } - fn render_no_project_state(&self, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); @@ -6176,13 +6155,6 @@ impl AgentPanel { (None, self.selected_agent.label()) }; - let active_thread = match &self.base_view { - BaseView::AgentThread { conversation_view } => { - conversation_view.read(cx).as_native_thread(cx) - } - BaseView::Terminal { .. } | BaseView::Uninitialized => None, - }; - let new_thread_menu_builder: Rc< dyn Fn(&mut Window, &mut App) -> Option>, > = { @@ -6200,31 +6172,8 @@ impl AgentPanel { let agent_server_store = agent_server_store; Rc::new(move |window, cx| { - let active_thread = active_thread.clone(); Some(ContextMenu::build(window, cx, |menu, _window, cx| { menu.context(focus_handle.clone()) - .when_some(active_thread, |this, active_thread| { - let thread = active_thread.read(cx); - - if !thread.is_empty() { - let session_id = thread.id().clone(); - this.item( - ContextMenuEntry::new("New From Summary") - .icon(IconName::ThreadFromSummary) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - Box::new(NewNativeAgentThreadFromSummary { - from_session_id: session_id.clone(), - }), - cx, - ); - }), - ) - } else { - this - } - }) .item( ContextMenuEntry::new("Zed Agent") .when( @@ -6445,15 +6394,12 @@ impl AgentPanel { }; enum ToolbarMode { - Overlay, Terminal, EmptyThread, ActiveThread, } - let mode = if self.is_overlay_open() { - ToolbarMode::Overlay - } else if matches!(self.base_view, BaseView::Terminal { .. }) { + let mode = if matches!(self.base_view, BaseView::Terminal { .. }) { ToolbarMode::Terminal } else if self.active_thread_has_messages(cx) { ToolbarMode::ActiveThread @@ -6520,6 +6466,13 @@ impl AgentPanel { .with_handle(self.new_thread_menu_handle.clone()) .menu(move |window, cx| new_thread_menu_builder(window, cx)); + let sandbox_status = self + .active_conversation_view() + .and_then(|conversation_view| conversation_view.read(cx).root_thread_view()) + .and_then(|thread_view| { + thread_view.update(cx, |thread_view, cx| thread_view.render_sandbox_status(cx)) + }); + base_container .child( h_flex() @@ -6530,11 +6483,7 @@ impl AgentPanel { .overflow_hidden() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) - .child(if matches!(mode, ToolbarMode::Overlay) { - self.render_toolbar_back_button(cx).into_any_element() - } else { - selected_agent.into_any_element() - }) + .child(selected_agent.into_any_element()) .child(match empty_thread_title { Some(title) => title, None => self.render_title_view(window, cx), @@ -6542,11 +6491,11 @@ impl AgentPanel { ) .child( h_flex() + .px_1() .h_full() .flex_none() .gap_1() - .pl_1() - .pr_1() + .children(sandbox_status) .when(can_create_entries, |this| this.child(new_thread_menu)) .child(full_screen_button) .child(self.render_panel_options_menu(window, cx)), @@ -6865,6 +6814,7 @@ impl Render for AgentPanel { .relative() .size_full() .justify_between() + .track_focus(&self.focus_handle) .bg(cx.theme().colors().panel_background) .on_action(cx.listener(|this, action: &NewThread, window, cx| { this.new_thread(action, window, cx); @@ -6878,12 +6828,12 @@ impl Render for AgentPanel { })) .on_action(cx.listener(Self::open_active_thread_as_markdown)) .on_action(cx.listener(Self::manage_skills)) - .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_options_menu)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) + .on_action(cx.listener(Self::toggle_terminal_thread_search)) .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { if let Some(conversation_view) = this.active_conversation_view() { conversation_view.update(cx, |conversation_view, cx| { @@ -6908,19 +6858,43 @@ impl Render for AgentPanel { VisibleSurface::AgentThread(conversation_view) => parent .child(conversation_view.clone()) .child(self.render_drag_target(cx)), - VisibleSurface::Terminal(terminal_view) => parent - .child(terminal_view.clone()) - .child(self.render_drag_target(cx)), - VisibleSurface::Configuration(configuration) => { - parent.children(configuration.cloned()) + VisibleSurface::Terminal(terminal_view) => { + let search_bar = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get(&terminal_id)) + .and_then(|terminal| terminal.search_bar.clone()); + let terminal_content = v_flex() + .size_full() + .when_some(search_bar, |this, search_bar| { + this.when(!search_bar.read(cx).is_dismissed(), |this| { + this.child( + v_flex() + .group("toolbar") + .relative() + .py(DynamicSpacing::Base06.rems(cx)) + .px(DynamicSpacing::Base08.rems(cx)) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().toolbar_background) + .child(search_bar), + ) + }) + }) + .child(terminal_view.clone()); + + parent + .child(terminal_content) + .child(self.render_drag_target(cx)) } }) .children(self.render_trial_end_upsell(window, cx)); match self.visible_font_size() { WhichFontSize::AgentFont => { - WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) + let theme_settings = ThemeSettings::get_global(cx); + WithRemSize::new(theme_settings.agent_ui_font_size(cx)) .size_full() + .font_family(theme_settings.agent_ui_font_family().clone()) .child(content) .into_any() } @@ -7150,14 +7124,16 @@ impl AgentPanel { let terminal = cx.new(|cx| builder.subscribe(cx)); let terminal_for_init_command = terminal.clone(); let terminal_view = cx.new(|cx| { - TerminalView::new( + let mut view = TerminalView::new( terminal, self.workspace.clone(), self.workspace_id, self.project.downgrade(), window, cx, - ) + ); + view.set_show_workspace_actions(false, cx); + view }); self.insert_terminal( terminal_id, @@ -7214,12 +7190,12 @@ mod tests { active_session_id, active_thread_id, open_thread_with_connection, open_thread_with_custom_connection, register_test_sidebar, send_message, }; - use acp_thread::{AgentConnection, StubAgentConnection, ThreadStatus, UserMessageId}; + use acp_thread::{AgentConnection, StubAgentConnection, ThreadStatus}; use action_log::ActionLog; use anyhow::{Result, anyhow}; use feature_flags::FeatureFlagAppExt; use fs::FakeFs; - use gpui::{App, TestAppContext, UpdateGlobal, VisualTestContext}; + use gpui::{App, Modifiers, TestAppContext, UpdateGlobal, VisualTestContext, px, size}; use parking_lot::Mutex; use project::{Project, WorktreePaths}; use settings::{SettingsStore, WorkingDirectory}; @@ -7403,7 +7379,6 @@ mod tests { fn prompt( &self, - _id: UserMessageId, params: acp::PromptRequest, _cx: &mut App, ) -> Task> { @@ -7421,6 +7396,121 @@ mod tests { } } + #[gpui::test] + async fn test_clicking_tool_call_output_keeps_agent_panel_focused_and_zoomed( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + cx.simulate_resize(size(px(900.), px(700.))); + + let connection = StubAgentConnection::new(); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace.focus_panel::(window, cx); + panel + }); + open_thread_with_connection(&panel, connection.clone(), cx); + + let session_id = active_session_id(&panel, cx); + let tool_call_id = acp::ToolCallId::new("tool-call-output-focus-regression"); + cx.update(|_window, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(tool_call_id.clone(), "Read file") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::InProgress), + ), + cx, + ); + connection.send_update( + session_id, + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new( + "tool output text".to_string(), + )), + ))]), + )), + cx, + ); + }); + cx.run_until_parked(); + + let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + thread_view.update(cx, |thread_view, cx| { + thread_view.entry_view_state.update(cx, |state, _cx| { + state.expand_tool_call(tool_call_id); + }); + cx.notify(); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.toggle_zoom(&ToggleZoom, window, cx); + }); + + // The thread receives only tool-call updates, so entry index 0 should remain stable. + let output_bounds = cx + .debug_bounds("tool-call-output-0-0") + .expect("tool call output should be rendered"); + cx.simulate_click(output_bounds.center(), Modifiers::default()); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + assert!( + panel.focus_handle(cx).contains_focused(window, cx), + "clicking tool call output should keep focus within the agent panel" + ); + assert!( + panel.is_zoomed(window, cx), + "clicking tool call output should not close Zen Mode" + ); + }); + + let title_editor_focus_handle = panel.read_with(cx, |panel, cx| { + panel + .active_thread_view(cx) + .expect("active thread view should be present") + .read(cx) + .title_editor + .focus_handle(cx) + }); + cx.update(|window, cx| { + title_editor_focus_handle.focus(window, cx); + }); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + assert!( + panel.focus_handle(cx).contains_focused(window, cx), + "focusing the thread title editor should keep focus within the agent panel" + ); + assert!( + panel.is_zoomed(window, cx), + "focusing the thread title editor should not close Zen Mode" + ); + }); + } + #[gpui::test] async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) { init_test(cx); @@ -7855,12 +7945,15 @@ mod tests { cx.executor().allow_parking(); cx.update(|_, cx| { let mut settings = AgentSettings::get_global(cx).clone(); - // The output (`init_ran_42`) is distinct from the command text - // (`init_ran_$((6*7))`), which the PTY also echoes back. Finding the - // output therefore proves the shell actually executed the command - // rather than merely echoing the keystrokes. - settings.terminal_init_command = Some("echo init_ran_$((6*7))".to_string()); + // `init_ran_42` is the command's output, not its echoed text, so finding + // it proves the shell executed the command rather than just echoing it. + settings.terminal_init_command = Some("printf 'init_ran_%s\\n' 42".to_string()); AgentSettings::override_global(settings, cx); + + // Force a known POSIX shell so the test doesn't depend on the developer's login shell. + let mut terminal_settings = TerminalSettings::get_global(cx).clone(); + terminal_settings.shell = task::Shell::Program("/bin/sh".to_string()); + TerminalSettings::override_global(terminal_settings, cx); }); let terminal_id = TerminalId::new(); @@ -7887,6 +7980,7 @@ mod tests { // sleep, matching the real-PTY test in `acp_thread`. let deadline = Instant::now() + Duration::from_secs(10); let terminal = loop { + cx.run_until_parked(); let terminal = panel.read_with(&cx, |panel, cx| { panel .terminals @@ -7900,13 +7994,29 @@ mod tests { { break terminal.clone(); } - assert!( - Instant::now() < deadline, - "init command output never appeared in the terminal" - ); + if Instant::now() >= deadline { + let terminal_created = terminal.is_some(); + let (content, input_log) = if let Some(terminal) = terminal { + let content = terminal.read_with(&cx, |terminal, _| terminal.get_content()); + let input_log = + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()); + (content, input_log) + } else { + (String::new(), Vec::new()) + }; + panic!( + "init command output never appeared in the terminal; terminal_created={terminal_created}, content={content:?}, input_log={input_log:?}" + ); + } cx.executor().timer(Duration::from_millis(50)).await; }; + let input_log = terminal.update(&mut cx, |terminal, _| terminal.take_input_log()); + assert_eq!( + input_log, + vec![b"printf 'init_ran_%s\\n' 42\r".to_vec()], + "init command should be written only after terminal startup has settled" + ); assert!( !terminal.read_with(&cx, |terminal, _| terminal.keyboard_input_sent()), "writing the init command must not mark the terminal as having received \ @@ -9532,7 +9642,7 @@ mod tests { let mut text = String::new(); for path in paths { text.push(' '); - text.push_str(&format!("{path:?}")); + text.push_str(&shlex::try_quote(path.to_str().unwrap()).unwrap()); } text.push(' '); text @@ -9787,96 +9897,6 @@ mod tests { ); } - #[gpui::test] - async fn test_terminal_close_event_closes_without_sidebar(cx: &mut TestAppContext) { - let (panel, mut cx) = setup_panel(cx).await; - cx.update(|_, cx| { - TerminalThreadMetadataStore::init_global(cx); - }); - - let terminal_id = panel - .update_in(&mut cx, |panel, window, cx| { - panel.insert_test_terminal("Dev Server", true, window, cx) - }) - .expect("test terminal should be inserted"); - cx.run_until_parked(); - - panel.update(&mut cx, |panel, cx| { - panel.emit_test_terminal_close(terminal_id, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, _cx| { - assert!(!panel.has_terminal(terminal_id)); - }); - cx.update(|_, cx| { - assert!( - TerminalThreadMetadataStore::global(cx) - .read(cx) - .entry(terminal_id) - .is_none(), - "terminal metadata should be deleted by the fallback close" - ); - }); - } - - #[gpui::test] - async fn test_new_thread_dismisses_settings_overlay(cx: &mut TestAppContext) { - let (panel, mut cx) = setup_panel(cx).await; - - // Put the panel on its ephemeral new-draft view so the base view - // already contains the draft that `NewThread` would activate. - panel.update_in(&mut cx, |panel, window, cx| { - panel.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, cx| { - assert!( - panel.active_view_is_new_draft(cx), - "precondition: base view should be the ephemeral draft" - ); - assert!(!panel.is_overlay_open()); - }); - - // Simulate the Settings overlay being open on top of the draft. - // We don't go through `open_configuration` here because it would - // build provider configuration views, which call into - // `LanguageModelProvider::configuration_view` — unimplemented for - // the fake provider used in tests. The bug being exercised lives - // entirely in the overlay/base-view bookkeeping, so toggling the - // overlay flag directly is sufficient. - panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, _cx| { - assert!( - panel.is_overlay_open(), - "precondition: Settings overlay should be open" - ); - }); - - // Dispatching `NewThread` while Settings is open must dismiss the - // overlay so the user actually sees the new thread. Previously - // this was a silent no-op: `activate_draft` early-returned without - // clearing the overlay because the base view already held the - // draft. - panel.update_in(&mut cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, cx| { - assert!( - !panel.is_overlay_open(), - "Settings overlay should be dismissed when invoking NewThread" - ); - assert!(panel.active_view_is_new_draft(cx)); - }); - } - #[gpui::test] async fn test_terminal_title_omits_placeholder_title(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; @@ -10556,62 +10576,6 @@ mod tests { ); } - #[gpui::test] - async fn test_terminal_bell_notifies_when_configuration_overlay_covers_terminal( - cx: &mut TestAppContext, - ) { - let (panel, mut cx) = setup_visible_panel(cx).await; - let terminal_id = panel - .update_in(&mut cx, |panel, window, cx| { - panel.insert_test_terminal("Claude", true, window, cx) - }) - .expect("test terminal should be inserted"); - cx.run_until_parked(); - - panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); - }); - panel.update(&mut cx, |panel, cx| { - panel.emit_test_terminal_bell(terminal_id, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, cx| { - let terminal = panel - .terminals(cx) - .into_iter() - .find(|terminal| terminal.id == terminal_id) - .expect("terminal should remain in the panel"); - assert!(terminal.has_notification); - }); - cx.windows() - .iter() - .find_map(|window| window.downcast::()) - .expect("covered terminal bell should show a notification"); - } - - #[gpui::test] - async fn test_thread_notification_shows_when_configuration_overlay_covers_thread( - cx: &mut TestAppContext, - ) { - let (panel, mut cx) = setup_visible_panel(cx).await; - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Default response".into()), - )]); - open_thread_with_connection(&panel, connection, &mut cx); - - panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); - }); - send_message(&panel, &mut cx); - - cx.windows() - .iter() - .find_map(|window| window.downcast::()) - .expect("covered thread should show a notification"); - } - #[gpui::test] async fn test_terminal_bell_marks_without_popup_when_sidebar_open(cx: &mut TestAppContext) { let (panel, mut cx) = setup_visible_panel(cx).await; @@ -11298,7 +11262,6 @@ mod tests { request_token_usage: HashMap::default(), model: None, profile: None, - imported: false, subagent_context: None, speed: None, thinking_enabled: false, @@ -11306,6 +11269,7 @@ mod tests { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: Default::default(), }; let thread_store = cx.update(|cx| ThreadStore::global(cx)); @@ -11821,6 +11785,60 @@ mod tests { }); } + #[gpui::test] + async fn test_select_agent_action_updates_visible_draft(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); + }); + + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + panel.update_in(cx, |panel, window, cx| { + panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + }); + + cx.dispatch_action(SelectAgent { + agent: "my-configured-agent".to_string(), + }); + cx.run_until_parked(); + + let expected_agent = Agent::Custom { + id: "my-configured-agent".into(), + }; + + panel.read_with(cx, |panel, cx| { + let draft = panel.draft_thread.as_ref().expect("draft should exist"); + assert_eq!(panel.selected_agent, expected_agent); + assert_eq!(*draft.read(cx).agent_key(), expected_agent); + }); + + let kvp = cx.update(|_, cx| KeyValueStore::global(cx)); + assert_eq!( + read_global_last_used_agent(&kvp), + Some(expected_agent), + "the selection should be persisted as the global last-used agent" + ); + } + #[gpui::test] async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) { init_test(cx); @@ -13163,7 +13181,6 @@ mod tests { fn prompt( &self, - _id: UserMessageId, params: acp::PromptRequest, _cx: &mut App, ) -> Task> { diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 897a853662407c..a4fb6bfc60eae8 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -514,19 +514,27 @@ impl AgentRegistryPage { .size(IconSize::Small) .color(Color::Muted), ) - .on_click(move |_, _, cx| { - let agent_id = agent_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let agent_servers = settings.agent_servers.get_or_insert_default(); - agent_servers.entry(agent_id).or_insert_with(|| { - settings::CustomAgentServerSettings::Registry { - default_mode: None, - env: Default::default(), - default_config_options: HashMap::default(), - favorite_config_option_values: HashMap::default(), - } - }); + .on_click(move |_, window, cx| { + update_settings_file(fs.clone(), cx, { + let agent_id = agent_id.clone(); + move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + agent_servers.entry(agent_id).or_insert_with(|| { + settings::CustomAgentServerSettings::Registry { + default_mode: None, + env: Default::default(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + } + }); + } }); + window.dispatch_action( + Box::new(zed_actions::agent::SelectAgent { + agent: agent_id.clone(), + }), + cx, + ); }) } RegistryInstallStatus::InstalledRegistry => { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 913793860523d3..1570315f888f2f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -35,20 +35,21 @@ pub mod thread_worktree_archive; pub mod threads_archive_view; mod ui; +mod unicode_confusables; use std::rc::Rc; use std::sync::Arc; use ::ui::IconName; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::{AgentProfileId, AgentSettings}; use command_palette_hooks::CommandPaletteFilter; use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use feature_flags::FeatureFlagAppExt as _; use fs::Fs; use gpui::{ - Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, TaskExt, Window, - actions, + Action, App, Context, Entity, ImageSource, ReadGlobal as _, Resource, SharedString, SharedUri, + TaskExt, Window, actions, }; use language::{ LanguageRegistry, @@ -65,9 +66,9 @@ use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide}; use std::any::TypeId; use std::path::{Path, PathBuf}; -use workspace::Workspace; +use workspace::{OpenOptions, Workspace}; -use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; +use crate::agent_configuration::ManageProfilesModal; pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore}; pub use crate::agent_panel::{ AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId, @@ -118,40 +119,68 @@ pub(crate) fn resolve_agent_image( None } +/// Opens `abs_path` in the workspace, moving the cursor to `point` when one +/// is given. Paths outside every worktree are only opened when a file exists +/// there, so broken agent links don't create empty buffers. pub(crate) fn open_abs_path_at_point( workspace: &mut Workspace, abs_path: PathBuf, - point: Point, + point: Option, window: &mut Window, cx: &mut Context, -) -> bool { - let project = workspace.project(); - let Some(path) = project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) - else { - return false; - }; - - let item = workspace.open_path(path, None, true, window, cx); +) { + let project_path = workspace + .project() + .update(cx, |project, cx| project.find_project_path(&abs_path, cx)); + let fs = workspace.project().read(cx).fs().clone(); + let workspace = cx.weak_entity(); window .spawn(cx, async move |cx| { - let Some(editor) = item.await?.downcast::() else { + let item = if let Some(project_path) = project_path { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(project_path, None, true, window, cx) + })? + .await? + } else { + let metadata = fs.metadata(&abs_path).await?; + anyhow::ensure!( + metadata.is_some_and(|metadata| !metadata.is_dir), + "no file found at path {abs_path:?}" + ); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + abs_path, + OpenOptions { + focus: Some(true), + ..Default::default() + }, + window, + cx, + ) + })? + .await? + }; + let Some(point) = point else { + return Ok(()); + }; + let Some(editor) = item.downcast::() else { return Ok(()); }; - let range = point..point; editor .update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::center()), window, cx, - |selections| selections.select_ranges([range]), + |selections| selections.select_ranges([point..point]), ); }) .ok(); anyhow::Ok(()) }) .detach_and_log_err(cx); - true } pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread"; @@ -196,8 +225,6 @@ actions!( CycleFavoriteModels, /// Expands the message editor to full size. ExpandMessageEditor, - /// Adds a context server to the configuration. - AddContextServer, /// Archives the currently selected thread. ArchiveSelectedThread, /// Removes the currently selected thread. @@ -262,6 +289,9 @@ actions!( RemoveFirstQueuedMessage, /// Edits the first message in the queue (the next one to be sent). EditFirstQueuedMessage, + /// Toggles steering for the first queued message: when on, it interrupts + /// the agent at its next step instead of waiting for it to finish. + ToggleSteerFirstQueuedMessage, /// Clears all messages from the queue. ClearMessageQueue, /// Opens the permission granularity dropdown for the current tool call. @@ -584,16 +614,12 @@ pub fn init( init_language_model_settings(cx); } agent_panel::init(cx); - context_server_configuration::init(language_registry.clone(), fs.clone(), cx); + context_server_configuration::init(language_registry, fs.clone(), cx); thread_metadata_store::init(cx); terminal_thread_metadata_store::init(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) - }) - .detach(); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { workspace.register_action( move |workspace: &mut Workspace, @@ -860,11 +886,12 @@ fn init_language_model_settings(cx: &mut App) { .detach(); cx.subscribe( &LanguageModelRegistry::global(cx), - |_, event: &language_model::Event, cx| match event { + |registry, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) | language_model::Event::ProvidersChanged => { + registry.update(cx, |registry, cx| registry.refresh_fallback_model(cx)); update_active_language_model_from_settings(cx); } _ => {} @@ -883,6 +910,12 @@ fn update_active_language_model_from_settings(cx: &mut App) { } } + let should_use_fallback = SettingsStore::global(cx) + .raw_user_settings() + .and_then(|user| user.content.agent.as_ref()) + .and_then(|agent| agent.default_model.as_ref()) + .is_none(); + let default = settings.default_model.as_ref().map(to_selected_model); let inline_assistant = settings .inline_assistant_model @@ -896,6 +929,7 @@ fn update_active_language_model_from_settings(cx: &mut App) { .thread_summary_model .as_ref() .map(to_selected_model); + let compaction = settings.compaction_model.as_ref().map(to_selected_model); let inline_alternatives = settings .inline_alternatives .iter() @@ -907,7 +941,9 @@ fn update_active_language_model_from_settings(cx: &mut App) { registry.select_inline_assistant_model(inline_assistant.as_ref(), cx); registry.select_commit_message_model(commit_message.as_ref(), cx); registry.select_thread_summary_model(thread_summary.as_ref(), cx); + registry.select_compaction_model(compaction.as_ref(), cx); registry.select_inline_alternative_models(inline_alternatives, cx); + registry.set_should_use_fallback(should_use_fallback); }); } @@ -949,8 +985,10 @@ mod tests { inline_assistant_model: None, inline_assistant_use_streaming_tools: false, commit_message_model: None, + commit_message_include_project_rules: true, commit_message_instructions: None, thread_summary_model: None, + compaction_model: None, inline_alternatives: vec![], favorite_models: vec![], default_profile: AgentProfileId::default(), diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 1fee158f4a16b2..022bf5c65858c3 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -525,18 +525,18 @@ impl CodegenAlternative { 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(), - use_input_streaming: false, - }, - 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(), - use_input_streaming: false, - }, + LanguageModelRequestTool::function( + REWRITE_SECTION_TOOL_NAME.to_string(), + "Replaces text in tags with your replacement_text.".to_string(), + language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + false, + ), + LanguageModelRequestTool::function( + FAILURE_MESSAGE_TOOL_NAME.to_string(), + "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), + language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + false, + ), ]; LanguageModelRequest { @@ -1179,9 +1179,7 @@ impl CodegenAlternative { let mut chars_read_by_tool_id = chars_read_by_tool_id.lock(); match tool_use.name.as_ref() { REWRITE_SECTION_TOOL_NAME => { - let Ok(input) = - serde_json::from_value::(tool_use.input) - else { + let Ok(input) = tool_use.input.parse::() else { return None; }; let chars_read_so_far = @@ -1198,9 +1196,7 @@ impl CodegenAlternative { }) } FAILURE_MESSAGE_TOOL_NAME => { - let Ok(mut input) = - serde_json::from_value::(tool_use.input) - else { + let Ok(mut input) = tool_use.input.parse::() else { return None; }; Some(ToolUseOutput::Failure(std::mem::take(&mut input.message))) @@ -2011,7 +2007,9 @@ mod tests { id: id.into(), name: REWRITE_SECTION_TOOL_NAME.into(), raw_input: serde_json::to_string(&input).unwrap(), - input: serde_json::to_value(&input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + 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 b676bc3c3e838a..6b6196c43829ad 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -7,7 +7,7 @@ use std::sync::atomic::AtomicBool; use crate::DEFAULT_THREAD_TITLE; use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use acp_thread::MentionUri; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use editor::{CompletionProvider, Editor, code_context_menus::COMPLETION_MENU_MAX_WIDTH}; use futures::FutureExt as _; @@ -190,6 +190,51 @@ impl PromptContextAction { } } +/// A slash command that runs a local UI action against the conversation +/// (sending feedback) instead of being sent to the agent as part of a prompt. +/// Each variant maps to a method on `ThreadView`; the completion provider only +/// surfaces them and emits an event, while `ThreadView` performs the actual +/// work (see `handle_message_editor_event`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptLocalCommand { + ThumbsUp, + ThumbsDown, +} + +impl PromptLocalCommand { + pub fn keyword(&self) -> &'static str { + match self { + Self::ThumbsUp => "helpful", + Self::ThumbsDown => "not-helpful", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::ThumbsUp => "Positive Feedback", + Self::ThumbsDown => "Negative Feedback", + } + } + + pub fn description(&self) -> &'static str { + match self { + Self::ThumbsUp => { + "Rate this response as helpful. Sends the current conversation to the Zed team." + } + Self::ThumbsDown => { + "Rate this response as not helpful. Sends the current conversation to the Zed team." + } + } + } + + pub fn icon(&self) -> IconName { + match self { + Self::ThumbsUp => IconName::ThumbsUp, + Self::ThumbsDown => IconName::ThumbsDown, + } + } +} + impl TryFrom<&str> for PromptContextType { type Error = String; @@ -366,13 +411,15 @@ impl AvailableCommand { enum SlashCompletionCandidate { Skill(AvailableSkill), Command(AvailableCommand), + LocalCommand(PromptLocalCommand), } impl SlashCompletionCandidate { - fn name(&self) -> &Arc { + fn name(&self) -> &str { match self { Self::Skill(skill) => &skill.name, Self::Command(command) => &command.name, + Self::LocalCommand(command) => command.keyword(), } } } @@ -385,6 +432,7 @@ fn slash_completion_group_key(candidate: &SlashCompletionCandidate) -> u32 { match candidate { SlashCompletionCandidate::Skill(_) => 0, SlashCompletionCandidate::Command(command) => 1 + command.category_order() as u32, + SlashCompletionCandidate::LocalCommand(_) => 4, } } @@ -411,6 +459,13 @@ pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { fn available_skills(&self, _cx: &App) -> Vec { Vec::new() } + + fn available_local_commands(&self, _cx: &App) -> Vec { + Vec::new() + } + + fn run_local_command(&self, _command: PromptLocalCommand, _cx: &mut App) {} + fn confirm_command(&self, cx: &mut App); /// Called once each time the user opens slash-command autocomplete @@ -473,7 +528,14 @@ impl PromptCompletionProvider { AgentContextSource::from_active(workspace, cx)? .read_selection(workspace, false, cx) }); - Self::completion_for_action(action, source_range, editor, mention_set, selection) + Self::completion_for_action( + action, + source_range, + editor, + mention_set, + workspace.downgrade(), + selection, + ) } } } @@ -760,6 +822,7 @@ impl PromptCompletionProvider { source_range: Range, editor: WeakEntity, mention_set: WeakEntity, + workspace: WeakEntity, selection: Option, ) -> Option { let (new_text, on_action) = match action { @@ -769,6 +832,7 @@ impl PromptCompletionProvider { source_range.clone(), editor, mention_set, + workspace, editor_selections, ) } @@ -959,15 +1023,21 @@ impl PromptCompletionProvider { let mut candidates = self .source - .available_skills(cx) + .available_commands(cx) .into_iter() - .map(SlashCompletionCandidate::Skill) + .map(SlashCompletionCandidate::Command) .collect::>(); candidates.extend( self.source - .available_commands(cx) + .available_skills(cx) + .into_iter() + .map(SlashCompletionCandidate::Skill), + ); + candidates.extend( + self.source + .available_local_commands(cx) .into_iter() - .map(SlashCompletionCandidate::Command), + .map(SlashCompletionCandidate::LocalCommand), ); if candidates.is_empty() { return Task::ready(Vec::new()); @@ -1429,7 +1499,10 @@ impl CompletionProvider for PromptCompletio Some((new_text, icon_path, icon_color, confirm)), ) } - SlashCompletionCandidate::Command(_) => (candidate, None), + SlashCompletionCandidate::Command(_) + | SlashCompletionCandidate::LocalCommand(_) => { + (candidate, None) + } }) .collect::)>>() }) @@ -1542,6 +1615,50 @@ impl CompletionProvider for PromptCompletio group, } } + SlashCompletionCandidate::LocalCommand(command) => { + let group = show_section_headers.then(|| CompletionGroup { + key: "local-commands".into(), + label: Some("Actions".into()), + }); + + Completion { + replace_range: source_range.clone(), + // Local commands aren't part of the prompt; + // confirming one clears the typed text + // rather than leaving `/keyword` behind. + new_text: String::new(), + label: CodeLabel::plain(command.label().to_string(), None), + documentation: Some( + CompletionDocumentation::MultiLinePlainText( + command.description().into(), + ), + ), + source: project::CompletionSource::Custom, + icon_path: Some(command.icon().path().into()), + icon_color: None, + match_start: None, + snippet_deduplication_key: None, + insert_text_mode: None, + confirm: Some(Arc::new({ + let source = source.clone(); + move |intent, _window, cx| { + cx.defer({ + let source = source.clone(); + move |cx| match intent { + CompletionIntent::Complete + | CompletionIntent::CompleteWithInsert + | CompletionIntent::CompleteWithReplace => { + source.run_local_command(command, cx); + } + CompletionIntent::Compose => {} + } + }); + false + } + })), + group, + } + } }) .collect(); @@ -2115,7 +2232,7 @@ fn diagnostics_crease_label( diagnostics_label(summary, include_errors, include_warnings).into() } -fn pluralize(noun: &str, count: usize) -> String { +pub(crate) fn pluralize(noun: &str, count: usize) -> String { if count == 1 { noun.to_string() } else { @@ -2594,6 +2711,7 @@ fn completion_text_for_editor_selections( source_range: Range, editor: WeakEntity, mention_set: WeakEntity, + workspace: WeakEntity, editor_selections: Vec<(Entity, Range)>, ) -> (String, ConfirmCallback) { const EDITOR_PLACEHOLDER: &str = "selection "; @@ -2617,6 +2735,7 @@ fn completion_text_for_editor_selections( let editor = editor.clone(); let selections = selections.clone(); let mention_set = mention_set.clone(); + let workspace = workspace.clone(); let source_range = source_range.clone(); window.defer(cx, move |window, cx| { if let Some(editor) = editor.upgrade() @@ -2628,6 +2747,7 @@ fn completion_text_for_editor_selections( source_range.clone(), selections, editor.clone(), + workspace, window, cx, ) @@ -2686,6 +2806,8 @@ fn completion_text_for_terminal_selections( mention_uri.name().into(), mention_uri.icon_path(cx), None, + None, + None, range, editor.downgrade(), ); diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index f8b72d52dda699..3f7301432bd404 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -1,7 +1,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::AgentSessionConfigOptions; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use collections::HashSet; @@ -13,11 +13,12 @@ use gpui::{ use ordered_float::OrderedFloat; use picker::popover_menu::PickerPopoverMenu; use picker::{Picker, PickerDelegate}; -use settings::SettingsStore; +use settings::{AgentConfigOptionValue, SettingsStore}; use ui::{ - ElevationIndex, IconButton, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, - prelude::*, + ElevationIndex, IconButton, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Switch, + SwitchLabelPosition, ToggleState, Tooltip, prelude::*, }; +use unicode_segmentation::UnicodeSegmentation; use util::ResultExt as _; use zed_actions::agent::ToggleModelSelector; @@ -78,7 +79,9 @@ impl ConfigOptionsView { window: &mut Window, cx: &mut Context, ) -> bool { - let Some(config_id) = self.first_config_option_id(category) else { + let Some(config_id) = self.first_config_option_id_matching(category, |option| { + matches!(&option.kind, acp::SessionConfigKind::Select(_)) + }) else { return false; }; @@ -86,11 +89,7 @@ impl ConfigOptionsView { return false; }; - selector.update(cx, |selector, cx| { - selector.toggle_picker(window, cx); - }); - - true + selector.update(cx, |selector, cx| selector.toggle_picker(window, cx)) } pub fn cycle_category_option( @@ -99,17 +98,20 @@ impl ConfigOptionsView { favorites_only: bool, cx: &mut Context, ) -> bool { - let Some(config_id) = self.first_config_option_id(category) else { + let Some(config_id) = self.first_config_option_id_matching(category, |option| { + Self::can_cycle_config_option(option, favorites_only) + }) else { return false; }; let Some(next_value) = self.next_value_for_config(&config_id, favorites_only, cx) else { return false; }; + let default_value = setting_value_for_config_option_value(&next_value); self.agent_server.set_default_config_option( config_id.0.as_ref(), - Some(next_value.0.as_ref()), + default_value, self.fs.clone(), cx, ); @@ -130,8 +132,13 @@ impl ConfigOptionsView { /// Returns the current model value ID string for the Model config option, if one exists. pub fn current_model_value(&self) -> Option { - let config_id = self.first_config_option_id(acp::SessionConfigOptionCategory::Model)?; - let option = self.config_options.config_options().into_iter().find(|opt| opt.id == config_id)?; + let config_id = + self.first_config_option_id_matching(acp::SessionConfigOptionCategory::Model, |_| true)?; + let option = self + .config_options + .config_options() + .into_iter() + .find(|opt| opt.id == config_id)?; match &option.kind { acp::SessionConfigKind::Select(select) => { // Return the value ID (e.g. "claude-sonnet-4-5-latest"), falling back to name @@ -141,17 +148,26 @@ impl ConfigOptionsView { } } - fn first_config_option_id( + fn first_config_option_id_matching( &self, category: acp::SessionConfigOptionCategory, + predicate: impl Fn(&acp::SessionConfigOption) -> bool, ) -> Option { self.config_options .config_options() .into_iter() - .find(|option| option.category.as_ref() == Some(&category)) + .find(|option| option.category.as_ref() == Some(&category) && predicate(option)) .map(|option| option.id) } + fn can_cycle_config_option(option: &acp::SessionConfigOption, favorites_only: bool) -> bool { + match &option.kind { + acp::SessionConfigKind::Select(_) => true, + acp::SessionConfigKind::Boolean(_) => !favorites_only, + _ => false, + } + } + fn selector_for_config_id( &self, config_id: &acp::SessionConfigId, @@ -168,35 +184,57 @@ impl ConfigOptionsView { config_id: &acp::SessionConfigId, favorites_only: bool, cx: &mut Context, - ) -> Option { - let mut options = extract_options(&self.config_options, config_id); - if options.is_empty() { - return None; - } + ) -> Option { + let option = self + .config_options + .config_options() + .into_iter() + .find(|option| &option.id == config_id)?; - if favorites_only { - let favorites = self - .agent_server - .favorite_config_option_value_ids(config_id, cx); - options.retain(|option| favorites.contains(&option.value)); - if options.is_empty() { - return None; - } - } + match &option.kind { + acp::SessionConfigKind::Select(_) => { + let mut options = extract_options(&self.config_options, config_id); + if options.is_empty() { + return None; + } - let current_value = get_current_value(&self.config_options, config_id); - let current_index = current_value - .as_ref() - .and_then(|current| options.iter().position(|option| &option.value == current)) - .unwrap_or(usize::MAX); + if favorites_only { + let favorites = self + .agent_server + .favorite_config_option_value_ids(config_id, cx); + options.retain(|option| favorites.contains(&option.value)); + if options.is_empty() { + return None; + } + } - let next_index = if current_index == usize::MAX { - 0 - } else { - (current_index + 1) % options.len() - }; + let current_value = get_current_select_value(&self.config_options, config_id); + let current_index = current_value + .as_ref() + .and_then(|current| options.iter().position(|option| &option.value == current)) + .unwrap_or(usize::MAX); + + let next_index = if current_index == usize::MAX { + 0 + } else { + (current_index + 1) % options.len() + }; - Some(options[next_index].value.clone()) + Some(acp::SessionConfigOptionValue::value_id( + options[next_index].value.clone(), + )) + } + acp::SessionConfigKind::Boolean(boolean) => { + if favorites_only { + None + } else { + Some(acp::SessionConfigOptionValue::boolean( + !boolean.current_value, + )) + } + } + _ => None, + } } fn config_option_ids( @@ -259,6 +297,8 @@ impl Render for ConfigOptionsView { } h_flex() + .min_w_0() + .flex_wrap() .gap_1() .children(self.selectors.iter().cloned()) .into_any_element() @@ -268,8 +308,10 @@ impl Render for ConfigOptionsView { struct ConfigOptionSelector { config_options: Rc, config_id: acp::SessionConfigId, - picker_handle: PopoverMenuHandle>, - picker: Entity>, + agent_server: Rc, + fs: Arc, + picker_handle: Option>>, + picker: Option>>, setting_value: bool, } @@ -282,21 +324,26 @@ impl ConfigOptionSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let option_count = config_options + let current_option = config_options .config_options() - .iter() - .find(|opt| opt.id == config_id) + .into_iter() + .find(|opt| opt.id == config_id); + let option_count = current_option + .as_ref() .map(count_config_options) .unwrap_or(0); + let is_select = current_option + .as_ref() + .is_some_and(|option| matches!(&option.kind, acp::SessionConfigKind::Select(_))); let is_searchable = option_count >= PICKER_THRESHOLD; - let picker = { + let (picker_handle, picker) = if is_select { let config_options = config_options.clone(); let config_id = config_id.clone(); let agent_server = agent_server.clone(); let fs = fs.clone(); - cx.new(move |picker_cx| { + let picker = cx.new(move |picker_cx| { let delegate = ConfigOptionPickerDelegate::new( config_options, config_id, @@ -312,15 +359,19 @@ impl ConfigOptionSelector { Picker::nonsearchable_list(delegate, window, picker_cx) } .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) - }) + .initial_width(rems(20.)) + }); + (Some(PopoverMenuHandle::default()), Some(picker)) + } else { + (None, None) }; Self { config_options, config_id, - picker_handle: PopoverMenuHandle::default(), + agent_server, + fs, + picker_handle, picker, setting_value: false, } @@ -337,8 +388,13 @@ impl ConfigOptionSelector { &self.config_id } - fn toggle_picker(&self, window: &mut Window, cx: &mut Context) { - self.picker_handle.toggle(window, cx); + fn toggle_picker(&self, window: &mut Window, cx: &mut Context) -> bool { + if let Some(picker_handle) = &self.picker_handle { + picker_handle.toggle(window, cx); + true + } else { + false + } } fn current_value_name(&self) -> String { @@ -359,7 +415,10 @@ impl ConfigOptionSelector { self.config_options .config_options() .into_iter() - .find(|option| option.category.as_ref() == Some(category)) + .find(|option| { + option.category.as_ref() == Some(category) + && matches!(&option.kind, acp::SessionConfigKind::Select(_)) + }) .is_some_and(|option| option.id == self.config_id) } @@ -371,17 +430,23 @@ impl ConfigOptionSelector { .disabled(true); }; - let icon = if self.picker_handle.is_deployed() { + let picker_deployed = self + .picker_handle + .as_ref() + .is_some_and(|picker_handle| picker_handle.is_deployed()); + let icon = if picker_deployed { IconName::ChevronUp } else { IconName::ChevronDown }; let value_name = self.current_value_name(); - let display_name = if value_name.len() > 33 { - format!("{}…", &value_name[..32]) + let mut graphemes = value_name.graphemes(true); + let truncated = graphemes.by_ref().take(32).collect::(); + let display_name = if graphemes.next().is_some() { + format!("{truncated}…") } else { - value_name + truncated }; Button::new( @@ -401,88 +466,170 @@ impl Render for ConfigOptionSelector { return div().into_any_element(); }; - let trigger_button = self.render_trigger_button(window, cx); + match &option.kind { + acp::SessionConfigKind::Select(_) => { + let (Some(picker), Some(picker_handle)) = + (self.picker.clone(), self.picker_handle.clone()) + else { + return div().into_any_element(); + }; + + let trigger_button = self.render_trigger_button(window, cx); + + let show_category_keybindings = option + .category + .as_ref() + .is_some_and(|category| self.handles_category_keybindings(category)); + let option_category = option.category.clone(); + let option_name = option.name.clone(); + let option_description: Option = + option.description.clone().map(Into::into); + + let tooltip = Tooltip::element(move |_window, cx| { + let mut content = v_flex().gap_1().child(Label::new(option_name.clone())); + if let Some(desc) = option_description.as_ref() { + content = content.child( + Label::new(desc.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ); + } - let show_category_keybindings = option - .category - .as_ref() - .is_some_and(|category| self.handles_category_keybindings(category)); - let option_category = option.category.clone(); - let option_name = option.name.clone(); - let option_description: Option = option.description.map(Into::into); - - let tooltip = Tooltip::element(move |_window, cx| { - let mut content = v_flex().gap_1().child(Label::new(option_name.clone())); - if let Some(desc) = option_description.as_ref() { - content = content.child( - Label::new(desc.clone()) - .size(LabelSize::Small) - .color(Color::Muted), - ); + let action_tooltip_container = |label: &str, keybinding: KeyBinding| { + h_flex() + .pt_1() + .gap_2() + .justify_between() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new(label)) + .child(keybinding) + }; + + if show_category_keybindings && let Some(category) = &option_category { + match category { + acp::SessionConfigOptionCategory::Mode => { + content = content + .child(action_tooltip_container( + "Change Mode", + KeyBinding::for_action(&ToggleProfileSelector, cx), + )) + .child(action_tooltip_container( + "Cycle Through Modes", + KeyBinding::for_action(&CycleModeSelector, cx), + )); + } + acp::SessionConfigOptionCategory::Model => { + content = content + .child(action_tooltip_container( + "Change Model", + KeyBinding::for_action(&ToggleModelSelector, cx), + )) + .child(action_tooltip_container( + "Cycle Favorite Models", + KeyBinding::for_action(&CycleFavoriteModels, cx), + )); + } + acp::SessionConfigOptionCategory::ThoughtLevel => { + content = content + .child(action_tooltip_container( + "Change Thinking Effort", + KeyBinding::for_action(&ToggleThinkingEffortMenu, cx), + )) + .child(action_tooltip_container( + "Cycle Thinking Effort", + KeyBinding::for_action(&CycleThinkingEffort, cx), + )); + } + _ => {} + } + } + content.into_any() + }); + + PickerPopoverMenu::new( + picker, + trigger_button, + tooltip, + gpui::Anchor::BottomRight, + cx, + ) + .with_handle(picker_handle) + .render(window, cx) + .into_any_element() } + acp::SessionConfigKind::Boolean(boolean) => { + let option_id = option.id.clone(); + let option_name: SharedString = option.name.clone().into(); + let option_description: Option = + option.description.clone().map(Into::into); + let tooltip_name = option_name.clone(); + let tooltip = Tooltip::element(move |_window, _cx| { + let mut content = v_flex().gap_1().child(Label::new(tooltip_name.clone())); + if let Some(desc) = option_description.as_ref() { + content = content.child( + Label::new(desc.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ); + } + content.into_any() + }); + + let config_id = self.config_id.clone(); + let config_options = self.config_options.clone(); + let agent_server = self.agent_server.clone(); + let fs = self.fs.clone(); + let current_value = boolean.current_value; + let toggle_state = if current_value { + ToggleState::Selected + } else { + ToggleState::Unselected + }; - let action_tooltip_container = |label: &str, keybinding: KeyBinding| { h_flex() - .pt_1() - .gap_2() - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(Label::new(label)) - .child(keybinding) - }; - - if show_category_keybindings && let Some(category) = &option_category { - match category { - acp::SessionConfigOptionCategory::Mode => { - content = content - .child(action_tooltip_container( - "Change Mode", - KeyBinding::for_action(&ToggleProfileSelector, cx), - )) - .child(action_tooltip_container( - "Cycle Through Modes", - KeyBinding::for_action(&CycleModeSelector, cx), - )); - } - acp::SessionConfigOptionCategory::Model => { - content = content - .child(action_tooltip_container( - "Change Model", - KeyBinding::for_action(&ToggleModelSelector, cx), - )) - .child(action_tooltip_container( - "Cycle Favorite Models", - KeyBinding::for_action(&CycleFavoriteModels, cx), - )); - } - acp::SessionConfigOptionCategory::ThoughtLevel => { - content = content - .child(action_tooltip_container( - "Change Thinking Effort", - KeyBinding::for_action(&ToggleThinkingEffortMenu, cx), - )) - .child(action_tooltip_container( - "Cycle Thinking Effort", - KeyBinding::for_action(&CycleThinkingEffort, cx), - )); - } - _ => {} - } + .id(ElementId::Name( + format!("config-option-{}", option_id.0).into(), + )) + .pr_1() + .tooltip(tooltip) + .child( + Switch::new( + ElementId::Name(format!("config-option-{}-switch", option_id.0).into()), + toggle_state, + ) + .label(option_name) + .label_position(SwitchLabelPosition::Start) + .label_size(LabelSize::Small) + .label_color(Color::Muted) + .disabled(self.setting_value) + .on_click(move |state, _window, cx| { + let next_value = matches!(state, ToggleState::Selected); + agent_server.set_default_config_option( + config_id.0.as_ref(), + Some(AgentConfigOptionValue::Boolean(next_value)), + fs.clone(), + cx, + ); + + let task = config_options.set_config_option( + config_id.clone(), + acp::SessionConfigOptionValue::boolean(next_value), + cx, + ); + + cx.spawn(async move |_| { + if let Err(err) = task.await { + log::error!("Failed to set config option: {:?}", err); + } + }) + .detach(); + }), + ) + .into_any_element() } - content.into_any() - }); - - PickerPopoverMenu::new( - self.picker.clone(), - trigger_button, - tooltip, - gpui::Anchor::BottomRight, - cx, - ) - .with_handle(self.picker_handle.clone()) - .render(window, cx) - .into_any_element() + _ => div().into_any_element(), + } } } @@ -527,7 +674,7 @@ impl ConfigOptionPickerDelegate { let all_options = extract_options(&config_options, &config_id); let filtered_entries = options_to_picker_entries(&all_options, &favorites); - let current_value = get_current_value(&config_options, &config_id); + let current_value = get_current_select_value(&config_options, &config_id); let selected_index = current_value .and_then(|current| { filtered_entries.iter().position(|entry| { @@ -565,13 +712,17 @@ impl ConfigOptionPickerDelegate { } fn current_value(&self) -> Option { - get_current_value(&self.config_options, &self.config_id) + get_current_select_value(&self.config_options, &self.config_id) } } impl PickerDelegate for ConfigOptionPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "config options" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } @@ -646,13 +797,13 @@ impl PickerDelegate for ConfigOptionPickerDelegate { { self.agent_server.set_default_config_option( self.config_id.0.as_ref(), - Some(option.value.0.as_ref()), + Some(AgentConfigOptionValue::ValueId(option.value.0.to_string())), self.fs.clone(), cx, ); let task = self.config_options.set_config_option( self.config_id.clone(), - option.value.clone(), + acp::SessionConfigOptionValue::value_id(option.value.clone()), cx, ); @@ -823,7 +974,7 @@ fn extract_options( } } -fn get_current_value( +fn get_current_select_value( config_options: &Rc, config_id: &acp::SessionConfigId, ) -> Option { @@ -837,6 +988,20 @@ fn get_current_value( }) } +fn setting_value_for_config_option_value( + value: &acp::SessionConfigOptionValue, +) -> Option { + match value { + acp::SessionConfigOptionValue::ValueId { value } => { + Some(AgentConfigOptionValue::ValueId(value.0.to_string())) + } + acp::SessionConfigOptionValue::Boolean { value } => { + Some(AgentConfigOptionValue::Boolean(*value)) + } + _ => None, + } +} + fn options_to_picker_entries( options: &[ConfigOptionValue], favorites: &HashSet, @@ -995,17 +1160,145 @@ mod tests { assert_eq!( agent_server.saved_defaults.lock().as_slice(), - &[("mode".to_string(), Some("manual".to_string()))] + &[( + "mode".to_string(), + Some(AgentConfigOptionValue::ValueId("manual".to_string())) + )] + ); + assert_eq!( + config_options.set_values.borrow().as_slice(), + &[( + "mode".to_string(), + acp::SessionConfigOptionValue::value_id("manual") + )] + ); + } + + #[gpui::test] + fn cycling_boolean_config_option_saves_selected_value_as_default(cx: &mut TestAppContext) { + let agent_server = Rc::new(TestAgentServer::default()); + let config_options = Rc::new(TestSessionConfigOptions::new(vec![ + acp::SessionConfigOption::boolean("web_search", "Web Search", false) + .category(acp::SessionConfigOptionCategory::ModelConfig), + ])); + let fs: Arc = FakeFs::new(cx.executor()); + + cx.update(|cx| { + let config_options: Rc = config_options.clone(); + let agent_server: Rc = agent_server.clone(); + let fs = fs.clone(); + let view = cx.new(|_| ConfigOptionsView { + config_option_ids: ConfigOptionsView::config_option_ids(&config_options), + config_options, + selectors: Vec::new(), + agent_server, + fs, + _refresh_task: Task::ready(()), + }); + + assert!(view.update(cx, |view, cx| { + view.cycle_category_option(acp::SessionConfigOptionCategory::ModelConfig, false, cx) + })); + }); + + assert_eq!( + agent_server.saved_defaults.lock().as_slice(), + &[( + "web_search".to_string(), + Some(AgentConfigOptionValue::Boolean(true)) + )] ); assert_eq!( config_options.set_values.borrow().as_slice(), - &[("mode".to_string(), "manual".to_string())] + &[( + "web_search".to_string(), + acp::SessionConfigOptionValue::boolean(true) + )] ); } + #[gpui::test] + fn cycling_category_cycles_boolean_config_option_first(cx: &mut TestAppContext) { + let agent_server = Rc::new(TestAgentServer::default()); + let config_options = Rc::new(TestSessionConfigOptions::new(vec![ + acp::SessionConfigOption::boolean("web_search", "Web Search", false) + .category(acp::SessionConfigOptionCategory::Model), + acp::SessionConfigOption::select( + "model", + "Model", + "small", + vec![ + acp::SessionConfigSelectOption::new("small", "Small"), + acp::SessionConfigSelectOption::new("large", "Large"), + ], + ) + .category(acp::SessionConfigOptionCategory::Model), + ])); + let fs: Arc = FakeFs::new(cx.executor()); + + cx.update(|cx| { + let config_options: Rc = config_options.clone(); + let agent_server: Rc = agent_server.clone(); + let fs = fs.clone(); + let view = cx.new(|_| ConfigOptionsView { + config_option_ids: ConfigOptionsView::config_option_ids(&config_options), + config_options, + selectors: Vec::new(), + agent_server, + fs, + _refresh_task: Task::ready(()), + }); + + assert!(view.update(cx, |view, cx| { + view.cycle_category_option(acp::SessionConfigOptionCategory::Model, false, cx) + })); + }); + + assert_eq!( + agent_server.saved_defaults.lock().as_slice(), + &[( + "web_search".to_string(), + Some(AgentConfigOptionValue::Boolean(true)) + )] + ); + assert_eq!( + config_options.set_values.borrow().as_slice(), + &[( + "web_search".to_string(), + acp::SessionConfigOptionValue::boolean(true) + )] + ); + } + + #[gpui::test] + fn toggling_category_picker_without_select_config_option_is_unhandled(cx: &mut TestAppContext) { + let agent_server = Rc::new(TestAgentServer::default()); + let config_options = Rc::new(TestSessionConfigOptions::new(vec![ + acp::SessionConfigOption::boolean("web_search", "Web Search", false) + .category(acp::SessionConfigOptionCategory::Model), + ])); + let fs: Arc = FakeFs::new(cx.executor()); + let cx = cx.add_empty_window(); + let view = cx.update({ + move |window, cx| { + let config_options: Rc = config_options; + let agent_server: Rc = agent_server; + cx.new(|cx| ConfigOptionsView::new(config_options, agent_server, fs, window, cx)) + } + }); + + let handled = cx.update(|window, cx| { + view.update(cx, |view, cx| { + view.toggle_category_picker(acp::SessionConfigOptionCategory::Model, window, cx) + }) + }); + + assert!(!handled); + } + #[derive(Default)] struct TestAgentServer { - saved_defaults: Arc)>>>, + saved_defaults: Arc)>>>, } impl AgentServer for TestAgentServer { @@ -1033,20 +1326,19 @@ mod tests { fn set_default_config_option( &self, config_id: &str, - value_id: Option<&str>, + value: Option, _fs: Arc, _cx: &mut App, ) { - self.saved_defaults.lock().push(( - config_id.to_string(), - value_id.map(|value| value.to_string()), - )); + self.saved_defaults + .lock() + .push((config_id.to_string(), value)); } } struct TestSessionConfigOptions { options: RefCell>, - set_values: RefCell>, + set_values: RefCell>, } impl TestSessionConfigOptions { @@ -1066,19 +1358,31 @@ mod tests { fn set_config_option( &self, config_id: acp::SessionConfigId, - value: acp::SessionConfigValueId, + value: acp::SessionConfigOptionValue, _cx: &mut App, ) -> Task>> { self.set_values .borrow_mut() - .push((config_id.0.to_string(), value.0.to_string())); + .push((config_id.0.to_string(), value.clone())); let options = { let mut options = self.options.borrow_mut(); - if let Some(option) = options.iter_mut().find(|option| option.id == config_id) - && let acp::SessionConfigKind::Select(select) = &mut option.kind - { - select.current_value = value; + if let Some(option) = options.iter_mut().find(|option| option.id == config_id) { + match (&mut option.kind, value) { + ( + acp::SessionConfigKind::Select(select), + acp::SessionConfigOptionValue::ValueId { value }, + ) => { + select.current_value = value; + } + ( + acp::SessionConfigKind::Boolean(boolean), + acp::SessionConfigOptionValue::Boolean { value }, + ) => { + boolean.current_value = value; + } + _ => {} + } } options.clone() }; diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 638b7df4c0178f..578723f55c4a23 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -1,17 +1,16 @@ use acp_thread::{ AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, - AuthRequired, LoadError, MaxOutputTokensError, MentionUri, PermissionOptionChoice, - PermissionOptions, PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus, - ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, + AuthRequired, ClientUserMessageId, ElicitationEntryId, ElicitationStatus, ElicitationStore, + LoadError, MaxOutputTokensError, MentionUri, PermissionOptionChoice, PermissionOptions, + PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus, ToolCall, + ToolCallContent, ToolCallStatus, }; use acp_thread::{AgentConnection, Plan}; #[cfg(feature = "external_websocket_sync")] use external_websocket_sync_dep as external_websocket_sync; use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; -use agent::{ - NativeAgentServer, NativeAgentSessionList, NoModelConfiguredError, SharedThread, ThreadStore, -}; -use agent_client_protocol::schema as acp; +use agent::{NativeAgentServer, NoModelConfiguredError, ThreadStore}; +use agent_client_protocol::schema::v1 as acp; #[cfg(test)] use agent_servers::AgentServerDelegate; use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID}; @@ -26,25 +25,27 @@ use editor::scroll::Autoscroll; use editor::{ Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior, }; -use feature_flags::{AgentSharingFeatureFlag, FeatureFlagAppExt as _}; use file_icons::FileIcons; use fs::Fs; use futures::FutureExt as _; use gpui::{ - Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle, - ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, - ObjectFit, PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task, - TaskExt, TextRun, TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, - linear_color_stop, linear_gradient, list, pulsating_between, + Action, Animation, AnimationExt, App, ClickEvent, ClipboardItem, CursorStyle, ElementId, Empty, + Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit, + PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task, TextRun, + TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, + linear_gradient, list, pulsating_between, }; use language::{Buffer, Language, Rope}; -use language_model::{LanguageModelCompletionError, LanguageModelRegistry}; +use language_model::LanguageModelCompletionError; use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle, }; use parking_lot::{Mutex, RwLock}; use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath}; +use crate::conversation_view::elicitation::{ + ElicitationCard, ElicitationCardHandlers, ElicitationFormState, should_render_elicitation, +}; use crate::message_editor::SessionCapabilities; use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image}; use lru::LruCache; @@ -72,8 +73,7 @@ use util::{ time::duration_alt_display, }; use workspace::{ - CollaboratorId, MultiWorkspace, NewTerminal, PathList, Toast, Workspace, - path_link::sanitize_path_text, + CollaboratorId, MultiWorkspace, NewTerminal, PathList, Workspace, path_link::sanitize_path_text, }; use zed_actions::agent::{Chat, ToggleModelSelector}; @@ -100,7 +100,8 @@ use crate::{ ScrollOutputLineDown, ScrollOutputLineUp, ScrollOutputPageDown, ScrollOutputPageUp, ScrollOutputToBottom, ScrollOutputToNextMessage, ScrollOutputToPreviousMessage, ScrollOutputToTop, SendImmediately, SendNextQueuedMessage, ToggleFastMode, - ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, + ToggleProfileSelector, ToggleSteerFirstQueuedMessage, ToggleThinkingEffortMenu, + ToggleThinkingMode, UndoLastReject, }; const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30); @@ -108,15 +109,13 @@ const TOKEN_THRESHOLD: u64 = 250; pub(crate) const DRAFT_PROMPT_PERSIST_DEBOUNCE: Duration = Duration::from_millis(250); +pub(crate) mod elicitation; +mod message_queue; mod thread_search_bar; mod thread_view; +pub use message_queue::*; pub use thread_view::*; -pub struct QueuedMessage { - pub content: Vec, - pub tracked_buffers: Vec>, -} - #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum ThreadFeedback { Positive, @@ -252,6 +251,17 @@ impl ProfileProvider for Entity { fn model_selected(&self, cx: &App) -> bool { self.read(cx).model().is_some() } + + fn is_restricted(&self, cx: &App) -> bool { + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &self.read(cx).project().read(cx).worktree_store(), + cx, + ) + } + + fn profile_downgraded(&self, cx: &App) -> bool { + self.read(cx).profile_was_downgraded() + } } /// No-op AgentConnection for headless threads created by thread_service. @@ -260,6 +270,7 @@ impl ProfileProvider for Entity { pub(crate) struct Conversation { threads: HashMap>, permission_requests: IndexMap>, + elicitation_requests: IndexMap>, subscriptions: Vec, updated_at: Option, } @@ -286,7 +297,22 @@ impl Conversation { } } } + AcpThreadEvent::ElicitationRequested(id) => { + this.elicitation_requests + .entry(session_id.clone()) + .or_default() + .push(id.clone()); + } + AcpThreadEvent::ElicitationResponded(id) => { + if let Some(elicitations) = this.elicitation_requests.get_mut(&session_id) { + elicitations.retain(|elicitation_id| elicitation_id != id); + if elicitations.is_empty() { + this.elicitation_requests.shift_remove(&session_id); + } + } + } AcpThreadEvent::NewEntry + | AcpThreadEvent::StatusChanged | AcpThreadEvent::TitleUpdated | AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::EntryUpdated(_) @@ -389,6 +415,20 @@ impl Conversation { .unwrap_or(0) } + pub fn respond_to_elicitation( + &mut self, + session_id: acp::SessionId, + elicitation_id: ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) -> Option<()> { + let thread = self.threads.get(&session_id)?.clone(); + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation(&elicitation_id, response, cx); + }); + Some(()) + } + pub fn authorize_pending_tool_call( &mut self, session_id: &acp::SessionId, @@ -524,6 +564,8 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool { | AcpThreadEvent::TitleUpdated | AcpThreadEvent::ToolAuthorizationRequested(_) | AcpThreadEvent::ToolAuthorizationReceived(_) + | AcpThreadEvent::ElicitationRequested(_) + | AcpThreadEvent::ElicitationResponded(_) | AcpThreadEvent::Stopped(_) | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) @@ -531,6 +573,7 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool { | AcpThreadEvent::WorkingDirectoriesUpdated => true, // -- AcpThreadEvent::EntryUpdated(_) + | AcpThreadEvent::StatusChanged | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::Retry(_) | AcpThreadEvent::TokenUsageUpdated @@ -572,6 +615,7 @@ pub struct ConversationView { /// Cache + worktree snapshot for resolving paths in markdown code spans. /// Shared with the child [`ThreadView`] when one is constructed. pub(crate) code_span_resolver: AgentCodeSpanResolver, + request_elicitation_form_states: HashMap, _subscriptions: Vec, } @@ -667,7 +711,7 @@ impl ConversationView { connected.navigate_to_thread(session_id); if let Some(view) = self.active_thread() { - view.focus_handle(cx).focus(window, cx); + view.read(cx).activation_focus_handle(cx).focus(window, cx); } cx.emit(AcpServerViewEvent::ActiveThreadChanged); cx.notify(); @@ -683,8 +727,14 @@ impl ConversationView { } enum ServerState { - Loading { _loading: Entity }, - LoadError { error: LoadError }, + Loading { + _loading: Entity, + connection: Option>, + _request_elicitation_subscription: Option, + }, + LoadError { + error: LoadError, + }, Connected(ConnectedServerState), } @@ -697,15 +747,14 @@ pub struct ConnectedServerState { connection: Rc, conversation: Entity, _connection_entry_subscription: Subscription, + _request_elicitation_subscription: Option, } enum AuthState { Ok, Unauthenticated { description: Option>, - configuration_view: Option, pending_auth_method: Option, - _subscription: Option, }, } @@ -797,6 +846,7 @@ impl ConversationView { })); cx.on_release(|this, cx| { + this.request_elicitation_form_states.clear(); if let Some(connected) = this.as_connected() { connected.close_all_sessions(cx).detach(); } @@ -842,6 +892,7 @@ impl ConversationView { last_theme_id: Some(cx.theme().id.clone()), draft_prompt_persist_task: None, code_span_resolver, + request_elicitation_form_states: HashMap::default(), _subscriptions: subscriptions, focus_handle: cx.focus_handle(), } @@ -1105,6 +1156,10 @@ impl ConversationView { threads: HashMap::from_iter([(id, current)]), conversation: conversation_entity, _connection_entry_subscription: Subscription::new(|| {}), + // HELIX: headless/external threads have no elicitation UI to + // drive, so there is nothing to subscribe to. Upstream added + // this field in the 2026-07 elicitation work. + _request_elicitation_subscription: None, }), notifications: Vec::new(), notification_subscriptions: HashMap::default(), @@ -1115,14 +1170,27 @@ impl ConversationView { last_theme_id: Some(cx.theme().id.clone()), draft_prompt_persist_task: None, code_span_resolver, + request_elicitation_form_states: HashMap::default(), } } fn set_server_state(&mut self, state: ServerState, cx: &mut Context) { + let previous_request_elicitation_connection = self.request_elicitation_connection(); + let next_request_elicitation_connection = + Self::request_elicitation_connection_for_state(&state); + if let Some(connected) = self.as_connected() { connected.close_all_sessions(cx).detach(); } + if let Some(connection) = previous_request_elicitation_connection + && !next_request_elicitation_connection + .as_ref() + .is_some_and(|next_connection| Rc::ptr_eq(&connection, next_connection)) + { + self.request_elicitation_form_states.clear(); + } + self.server_state = state; cx.emit(StateChange); cx.emit(AcpServerViewEvent::ActiveThreadChanged); @@ -1132,6 +1200,53 @@ impl ConversationView { cx.notify(); } + fn request_elicitation_subscription( + connection: &Rc, + cx: &mut Context, + ) -> Option { + let store = connection.request_elicitations()?; + Some(cx.observe(&store, |this, _store, cx| { + if let Some(active_thread) = this.active_thread().cloned() { + active_thread.update(cx, |_thread, cx| cx.notify()); + } + cx.notify(); + })) + } + + fn request_elicitation_connection(&self) -> Option> { + Self::request_elicitation_connection_for_state(&self.server_state) + } + + fn active_thread_renders_request_elicitations(&self) -> bool { + match &self.server_state { + ServerState::Connected(connected) => { + connected.auth_state.is_ok() && connected.active_view().is_some() + } + _ => false, + } + } + + fn request_elicitation_connection_for_state( + state: &ServerState, + ) -> Option> { + match state { + ServerState::Loading { + connection: Some(connection), + .. + } => Some(connection.clone()), + ServerState::Connected(connected) => Some(connected.connection.clone()), + ServerState::Loading { + connection: None, .. + } + | ServerState::LoadError { .. } => None, + } + } + + fn request_elicitation_store(&self) -> Option> { + self.request_elicitation_connection()? + .request_elicitations() + } + fn reset(&mut self, window: &mut Window, cx: &mut Context) { let (resume_session_id, work_dirs, title) = self .root_thread_view() @@ -1157,6 +1272,7 @@ impl ConversationView { (session_id, work_dirs, title) }); + self.clear_resolved_request_elicitations(cx); self.loading_status = None; let state = Self::initial_state( @@ -1246,6 +1362,22 @@ impl ConversationView { } }; + this.update_in(cx, |this, _window, cx| { + let request_elicitation_subscription = + Self::request_elicitation_subscription(&connection, cx); + if let ServerState::Loading { + connection: loading_connection, + _request_elicitation_subscription, + .. + } = &mut this.server_state + { + *loading_connection = Some(connection.clone()); + *_request_elicitation_subscription = request_elicitation_subscription; + cx.notify(); + } + }) + .log_err(); + telemetry::event!( "Agent Thread Started", agent = connection.telemetry_id(), @@ -1320,14 +1452,7 @@ impl ConversationView { Err(e) => match e.downcast::() { Ok(err) => { cx.update(|window, cx| { - Self::handle_auth_required( - this, - err, - agent.agent_id(), - connection, - window, - cx, - ) + Self::handle_auth_required(this, err, connection, window, cx) }) .log_err(); return; @@ -1340,6 +1465,7 @@ impl ConversationView { this.update_in(cx, |this, window, cx| { match result { Ok(thread) => { + this.clear_resolved_request_elicitations_for_connection(&connection, cx); let root_session_id = thread.read(cx).session_id().clone(); let conversation = cx.new(|cx| { @@ -1413,6 +1539,8 @@ impl ConversationView { } this.root_session_id = Some(root_session_id.clone()); + let request_elicitation_subscription = + Self::request_elicitation_subscription(&connection, cx); this.set_server_state( ServerState::Connected(ConnectedServerState { connection, @@ -1421,6 +1549,7 @@ impl ConversationView { threads: HashMap::from_iter([(root_session_id, current.clone())]), conversation, _connection_entry_subscription: connection_entry_subscription, + _request_elicitation_subscription: request_elicitation_subscription, }), cx, ); @@ -1477,6 +1606,8 @@ impl ConversationView { ServerState::Loading { _loading: loading_view, + connection: None, + _request_elicitation_subscription: None, } } @@ -1542,7 +1673,6 @@ impl ConversationView { // Check for config options first // Config options take precedence over legacy mode/model selectors - // (feature flag gating happens at the data layer) let config_options_provider = connection.session_config_options(&session_id, cx); let config_options_view; @@ -1685,55 +1815,17 @@ impl ConversationView { fn handle_auth_required( this: WeakEntity, err: AuthRequired, - agent_id: AgentId, connection: Rc, window: &mut Window, cx: &mut App, ) { - let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id { - let registry = LanguageModelRegistry::global(cx); - - let sub = window.subscribe(®istry, cx, { - let provider_id = provider_id.clone(); - let this = this.clone(); - move |_, ev, window, cx| { - if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev - && &provider_id == updated_provider_id - && LanguageModelRegistry::global(cx) - .read(cx) - .provider(&provider_id) - .map_or(false, |provider| provider.is_authenticated(cx)) - { - this.update(cx, |this, cx| { - this.reset(window, cx); - }) - .ok(); - } - } - }); - - let view = registry.read(cx).provider(&provider_id).map(|provider| { - provider.configuration_view( - language_model::ConfigurationViewTargetAgent::Other(agent_id.0), - window, - cx, - ) - }); - - (view, Some(sub)) - } else { - (None, None) - }; - this.update(cx, |this, cx| { let description = err .description .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))); let auth_state = AuthState::Unauthenticated { pending_auth_method: None, - configuration_view, description, - _subscription: subscription, }; if let Some(connected) = this.as_connected_mut() { connected.auth_state = auth_state; @@ -1748,6 +1840,8 @@ impl ConversationView { this.focus_handle.focus(window, cx) } } else { + let request_elicitation_subscription = + Self::request_elicitation_subscription(&connection, cx); this.set_server_state( ServerState::Connected(ConnectedServerState { auth_state, @@ -1756,6 +1850,7 @@ impl ConversationView { connection, conversation: cx.new(|_cx| Conversation::default()), _connection_entry_subscription: Subscription::new(|| {}), + _request_elicitation_subscription: request_elicitation_subscription, }), cx, ); @@ -1851,41 +1946,6 @@ impl ConversationView { matches!(self.server_state, ServerState::Loading { .. }) } - fn send_queued_message_at_index( - &mut self, - index: usize, - is_send_now: bool, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active) = self.root_thread_view() { - active.update(cx, |active, cx| { - active.send_queued_message_at_index(index, is_send_now, window, cx); - }); - } - } - - fn move_queued_message_to_main_editor( - &mut self, - index: usize, - attempt: Option, - cursor_offset: Option, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active) = self.root_thread_view() { - active.update(cx, |active, cx| { - active.move_queued_message_to_main_editor( - index, - attempt, - cursor_offset, - window, - cx, - ); - }); - } - } - fn handle_thread_event( &mut self, thread: &Entity, @@ -1905,6 +1965,13 @@ impl ConversationView { cx.emit(RootThreadUpdated); } match event { + AcpThreadEvent::StatusChanged => { + if let Some(active) = self.thread_view(&session_id) { + active.update(cx, |active, cx| { + active.sync_generating_indicator(cx); + }); + } + } AcpThreadEvent::NewEntry => { let len = thread.read(cx).entries().len(); let index = len - 1; @@ -1921,7 +1988,8 @@ impl ConversationView { ); }); active.update(cx, |active, cx| { - active.sync_editor_mode_for_empty_state(cx); + active.sync_elicitation_state_for_entry(index, window, cx); + active.sync_editor_mode(cx); active.sync_generating_indicator(cx); }); } @@ -1938,6 +2006,7 @@ impl ConversationView { }); list_state.remeasure_items(*index..*index + 1); active.update(cx, |active, cx| { + active.sync_elicitation_state_for_entry(*index, window, cx); active.auto_expand_streaming_thought(cx); active.sync_generating_indicator(cx); }); @@ -1952,7 +2021,7 @@ impl ConversationView { entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone())); list_state.splice(range.clone(), 0); active.update(cx, |active, cx| { - active.sync_editor_mode_for_empty_state(cx); + active.sync_editor_mode(cx); }); } } @@ -1963,6 +2032,10 @@ impl ConversationView { self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx); } AcpThreadEvent::ToolAuthorizationReceived(_) => {} + AcpThreadEvent::ElicitationRequested(_) => { + self.notify_with_sound("Waiting for input", IconName::Info, window, cx); + } + AcpThreadEvent::ElicitationResponded(_) => {} AcpThreadEvent::Retry(retry) => { if let Some(active) = self.thread_view(&session_id) { active.update(cx, |active, _cx| { @@ -1994,34 +2067,31 @@ impl ConversationView { return; } - let should_send_queued = if let Some(active) = self.root_thread_view() { + let sent_queued_message = if let Some(active) = self.root_thread_view() { active.update(cx, |active, cx| { - if active.skip_queue_processing_count > 0 { - active.skip_queue_processing_count -= 1; - false - } else if active.user_interrupted_generation { - // Manual interruption: don't auto-process queue. - // Reset the flag so future completions can process normally. - active.user_interrupted_generation = false; - false + // Don't auto-send while the user is editing the next message. + let is_first_editor_focused = active + .message_queue + .first() + .is_some_and(|entry| entry.editor.focus_handle(cx).is_focused(window)); + if let Some(entry) = active + .message_queue + .on_generation_stopped(is_first_editor_focused) + { + active.dispatch_queued_entry(entry, window, cx); + true } else { - let has_queued = !active.local_queued_messages.is_empty(); - // Don't auto-send if the first message editor is currently focused - let is_first_editor_focused = active - .queued_message_editors - .first() - .is_some_and(|editor| editor.focus_handle(cx).is_focused(window)); - has_queued && !is_first_editor_focused + false } }) } else { false }; - // Skip notifying when a queued message is about to be auto-sent: the agent + // Skip notifying when a queued message was just auto-sent: the agent // is not actually idle and a notification here would fire just before the // next turn starts. - if !should_send_queued { + if !sent_queued_message { let used_tools = thread.read(cx).used_tools_since_last_user_message(); self.notify_with_sound( if used_tools { @@ -2033,8 +2103,6 @@ impl ConversationView { window, cx, ); - } else { - self.send_queued_message_at_index(0, false, window, cx); } // WebSocket MessageCompleted is emitted by the persistent subscription's @@ -2247,7 +2315,6 @@ impl ConversationView { let connection = connected.connection.clone(); let AuthState::Unauthenticated { - configuration_view, pending_auth_method, .. } = &mut connected.auth_state @@ -2258,7 +2325,6 @@ impl ConversationView { let agent_telemetry_id = connection.telemetry_id(); if let Some(login_task) = connection.terminal_auth_task(&method, cx) { - configuration_view.take(); pending_auth_method.replace(method.clone()); let project = self.project.clone(); @@ -2298,6 +2364,7 @@ impl ConversationView { this.update_in(cx, |this, window, cx| { if let Err(err) = result { + this.cancel_request_elicitations(cx); if let Some(ConnectedServerState { auth_state: AuthState::Unauthenticated { @@ -2326,7 +2393,6 @@ impl ConversationView { return; } - configuration_view.take(); pending_auth_method.replace(method.clone()); let authenticate = connection.authenticate(method, cx); @@ -2348,6 +2414,7 @@ impl ConversationView { this.update_in(cx, |this, window, cx| { if let Err(err) = result { + this.cancel_request_elicitations(cx); if let Some(ConnectedServerState { auth_state: AuthState::Unauthenticated { @@ -2574,7 +2641,6 @@ impl ConversationView { &self, connection: &Rc, description: Option<&Entity>, - configuration_view: Option<&AnyView>, pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, @@ -2587,10 +2653,8 @@ impl ConversationView { .agent_display_name(&self.agent.agent_id()) .unwrap_or_else(|| self.agent.agent_id().0); - let show_fallback_description = auth_methods.len() > 1 - && configuration_view.is_none() - && description.is_none() - && pending_auth_method.is_none(); + let show_fallback_description = + auth_methods.len() > 1 && description.is_none() && pending_auth_method.is_none(); let auth_buttons = || { h_flex().justify_end().flex_wrap().gap_1().children( @@ -2665,12 +2729,7 @@ impl ConversationView { .color(Color::Muted), ) } else { - this.children( - configuration_view - .cloned() - .map(|view| div().w_full().child(view)), - ) - .children(description.map(|desc| { + this.children(description.map(|desc| { self.render_markdown( desc.clone(), MarkdownStyle::themed(MarkdownFont::Agent, window, cx), @@ -2686,307 +2745,470 @@ impl ConversationView { .into_any_element() } - fn emit_load_error_telemetry(&self, error: &LoadError) { - let error_kind = match error { - LoadError::Unsupported { .. } => "unsupported", - LoadError::FailedToInstall(_) => "failed_to_install", - LoadError::Exited { .. } => "exited", - LoadError::Other(_) => "other", + fn sync_request_elicitation_states(&mut self, window: &mut Window, cx: &mut Context) { + let Some(store) = self.request_elicitation_store() else { + self.request_elicitation_form_states.clear(); + return; }; - let agent_name = self.agent.agent_id(); - - telemetry::event!( - "Agent Panel Error Shown", - agent = agent_name, - kind = error_kind, - message = error.to_string(), - ); - } - - 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, stderr } => { - let mut message = format!("Server exited with status {status}"); - if let Some(stderr) = stderr { - message.push_str("\n"); - message.push_str(stderr); + let elicitations = store + .read(cx) + .elicitations() + .iter() + .map(|elicitation| { + let is_pending = matches!(elicitation.status, ElicitationStatus::Pending { .. }); + let schema = match &elicitation.request.mode { + acp::ElicitationMode::Form(mode) => Some(mode.requested_schema.clone()), + _ => None, }; - let action_slot = stderr - .is_some() - .then(|| self.create_copy_button(message.clone()).into_any_element()); - ("Failed to Launch", message.into(), action_slot) - } - 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) - .actions_slot(div().children(action_slot)) - .into_any_element() - } - - fn render_unsupported( - &self, - path: &SharedString, - version: &SharedString, - minimum_version: &SharedString, - _window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let (heading_label, description_label) = ( - format!("Upgrade {} to work with Zed", self.agent.agent_id()), - if version.is_empty() { - format!( - "Currently using {}, which does not report a valid --version", - path, - ) - } else { - format!( - "Currently using {}, which is only version {} (need at least {minimum_version})", - path, version - ) - }, - ); + (elicitation.id.clone(), is_pending, schema) + }) + .collect::>(); - v_flex() - .w_full() - .p_3p5() - .gap_2p5() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(linear_gradient( - 180., - linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), - linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), - )) - .child( - v_flex().gap_0p5().child(Label::new(heading_label)).child( - Label::new(description_label) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() + let known_ids = elicitations + .iter() + .map(|(id, _, _)| id.clone()) + .collect::>(); + self.request_elicitation_form_states + .retain(|id, _| known_ids.contains(id)); + + for (id, is_pending, schema) in elicitations { + if is_pending + && let Some(schema) = schema + && !self.request_elicitation_form_states.contains_key(&id) + { + self.request_elicitation_form_states + .insert(id, ElicitationFormState::new(&schema, window, cx)); + } else if !is_pending { + self.request_elicitation_form_states.remove(&id); + } + } } - pub(crate) fn as_native_connection( + fn render_request_elicitations( &self, + connection: &Rc, + view: WeakEntity, cx: &App, - ) -> Option> { - self.root_thread(cx)? - .read(cx) - .connection() - .clone() - .downcast() - } + ) -> Vec { + let Some(store) = connection.request_elicitations() else { + return Vec::new(); + }; - pub fn as_native_thread(&self, cx: &App) -> Option> { - self.as_native_connection(cx)? - .thread(self.root_session_id.as_ref()?, cx) - } + let handlers = Self::request_elicitation_card_handlers(view); + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&self.agent.agent_id()) + .unwrap_or_else(|| self.agent.agent_id().0); - fn queued_messages_len(&self, cx: &App) -> usize { - self.root_thread_view() - .map(|thread| thread.read(cx).local_queued_messages.len()) - .unwrap_or_default() + store + .read(cx) + .elicitations() + .iter() + .enumerate() + .filter(|(_, elicitation)| should_render_elicitation(elicitation)) + .map(|(ix, elicitation)| { + ElicitationCard::new( + ix, + elicitation, + agent_display_name.clone(), + self.request_elicitation_form_states.get(&elicitation.id), + handlers.clone(), + ) + .render(cx) + .into_any_element() + }) + .collect() } - fn update_queued_message( - &mut self, - index: usize, - content: Vec, - tracked_buffers: Vec>, - cx: &mut Context, - ) -> bool { - match self.root_thread_view() { - Some(thread) => thread.update(cx, |thread, _cx| { - if index < thread.local_queued_messages.len() { - thread.local_queued_messages[index] = QueuedMessage { - content, - tracked_buffers, - }; - true - } else { - false + fn request_elicitation_card_handlers(view: WeakEntity) -> ElicitationCardHandlers { + ElicitationCardHandlers::new( + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.submit_request_elicitation(elicitation_id, window, cx); + }) + .log_err(); } - }), - None => false, + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.decline_request_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.cancel_request_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, _window, cx| { + view.update(cx, |this, cx| { + this.dismiss_request_url_elicitation(elicitation_id, cx); + }) + .log_err(); + } + }, + move |_elicitation_id, url, _window, cx| cx.open_url(&url), + { + let view = view.clone(); + move |elicitation_id, field_name, value, cx| { + view.update(cx, |this, cx| { + this.update_request_elicitation_form_state( + &elicitation_id, + |form| form.set_boolean(&field_name, value), + cx, + ); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, field_name, value, cx| { + view.update(cx, |this, cx| { + this.update_request_elicitation_form_state( + &elicitation_id, + |form| form.set_single_select(&field_name, value), + cx, + ); + }) + .log_err(); + } + }, + move |elicitation_id, field_name, value, selected, cx| { + view.update(cx, |this, cx| { + this.update_request_elicitation_form_state( + &elicitation_id, + |form| form.set_multi_select(&field_name, value, selected), + cx, + ); + }) + .log_err(); + }, + ) + } + + fn update_request_elicitation_form_state( + &mut self, + elicitation_id: &ElicitationEntryId, + update: impl FnOnce(&mut ElicitationFormState), + cx: &mut Context, + ) { + if let Some(form) = self.request_elicitation_form_states.get_mut(elicitation_id) { + update(form); + self.notify_request_elicitation_renderers(cx); } } - fn queued_message_contents(&self, cx: &App) -> Vec> { - match self.root_thread_view() { - None => Vec::new(), - Some(thread) => thread - .read(cx) - .local_queued_messages - .iter() - .map(|q| q.content.clone()) - .collect(), + fn notify_request_elicitation_renderers(&self, cx: &mut Context) { + if let Some(active_thread) = self.active_thread().cloned() { + active_thread.update(cx, |_thread, cx| cx.notify()); } + cx.notify(); } - fn save_queued_message_at_index(&mut self, index: usize, cx: &mut Context) { - let editor = match self.root_thread_view() { - Some(thread) => thread.read(cx).queued_message_editors.get(index).cloned(), - None => None, + fn submit_request_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(store) = self.request_elicitation_store() else { + return; }; - let Some(editor) = editor else { + + let mode = store + .read(cx) + .elicitation(&elicitation_id) + .map(|(_, elicitation)| elicitation.request.mode.clone()); + let Some(mode) = mode else { return; }; - let contents_task = editor.update(cx, |editor, cx| editor.contents(false, cx)); + match mode { + acp::ElicitationMode::Form(mode) => { + let Some(state) = self + .request_elicitation_form_states + .get_mut(&elicitation_id) + else { + return; + }; + let Some(submission) = state.begin_submission(cx) else { + return; + }; + let schema = mode.requested_schema; + let validation_task = cx.background_spawn(async move { + let result = submission.validate(&schema); + (submission, result) + }); + self.notify_request_elicitation_renderers(cx); + cx.spawn(async move |this, cx| { + let (submission, result) = validation_task.await; + this.update(cx, |this, cx| { + let is_current = this + .request_elicitation_form_states + .get_mut(&elicitation_id) + .is_some_and(|state| { + state.validation_matches_current_values(&submission, cx) + }); + if !is_current { + this.notify_request_elicitation_renderers(cx); + return; + } + match result { + Ok(content) => { + this.respond_to_request_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new( + acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(content), + ), + ), + cx, + ); + } + Err(errors) => { + this.update_request_elicitation_form_state( + &elicitation_id, + |state| state.set_errors(errors), + cx, + ); + } + } + }) + .log_err(); + }) + .detach(); + } + acp::ElicitationMode::Url(_) => { + self.respond_to_request_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + } + _ => {} + } + } - cx.spawn(async move |this, cx| { - let Ok((content, tracked_buffers)) = contents_task.await else { - return Ok::<(), anyhow::Error>(()); - }; + fn decline_request_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.respond_to_request_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + } - this.update(cx, |this, cx| { - this.update_queued_message(index, content, tracked_buffers, cx); - cx.notify(); - })?; + fn cancel_request_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.respond_to_request_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), + cx, + ); + } - Ok(()) - }) - .detach_and_log_err(cx); + fn dismiss_request_url_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + cx: &mut Context, + ) { + self.request_elicitation_form_states.remove(&elicitation_id); + if let Some(store) = self.request_elicitation_store() { + store.update(cx, |store, cx| { + store.cancel_elicitation(&elicitation_id, cx); + }); + } + self.notify_request_elicitation_renderers(cx); } - fn sync_queued_message_editors(&mut self, window: &mut Window, cx: &mut Context) { - let needed_count = self.queued_messages_len(cx); - let queued_messages = self.queued_message_contents(cx); + fn respond_to_request_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) { + self.request_elicitation_form_states.remove(&elicitation_id); + if let Some(store) = self.request_elicitation_store() { + store.update(cx, |store, cx| { + store.respond_to_elicitation(&elicitation_id, response, cx); + }); + } + cx.notify(); + } - let agent_name = self.agent.agent_id(); - let workspace = self.workspace.clone(); - let project = self.project.downgrade(); - let Some(connected) = self.as_connected() else { - return; - }; - let Some(thread) = connected.active_view() else { - return; - }; - let session_capabilities = thread.read(cx).session_capabilities.clone(); + fn cancel_request_elicitations(&mut self, cx: &mut App) { + self.request_elicitation_form_states.clear(); + if let Some(store) = self.request_elicitation_store() { + store.update(cx, |store, cx| store.clear(cx)); + } + } - let current_count = thread.read(cx).queued_message_editors.len(); - let last_synced = thread.read(cx).last_synced_queue_length; + fn clear_resolved_request_elicitations(&mut self, cx: &mut App) { + if let Some(connection) = self.request_elicitation_connection() { + self.clear_resolved_request_elicitations_for_connection(&connection, cx); + } + } - if current_count == needed_count && needed_count == last_synced { + fn clear_resolved_request_elicitations_for_connection( + &mut self, + connection: &Rc, + cx: &mut App, + ) { + let Some(store) = connection.request_elicitations() else { return; + }; + let cleared_ids = store.update(cx, |store, cx| store.clear_resolved(cx)); + for id in cleared_ids { + self.request_elicitation_form_states.remove(&id); } + } - if current_count > needed_count { - thread.update(cx, |thread, _cx| { - thread.queued_message_editors.truncate(needed_count); - thread - .queued_message_editor_subscriptions - .truncate(needed_count); - }); + fn emit_load_error_telemetry(&self, error: &LoadError) { + let error_kind = match error { + LoadError::Unsupported { .. } => "unsupported", + LoadError::FailedToInstall(_) => "failed_to_install", + LoadError::Exited { .. } => "exited", + LoadError::Other(_) => "other", + }; - let editors = thread.read(cx).queued_message_editors.clone(); - for (index, editor) in editors.into_iter().enumerate() { - if let Some(content) = queued_messages.get(index) { - editor.update(cx, |editor, cx| { - editor.set_read_only(true, cx); - editor.set_message(content.clone(), window, cx); - }); - } + let agent_name = self.agent.agent_id(); + + telemetry::event!( + "Agent Panel Error Shown", + agent = agent_name, + kind = error_kind, + message = error.to_string(), + ); + } + + 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, stderr } => { + let mut message = format!("Server exited with status {status}"); + if let Some(stderr) = stderr { + message.push_str("\n"); + message.push_str(stderr); + }; + let action_slot = stderr + .is_some() + .then(|| self.create_copy_button(message.clone()).into_any_element()); + ("Failed to Launch", message.into(), action_slot) + } + LoadError::Other(msg) => ( + "Failed to Launch", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + }; - while thread.read(cx).queued_message_editors.len() < needed_count { - let index = thread.read(cx).queued_message_editors.len(); - let content = queued_messages.get(index).cloned().unwrap_or_default(); + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircleFilled) + .title(title) + .description(message) + .actions_slot(div().children(action_slot)) + .into_any_element() + } - let editor = cx.new(|cx| { - let mut editor = MessageEditor::new( - workspace.clone(), - project.clone(), - None, - session_capabilities.clone(), - agent_name.clone(), - "", - EditorMode::AutoHeight { - min_lines: 1, - max_lines: Some(10), - }, - window, - cx, - ); - editor.set_read_only(true, cx); - editor.set_message(content, window, cx); - editor - }); + fn render_unsupported( + &self, + path: &SharedString, + version: &SharedString, + minimum_version: &SharedString, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (heading_label, description_label) = ( + format!("Upgrade {} to work with Zed", self.agent.agent_id()), + if version.is_empty() { + format!( + "Currently using {}, which does not report a valid --version", + path, + ) + } else { + format!( + "Currently using {}, which is only version {} (need at least {minimum_version})", + path, version + ) + }, + ); - let subscription = cx.subscribe_in( - &editor, - window, - move |this, _editor, event, window, cx| match event { - MessageEditorEvent::InputAttempted { - attempt, - cursor_offset, - } => { - this.move_queued_message_to_main_editor( - index, - Some(attempt.clone()), - Some(*cursor_offset), - window, - cx, - ); - } - MessageEditorEvent::LostFocus => { - this.save_queued_message_at_index(index, cx); - } - MessageEditorEvent::Cancel => { - window.focus(&this.focus_handle(cx), cx); - } - MessageEditorEvent::Send => { - window.focus(&this.focus_handle(cx), cx); - } - MessageEditorEvent::SendImmediately => { - this.send_queued_message_at_index(index, true, window, cx); - } - _ => {} - }, - ); + v_flex() + .w_full() + .p_3p5() + .gap_2p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(linear_gradient( + 180., + linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), + linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), + )) + .child( + v_flex().gap_0p5().child(Label::new(heading_label)).child( + Label::new(description_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } - thread.update(cx, |thread, _cx| { - thread.queued_message_editors.push(editor); - thread - .queued_message_editor_subscriptions - .push(subscription); - }); - } + pub(crate) fn as_native_connection( + &self, + cx: &App, + ) -> Option> { + self.root_thread(cx)? + .read(cx) + .connection() + .clone() + .downcast() + } - if let Some(active) = self.root_thread_view() { - active.update(cx, |active, _cx| { - active.last_synced_queue_length = needed_count; - }); - } + pub fn as_native_thread(&self, cx: &App) -> Option> { + self.as_native_connection(cx)? + .thread(self.root_session_id.as_ref()?, cx) } fn render_markdown( @@ -3104,6 +3326,7 @@ impl ConversationView { match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { + window.request_attention(); if let Some(primary) = cx.primary_display() { self.pop_up( icon, @@ -3119,6 +3342,7 @@ impl ConversationView { } } NotifyWhenAgentWaiting::AllScreens => { + window.request_attention(); let caption = caption.into(); for screen in cx.displays() { self.pop_up( @@ -3288,7 +3512,7 @@ impl ConversationView { dismiss_if_visible(this, window, cx); } AgentPanelEvent::EntryChanged - | AgentPanelEvent::TerminalClosed { .. } + | AgentPanelEvent::TerminalCloseRequested { .. } | AgentPanelEvent::ThreadInteracted { .. } => {} }, )); @@ -3296,7 +3520,8 @@ impl ConversationView { } } - fn dismiss_notifications(&mut self, cx: &mut Context) { + pub(crate) fn dismiss_notifications(&mut self, cx: &mut Context) -> bool { + let had_notifications = !self.notifications.is_empty(); for window in self.notifications.drain(..) { window .update(cx, |_, window, _| { @@ -3306,6 +3531,7 @@ impl ConversationView { self.notification_subscriptions.remove(&window); } + had_notifications } fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context) { @@ -3399,7 +3625,7 @@ impl ConversationView { } pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { - let agent_id = self.agent.agent_id(); + self.cancel_request_elicitations(cx); if let Some(active) = self.root_thread_view() { active.update(cx, |active, cx| active.clear_thread_error(cx)); } @@ -3409,7 +3635,7 @@ impl ConversationView { return; }; window.defer(cx, |window, cx| { - Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx); + Self::handle_auth_required(this, AuthRequired::new(), connection, window, cx); }) } @@ -3436,24 +3662,25 @@ impl ConversationView { if let Some(active) = this.root_thread_view() { active.update(cx, |active, cx| active.handle_thread_error(err, cx)); } - } else if let Some(connected) = this.as_connected_mut() { - connected.auth_state = AuthState::Unauthenticated { - description: None, - configuration_view: None, - pending_auth_method: None, - _subscription: None, - }; - cx.emit(StateChange); - if let Some(view) = connected.active_view() - && view - .read(cx) - .message_editor - .focus_handle(cx) - .is_focused(window) - { - this.focus_handle.focus(window, cx) + } else { + this.cancel_request_elicitations(cx); + if let Some(connected) = this.as_connected_mut() { + connected.auth_state = AuthState::Unauthenticated { + description: None, + pending_auth_method: None, + }; + cx.emit(StateChange); + if let Some(view) = connected.active_view() + && view + .read(cx) + .message_editor + .focus_handle(cx) + .is_focused(window) + { + this.focus_handle.focus(window, cx) + } + cx.notify(); } - cx.notify(); } drop(this.auth_task.take()); }) @@ -3514,6 +3741,14 @@ impl Focusable for ConversationView { } } +impl ConversationView { + pub(crate) fn activation_focus_handle(&self, cx: &App) -> FocusHandle { + self.active_thread() + .map(|thread| thread.read(cx).activation_focus_handle(cx)) + .unwrap_or_else(|| self.focus_handle.clone()) + } +} + #[cfg(any(test, feature = "test-support"))] impl ConversationView { /// Expands a tool call so its content is visible. @@ -3543,72 +3778,81 @@ impl ConversationView { impl Render for ConversationView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.sync_queued_message_editors(window, cx); - - v_flex() - .track_focus(&self.focus_handle) - .size_full() - .bg(cx.theme().colors().panel_background) - .child(match &self.server_state { - ServerState::Loading { .. } => { - let label_text = self - .loading_status - .clone() - .unwrap_or_else(|| "Loading…".into()); - v_flex() - .flex_1() - .size_full() - .items_center() - .justify_center() - .child( - Label::new(label_text).color(Color::Muted).with_animation( - "loading-agent-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.7)), - |label, delta| label.alpha(delta), - ), - ) - .into_any() - } - ServerState::LoadError { error: e, .. } => v_flex() + self.sync_request_elicitation_states(window, cx); + let request_elicitation_connection = self.request_elicitation_connection(); + let active_thread_renders_request_elicitations = + self.active_thread_renders_request_elicitations(); + let content = match &self.server_state { + ServerState::Loading { .. } => { + let label_text = self + .loading_status + .clone() + .unwrap_or_else(|| "Loading…".into()); + v_flex() .flex_1() .size_full() .items_center() - .justify_end() - .child(self.render_load_error(e, window, cx)) - .into_any(), - ServerState::Connected(ConnectedServerState { + .justify_center() + .child( + Label::new(label_text).color(Color::Muted).with_animation( + "loading-agent-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.3, 0.7)), + |label, delta| label.alpha(delta), + ), + ) + .into_any() + } + ServerState::LoadError { error: e, .. } => v_flex() + .flex_1() + .size_full() + .items_center() + .justify_end() + .child(self.render_load_error(e, window, cx)) + .into_any(), + ServerState::Connected(ConnectedServerState { + connection, + auth_state: + AuthState::Unauthenticated { + description, + pending_auth_method, + }, + .. + }) => v_flex() + .flex_1() + .size_full() + .justify_end() + .child(self.render_auth_required_state( connection, - auth_state: - AuthState::Unauthenticated { - description, - configuration_view, - pending_auth_method, - _subscription, - }, - .. - }) => 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_element(), - ServerState::Connected(connected) => { - if let Some(view) = connected.active_view() { - view.clone().into_any_element() - } else { - debug_panic!("This state should never be reached"); - div().into_any_element() - } + description.as_ref(), + pending_auth_method.as_ref(), + window, + cx, + )) + .into_any_element(), + ServerState::Connected(connected) => { + if let Some(view) = connected.active_view() { + view.clone().into_any_element() + } else { + debug_panic!("This state should never be reached"); + div().into_any_element() } + } + }; + + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .bg(cx.theme().colors().panel_background) + .child(v_flex().flex_1().min_h_0().child(content)) + .when(!active_thread_renders_request_elicitations, |this| { + this.children(request_elicitation_connection.as_ref().map_or_else( + Vec::new, + |connection| { + self.render_request_elicitations(connection, cx.entity().downgrade(), cx) + }, + )) }) } } @@ -3836,6 +4080,7 @@ pub(crate) mod tests { use agent_servers::FakeAcpAgentServer; use editor::MultiBufferOffset; use editor::actions::Paste; + use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _}; use fs::FakeFs; use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size}; use parking_lot::Mutex; @@ -3881,6 +4126,192 @@ pub(crate) mod tests { assert!(!weak_view.is_upgradable()); } + #[gpui::test] + async fn test_drop_preserves_shared_pending_request_elicitations(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let response = Arc::new(Mutex::new(None)); + let server = ReleaseRequestElicitationServer { + response: response.clone(), + }; + let (conversation_view, cx) = setup_conversation_view(server, cx).await; + let _connection = conversation_view + .read_with(cx, |view, _cx| view.request_elicitation_connection()) + .expect("conversation should have an active connection"); + let store = _connection + .request_elicitations() + .expect("connection should expose request elicitations"); + store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 1, + "test should start with one pending request elicitation" + ); + }); + + assert_eq!(*response.lock(), None); + let weak_view = conversation_view.downgrade(); + drop(conversation_view); + cx.update(|_, _| {}); + cx.run_until_parked(); + + assert!(!weak_view.is_upgradable()); + store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 1, + "view release should not clear connection-wide request elicitations" + ); + }); + assert_eq!(*response.lock(), None); + + store.update(cx, |store, cx| store.clear(cx)); + cx.run_until_parked(); + assert!(matches!( + response.lock().as_ref(), + Some(acp::ElicitationAction::Cancel) + )); + } + + #[gpui::test] + async fn test_state_transition_preserves_shared_pending_request_elicitations( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let response = Arc::new(Mutex::new(None)); + let server = ReleaseRequestElicitationServer { + response: response.clone(), + }; + let (conversation_view, cx) = setup_conversation_view(server, cx).await; + let connection = conversation_view + .read_with(cx, |view, _cx| view.request_elicitation_connection()) + .expect("conversation should have an active connection"); + let store = connection + .request_elicitations() + .expect("connection should expose request elicitations"); + store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 1, + "test should start with one pending request elicitation" + ); + }); + + conversation_view.update(cx, |view, cx| { + view.set_server_state( + ServerState::LoadError { + error: LoadError::Other("load failed".into()), + }, + cx, + ); + }); + cx.run_until_parked(); + + store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 1, + "leaving a connection should not clear connection-wide request elicitations" + ); + }); + assert_eq!(*response.lock(), None); + + store.update(cx, |store, cx| store.clear(cx)); + cx.run_until_parked(); + assert!(matches!( + response.lock().as_ref(), + Some(acp::ElicitationAction::Cancel) + )); + } + + #[gpui::test] + async fn test_successful_session_creation_clears_resolved_request_elicitations( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let store = cx.update(|cx| cx.new(|_| ElicitationStore::default())); + let response = Arc::new(Mutex::new(None)); + let server = SessionCreationRequestElicitationServer { + store: store.clone(), + response: response.clone(), + }; + let (conversation_view, cx) = setup_conversation_view(server, cx).await; + let first_request_id = acp::RequestId::Number(1); + let second_request_id = acp::RequestId::Number(2); + let first_elicitation_id = store.read_with(cx, |store, _cx| { + assert_eq!( + store.elicitations().len(), + 2, + "session creation should be waiting on one prompt with another prompt still pending" + ); + store + .elicitations() + .iter() + .find_map(|elicitation| { + let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else { + return None; + }; + (&scope.request_id == &first_request_id).then(|| elicitation.id.clone()) + }) + .expect("first request-scoped elicitation should exist") + }); + + store.update(cx, |store, cx| { + store.respond_to_elicitation( + &first_elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + cx.run_until_parked(); + + assert!(matches!( + response.lock().as_ref(), + Some(acp::ElicitationAction::Accept(_)) + )); + conversation_view.read_with(cx, |view, _cx| { + let connected = view + .as_connected() + .expect("session creation should complete successfully"); + assert!( + connected.active_id.is_some(), + "successful session creation should install an active thread" + ); + }); + store.read_with(cx, |store, _cx| { + let [remaining] = store.elicitations() else { + panic!( + "expected only the pending request elicitation to remain, got {:?}", + store.elicitations() + ); + }; + let acp::ElicitationScope::Request(scope) = remaining.request.scope() else { + panic!("expected request-scoped elicitation"); + }; + assert_eq!(scope.request_id, second_request_id); + assert!(matches!( + remaining.status, + ElicitationStatus::Pending { .. } + )); + }); + + store.update(cx, |store, cx| store.clear(cx)); + cx.run_until_parked(); + } + #[gpui::test] async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) { init_test(cx); @@ -4062,12 +4493,13 @@ pub(crate) mod tests { .clone() }); - active_thread(&conversation_view, cx).update_in(cx, |thread, _window, cx| { + active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| { thread.add_to_queue( vec![acp::ContentBlock::Text(acp::TextContent::new( "queued".to_string(), ))], vec![], + window, cx, ); }); @@ -4098,6 +4530,116 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_queued_message_steer_defaults_off_and_toggles(cx: &mut TestAppContext) { + init_test(cx); + + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; + add_to_workspace(conversation_view.clone(), cx); + + let id = active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| { + thread.add_to_queue( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "queued".to_string(), + ))], + vec![], + window, + cx, + ); + thread.message_queue.first_id().unwrap() + }); + cx.run_until_parked(); + + // Default: steering is off, so the message waits for end-of-generation + // rather than interrupting the agent at the next boundary. + active_thread(&conversation_view, cx).read_with(cx, |thread, _cx| { + assert!( + !thread.message_queue.front_wants_steer(), + "steering should default off" + ); + }); + + active_thread(&conversation_view, cx).update(cx, |thread, _cx| { + thread.message_queue.toggle_steer(id); + }); + active_thread(&conversation_view, cx).read_with(cx, |thread, _cx| { + assert!( + thread.message_queue.front_wants_steer(), + "steering should be on after toggling" + ); + }); + } + + #[gpui::test] + async fn test_queue_resumes_after_stop_and_new_message(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(conversation_view.clone(), cx); + + let message_editor = message_editor(&conversation_view, cx); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("first", window, cx); + }); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); + cx.run_until_parked(); + + // Queue a follow-up while the agent is generating. + active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| { + thread.add_to_queue( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "queued".to_string(), + ))], + vec![], + window, + cx, + ); + }); + + // User stops generation: the queued message must NOT be sent. + active_thread(&conversation_view, cx) + .update_in(cx, |thread, _window, cx| thread.cancel_generation(cx)); + cx.run_until_parked(); + + let queue_len = active_thread(&conversation_view, cx) + .read_with(cx, |thread, _cx| thread.message_queue.len()); + assert_eq!(queue_len, 1, "stopping must not send the queued message"); + + // User sends a new message, which should resume queue auto-processing. + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("second", window, cx); + }); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); + cx.run_until_parked(); + + let session_id = conversation_view.read_with(cx, |view, cx| { + view.active_thread() + .unwrap() + .read(cx) + .thread + .read(cx) + .session_id() + .clone() + }); + + // When this generation completes, the queued message should be picked + // up automatically (regression test for the "frozen queue" bug). + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + + let queue_len = active_thread(&conversation_view, cx) + .read_with(cx, |thread, _cx| thread.message_queue.len()); + assert_eq!( + queue_len, 0, + "queued message should be auto-sent after the user re-engages" + ); + } + #[gpui::test] async fn test_notification_for_error(cx: &mut TestAppContext) { init_test(cx); @@ -4153,6 +4695,31 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_thread_view_seeds_existing_elicitation_form_state(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + + let connection = PreloadedElicitationConnection::default(); + let elicitation_id = connection.elicitation_id.clone(); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + + let elicitation_id = elicitation_id + .lock() + .clone() + .expect("connection should preload an elicitation"); + let active_thread = active_thread(&conversation_view, cx); + active_thread.read_with(cx, |thread, _cx| { + assert!( + thread.has_elicitation_form_state(&elicitation_id), + "pending form elicitations that predate ThreadView construction should be usable" + ); + }); + } + #[gpui::test] async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) { init_test(cx); @@ -4275,7 +4842,6 @@ pub(crate) mod tests { fn prompt( &self, - _id: acp_thread::UserMessageId, _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { @@ -4618,6 +5184,10 @@ pub(crate) mod tests { connected.active_id.is_none(), "There should be no active thread since no session was created" ); + assert!( + !view.active_thread_renders_request_elicitations(), + "request elicitations should render outside ThreadView when no thread exists" + ); assert!( connected.threads.is_empty(), "There should be no threads since no session was created" @@ -4657,6 +5227,10 @@ pub(crate) mod tests { connected.active_id.is_some(), "There should be an active thread after successful auth" ); + assert!( + view.active_thread_renders_request_elicitations(), + "request elicitations should render inside ThreadView while authenticated" + ); assert_eq!( connected.threads.len(), 1, @@ -4687,6 +5261,14 @@ pub(crate) mod tests { !view.supports_logout(), "Logout should be hidden after logout" ); + assert!( + view.active_thread().is_some(), + "The existing thread should still exist after logout" + ); + assert!( + !view.active_thread_renders_request_elicitations(), + "Unauthenticated auth UI should render request elicitations outside ThreadView" + ); }); } @@ -5397,11 +5979,71 @@ pub(crate) mod tests { ); } - async fn setup_conversation_view( - agent: impl AgentServer + 'static, - cx: &mut TestAppContext, - ) -> (Entity, &mut VisualTestContext) { - setup_conversation_view_with_initial_content_opt(agent, None, cx).await + async fn setup_conversation_view( + agent: impl AgentServer + 'static, + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + setup_conversation_view_with_initial_content_opt(agent, None, cx).await + } + + #[gpui::test] + async fn test_completed_plan_snapshot_keeps_list_state_in_sync(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + + message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| { + view.send(window, cx); + }); + cx.run_until_parked(); + + let session_id = active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert_thread_list_item_count_matches_entries(view, cx); + view.thread.read(cx).session_id().clone() + }); + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::Plan(acp::Plan::new(vec![acp::PlanEntry::new( + "Do the thing", + acp::PlanEntryPriority::Medium, + acp::PlanEntryStatus::InProgress, + )])), + cx, + ); + }); + cx.run_until_parked(); + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert_thread_list_item_count_matches_entries(view, cx); + }); + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::Plan(acp::Plan::new(vec![acp::PlanEntry::new( + "Do the thing", + acp::PlanEntryPriority::Medium, + acp::PlanEntryStatus::Completed, + )])), + cx, + ); + }); + cx.run_until_parked(); + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert_thread_list_item_count_matches_entries(view, cx); + }); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { + assert_thread_list_item_count_matches_entries(view, cx); + }); } async fn setup_conversation_view_with_initial_content( @@ -5663,6 +6305,330 @@ pub(crate) mod tests { }) } + #[derive(Clone, Default)] + struct PreloadedElicitationConnection { + elicitation_id: Arc>>, + } + + impl AgentConnection for PreloadedElicitationConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("preloaded-elicitation") + } + + fn telemetry_id(&self) -> SharedString { + "preloaded-elicitation".into() + } + + fn new_session( + self: Rc, + project: Entity, + _work_dirs: PathList, + cx: &mut App, + ) -> Task>> { + let session_id = acp::SessionId::new("new-session"); + let thread = build_test_thread( + self.clone(), + project, + "PreloadedElicitationConnection", + session_id.clone(), + cx, + ); + thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .expect("preloaded elicitation should be accepted") + .detach(); + }); + let elicitation_id = thread.read_with(cx, |thread, _cx| { + thread.entries().iter().find_map(|entry| { + if let AgentThreadEntry::Elicitation(elicitation_id) = entry { + Some(elicitation_id.clone()) + } else { + None + } + }) + }); + *self.elicitation_id.lock() = elicitation_id; + Task::ready(Ok(thread)) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + self + } + } + + struct SessionCreationRequestElicitationServer { + store: Entity, + response: Arc>>, + } + + impl AgentServer for SessionCreationRequestElicitationServer { + fn logo(&self) -> ui::IconName { + ui::IconName::ZedAgent + } + + fn agent_id(&self) -> AgentId { + "SessionCreationRequestElicitation".into() + } + + fn connect( + &self, + _delegate: AgentServerDelegate, + _project: Entity, + _cx: &mut App, + ) -> Task>> { + let connection = SessionCreationRequestElicitationConnection { + store: self.store.clone(), + response: self.response.clone(), + }; + Task::ready(Ok(Rc::new(connection))) + } + + fn into_any(self: Rc) -> Rc { + self + } + } + + struct SessionCreationRequestElicitationConnection { + store: Entity, + response: Arc>>, + } + + impl AgentConnection for SessionCreationRequestElicitationConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("session-creation-request-elicitation") + } + + fn telemetry_id(&self) -> SharedString { + "session-creation-request-elicitation".into() + } + + fn new_session( + self: Rc, + project: Entity, + _work_dirs: PathList, + cx: &mut App, + ) -> Task>> { + let thread = build_test_thread( + self.clone(), + project, + "SessionCreationRequestElicitationConnection", + acp::SessionId::new("session-creation-request-elicitation-session"), + cx, + ); + let first_response_task = self.store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .expect("first request-scoped elicitation should be accepted") + }); + self.store + .update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(2)), + acp::ElicitationSchema::new().string("account", true), + ), + "Provide an account", + ), + cx, + ) + .expect("second request-scoped elicitation should be accepted") + }) + .detach(); + + let response = self.response.clone(); + cx.spawn(async move |_cx| { + let elicitation_response = first_response_task.await; + *response.lock() = Some(elicitation_response.action); + Ok(thread) + }) + } + + fn request_elicitations(&self) -> Option> { + Some(self.store.clone()) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + self + } + } + + struct ReleaseRequestElicitationServer { + response: Arc>>, + } + + impl AgentServer for ReleaseRequestElicitationServer { + fn logo(&self) -> ui::IconName { + ui::IconName::ZedAgent + } + + fn agent_id(&self) -> AgentId { + "ReleaseRequestElicitation".into() + } + + fn connect( + &self, + _delegate: AgentServerDelegate, + _project: Entity, + cx: &mut App, + ) -> Task>> { + let connection = ReleaseRequestElicitationConnection { + store: cx.new(|_| ElicitationStore::default()), + response: self.response.clone(), + }; + Task::ready(Ok(Rc::new(connection))) + } + + fn into_any(self: Rc) -> Rc { + self + } + } + + struct ReleaseRequestElicitationConnection { + store: Entity, + response: Arc>>, + } + + impl AgentConnection for ReleaseRequestElicitationConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("release-request-elicitation") + } + + fn telemetry_id(&self) -> SharedString { + "release-request-elicitation".into() + } + + fn new_session( + self: Rc, + project: Entity, + _work_dirs: PathList, + cx: &mut App, + ) -> Task>> { + let thread = build_test_thread( + self.clone(), + project, + "ReleaseRequestElicitationConnection", + acp::SessionId::new("release-request-elicitation-session"), + cx, + ); + let response_task = self.store.update(cx, |store, cx| { + store + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationRequestScope::new(acp::RequestId::Number(1)), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .expect("request-scoped elicitation should be accepted") + }); + let response = self.response.clone(); + cx.spawn(async move |_cx| { + let elicitation_response = response_task.await; + *response.lock() = Some(elicitation_response.action); + }) + .detach(); + Task::ready(Ok(thread)) + } + + fn request_elicitations(&self) -> Option> { + Some(self.store.clone()) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + self + } + } + #[derive(Clone)] struct ResumeOnlyAgentConnection; @@ -5722,7 +6688,6 @@ pub(crate) mod tests { fn prompt( &self, - _id: acp_thread::UserMessageId, _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { @@ -5830,7 +6795,6 @@ pub(crate) mod tests { fn prompt( &self, - _id: acp_thread::UserMessageId, _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { @@ -5900,7 +6864,6 @@ pub(crate) mod tests { fn prompt( &self, - _id: acp_thread::UserMessageId, _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { @@ -6016,7 +6979,6 @@ pub(crate) mod tests { fn prompt( &self, - _id: acp_thread::UserMessageId, _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { @@ -6059,6 +7021,13 @@ pub(crate) mod tests { }) } + fn assert_thread_list_item_count_matches_entries(view: &ThreadView, cx: &App) { + assert_eq!( + view.list_state.item_count(), + view.thread.read(cx).entries().len() + usize::from(view.generating_indicator_in_list) + ); + } + fn message_editor( conversation_view: &Entity, cx: &TestAppContext, @@ -6177,7 +7146,7 @@ pub(crate) mod tests { let AgentThreadEntry::UserMessage(user_message) = &thread.entries()[2] else { panic!(); }; - user_message.id.clone().unwrap() + user_message.client_id.clone().unwrap() }); conversation_view.read_with(cx, |view, cx| { @@ -6456,10 +7425,20 @@ pub(crate) mod tests { cx.run_until_parked(); active_thread(&conversation_view, cx).update(cx, |view, cx| { - view.scroll_to_most_recent_user_prompt(cx); + view.scroll_to_user_message_index(None, cx); let scroll_top = view.list_state.logical_scroll_top(); // Entries layout is: [User1, Assistant1, User2, Assistant2] assert_eq!(scroll_top.item_ix, 2); + + view.scroll_to_top(cx); + view.scroll_to_user_message_index(Some(0), cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 0); + + view.scroll_to_top(cx); + view.scroll_to_user_message_index(Some(2), cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 2); }); } @@ -6474,7 +7453,7 @@ pub(crate) mod tests { // With no entries, scrolling should be a no-op and must not panic. active_thread(&conversation_view, cx).update(cx, |view, cx| { - view.scroll_to_most_recent_user_prompt(cx); + view.scroll_to_user_message_index(None, cx); let scroll_top = view.list_state.logical_scroll_top(); assert_eq!(scroll_top.item_ix, 0); }); @@ -6953,7 +7932,9 @@ pub(crate) mod tests { .find_map(|entry| match entry { AgentThreadEntry::AssistantMessage(message) => { message.chunks.iter().find_map(|chunk| match chunk { - AssistantMessageChunk::Message { block } => block.markdown().cloned(), + AssistantMessageChunk::Message { block, .. } => { + block.markdown().cloned() + } AssistantMessageChunk::Thought { .. } => None, }) } @@ -10147,19 +11128,21 @@ pub(crate) mod tests { "queued message".to_string(), ))], vec![], + window, cx, ); // Main editor must be empty for this path — it is by default, but // assert to make the precondition explicit. assert!(thread.message_editor.read(cx).is_empty(cx)); - thread.move_queued_message_to_main_editor(0, None, None, window, cx); + let id = thread.message_queue.first_id().unwrap(); + thread.move_queued_message_to_main_editor(id, None, None, window, cx); }); cx.run_until_parked(); // Queue should now be empty. let queue_len = active_thread(&conversation_view, cx) - .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + .read_with(cx, |thread, _cx| thread.message_queue.len()); assert_eq!(queue_len, 0, "Queue should be empty after move"); // Main editor should contain the queued message text. @@ -10195,16 +11178,18 @@ pub(crate) mod tests { "queued message".to_string(), ))], vec![], + window, cx, ); - thread.move_queued_message_to_main_editor(0, None, None, window, cx); + let id = thread.message_queue.first_id().unwrap(); + thread.move_queued_message_to_main_editor(id, None, None, window, cx); }); cx.run_until_parked(); // Queue should now be empty. let queue_len = active_thread(&conversation_view, cx) - .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + .read_with(cx, |thread, _cx| thread.message_queue.len()); assert_eq!(queue_len, 0, "Queue should be empty after move"); // Main editor should contain existing content + separator + queued content. @@ -10223,12 +11208,13 @@ pub(crate) mod tests { setup_conversation_view(StubAgentServer::default_response(), cx).await; add_to_workspace(conversation_view.clone(), cx); - active_thread(&conversation_view, cx).update(cx, |thread, cx| { + active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| { thread.add_to_queue( vec![acp::ContentBlock::Text(acp::TextContent::new( "first queued".to_string(), ))], vec![], + window, cx, ); thread.add_to_queue( @@ -10236,6 +11222,7 @@ pub(crate) mod tests { "second queued".to_string(), ))], vec![], + window, cx, ); }); @@ -10250,7 +11237,7 @@ pub(crate) mod tests { cx.run_until_parked(); let queue_len = active_thread(&conversation_view, cx) - .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + .read_with(cx, |thread, _cx| thread.message_queue.len()); assert_eq!( queue_len, 1, "Up arrow should pull the last queued message out of the queue" @@ -10268,7 +11255,7 @@ pub(crate) mod tests { cx.run_until_parked(); let queue_len = active_thread(&conversation_view, cx) - .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + .read_with(cx, |thread, _cx| thread.message_queue.len()); assert_eq!(queue_len, 1, "Queue should be untouched"); let text = editor.update(cx, |editor, cx| editor.text(cx)); assert_eq!(text, "second queued"); @@ -10282,7 +11269,7 @@ pub(crate) mod tests { paste_into_queued_message(cx, ClipboardItem::new_string("PASTED".to_string())).await; let queue_len = active_thread(&conversation_view, cx) - .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + .read_with(cx, |thread, _cx| thread.message_queue.len()); assert_eq!(queue_len, 0); let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx)); @@ -10312,7 +11299,7 @@ pub(crate) mod tests { .await; let queue_len = active_thread(&conversation_view, cx) - .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + .read_with(cx, |thread, _cx| thread.message_queue.len()); assert_eq!(queue_len, 0); let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx)); @@ -10336,7 +11323,7 @@ pub(crate) mod tests { setup_conversation_view(StubAgentServer::default_response(), cx).await; add_to_workspace(conversation_view.clone(), cx); - active_thread(&conversation_view, cx).update_in(cx, |thread, _window, cx| { + active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| { thread .session_capabilities .write() @@ -10346,6 +11333,7 @@ pub(crate) mod tests { "queued message".to_string(), ))], vec![], + window, cx, ); }); @@ -10354,10 +11342,10 @@ pub(crate) mod tests { let queued_editor = active_thread(&conversation_view, cx).read_with(cx, |thread, _cx| { thread - .queued_message_editors + .message_queue .first() - .cloned() - .expect("queued message editor not synced") + .map(|entry| entry.editor.clone()) + .expect("queued message editor not created") }); cx.write_to_clipboard(clipboard); @@ -10589,7 +11577,6 @@ pub(crate) mod tests { fn prompt( &self, - _id: acp_thread::UserMessageId, _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs new file mode 100644 index 00000000000000..94e0477ebca9b8 --- /dev/null +++ b/crates/agent_ui/src/conversation_view/elicitation.rs @@ -0,0 +1,2029 @@ +use acp_thread::{Elicitation, ElicitationEntryId, ElicitationStatus}; +use agent_client_protocol::schema::v1 as acp; +use collections::{HashMap, HashSet}; +use component::{Component, ComponentScope, example_group_with_title, single_example}; +use editor::Editor; +use futures::channel::oneshot; +use gpui::{AnyElement, App, Div, Empty, Entity, Hsla, SharedString, Window, div}; +use std::collections::BTreeMap; +use std::rc::Rc; +use ui::{ + Button, Checkbox, Color, Icon, IconName, IconSize, Indicator, Label, LabelSize, ToggleState, + prelude::*, +}; + +#[derive(Clone)] +struct ElicitationOption { + value: String, + label: SharedString, + description: Option, +} + +enum ElicitationFieldState { + Text(Entity), + Boolean(bool), + SingleSelect { value: Option }, + MultiSelect(HashSet), +} + +#[derive(PartialEq, Eq)] +enum ElicitationFieldValue { + Text(String), + Boolean(bool), + SingleSelect { value: Option }, + MultiSelect(HashSet), +} + +#[derive(PartialEq, Eq)] +pub(crate) struct ElicitationFormSubmission { + fields: HashMap, +} + +pub(crate) struct ElicitationFormState { + fields: HashMap, + field_errors: HashMap, + is_submitting: bool, +} + +impl ElicitationFormState { + pub(crate) fn new(schema: &acp::ElicitationSchema, window: &mut Window, cx: &mut App) -> Self { + let required = schema.required.as_deref().unwrap_or_default(); + let mut fields = HashMap::default(); + + for (name, property) in &schema.properties { + let is_required = required.iter().any(|required| required == name); + let field = match property { + acp::ElicitationPropertySchema::String(schema) => { + let options = single_select_options(schema); + if options.is_empty() { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(default) = &schema.default { + editor.set_text(default.clone(), window, cx); + } + editor + }); + ElicitationFieldState::Text(editor) + } else { + let value = single_select_default_value(schema, &options).or_else(|| { + is_required + .then(|| options.first().map(|option| option.value.clone())) + .flatten() + }); + ElicitationFieldState::SingleSelect { value } + } + } + acp::ElicitationPropertySchema::Number(schema) => { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(default) = schema.default { + editor.set_text(default.to_string(), window, cx); + } + editor + }); + ElicitationFieldState::Text(editor) + } + acp::ElicitationPropertySchema::Integer(schema) => { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(default) = schema.default { + editor.set_text(default.to_string(), window, cx); + } + editor + }); + ElicitationFieldState::Text(editor) + } + acp::ElicitationPropertySchema::Boolean(schema) => { + ElicitationFieldState::Boolean(schema.default.unwrap_or(false)) + } + acp::ElicitationPropertySchema::Array(schema) => { + ElicitationFieldState::MultiSelect( + schema + .default + .clone() + .unwrap_or_default() + .into_iter() + .collect(), + ) + } + _ => continue, + }; + fields.insert(name.clone(), field); + } + + Self { + fields, + field_errors: HashMap::default(), + is_submitting: false, + } + } + + fn snapshot(&self, cx: &App) -> ElicitationFormSubmission { + ElicitationFormSubmission { + fields: self + .fields + .iter() + .map(|(name, field)| { + let value = match field { + ElicitationFieldState::Text(editor) => { + ElicitationFieldValue::Text(editor.read(cx).text(cx)) + } + ElicitationFieldState::Boolean(value) => { + ElicitationFieldValue::Boolean(*value) + } + ElicitationFieldState::SingleSelect { value } => { + ElicitationFieldValue::SingleSelect { + value: value.clone(), + } + } + ElicitationFieldState::MultiSelect(values) => { + ElicitationFieldValue::MultiSelect(values.clone()) + } + }; + (name.clone(), value) + }) + .collect(), + } + } + + pub(crate) fn begin_submission(&mut self, cx: &App) -> Option { + if self.is_submitting { + return None; + } + self.is_submitting = true; + Some(self.snapshot(cx)) + } + + pub(crate) fn validation_matches_current_values( + &mut self, + submission: &ElicitationFormSubmission, + cx: &App, + ) -> bool { + let is_current = self.snapshot(cx) == *submission; + if !is_current { + self.is_submitting = false; + } + is_current + } + + #[cfg(test)] + pub(crate) fn collect( + &self, + schema: &acp::ElicitationSchema, + cx: &App, + ) -> Result, HashMap> { + self.snapshot(cx).validate(schema) + } + + pub(crate) fn set_errors(&mut self, errors: HashMap) { + self.field_errors = errors; + self.is_submitting = false; + } + + pub(crate) fn set_field_error( + &mut self, + field_name: impl Into, + error: impl Into, + ) { + self.field_errors.insert(field_name.into(), error.into()); + } + + pub(crate) fn set_boolean(&mut self, field_name: &str, value: bool) { + if let Some(ElicitationFieldState::Boolean(field)) = self.fields.get_mut(field_name) { + *field = value; + self.field_errors.remove(field_name); + } + } + + pub(crate) fn set_single_select(&mut self, field_name: &str, value: String) { + if let Some(ElicitationFieldState::SingleSelect { value: selected }) = + self.fields.get_mut(field_name) + { + *selected = Some(value); + self.field_errors.remove(field_name); + } + } + + pub(crate) fn set_multi_select(&mut self, field_name: &str, value: String, selected: bool) { + if let Some(ElicitationFieldState::MultiSelect(values)) = self.fields.get_mut(field_name) { + if selected { + values.insert(value); + } else { + values.remove(&value); + } + self.field_errors.remove(field_name); + } + } +} + +impl ElicitationFormSubmission { + pub(crate) fn validate( + &self, + schema: &acp::ElicitationSchema, + ) -> Result, HashMap> { + let required = schema.required.as_deref().unwrap_or_default(); + let mut content = BTreeMap::new(); + let mut errors = HashMap::default(); + + for (name, property) in &schema.properties { + let is_required = required.iter().any(|required| required == name); + let Some(field) = self.fields.get(name) else { + continue; + }; + + let field_content = match (property, field) { + ( + acp::ElicitationPropertySchema::String(schema), + ElicitationFieldValue::Text(value), + ) => { + if value.is_empty() { + if is_required { + Err(format!("{} is required", property_title(name, property)).into()) + } else { + Ok(None) + } + } else { + validate_string_value(property_title(name, property), schema, value) + .map(|()| Some(value.clone().into())) + } + } + ( + acp::ElicitationPropertySchema::String(schema), + ElicitationFieldValue::SingleSelect { value }, + ) => { + if let Some(value) = value { + validate_single_select_value(property_title(name, property), schema, value) + .and_then(|()| { + validate_string_value(property_title(name, property), schema, value) + }) + .map(|()| Some(value.clone().into())) + } else if is_required { + Err(format!("{} is required", property_title(name, property)).into()) + } else { + Ok(None) + } + } + ( + acp::ElicitationPropertySchema::Number(schema), + ElicitationFieldValue::Text(value), + ) => { + let value = value.trim(); + if value.is_empty() { + if is_required { + Err(format!("{} is required", property_title(name, property)).into()) + } else { + Ok(None) + } + } else { + validate_number_value(property_title(name, property), schema, value) + .map(|parsed| Some(parsed.into())) + } + } + ( + acp::ElicitationPropertySchema::Integer(schema), + ElicitationFieldValue::Text(value), + ) => { + let value = value.trim(); + if value.is_empty() { + if is_required { + Err(format!("{} is required", property_title(name, property)).into()) + } else { + Ok(None) + } + } else { + validate_integer_value(property_title(name, property), schema, value) + .map(|parsed| Some(parsed.into())) + } + } + ( + acp::ElicitationPropertySchema::Boolean(schema), + ElicitationFieldValue::Boolean(value), + ) => { + if is_required || *value || schema.default.is_some() { + Ok(Some((*value).into())) + } else { + Ok(None) + } + } + ( + acp::ElicitationPropertySchema::Array(schema), + ElicitationFieldValue::MultiSelect(selected), + ) => { + let mut values = multi_select_options(schema) + .into_iter() + .filter_map(|option| { + selected.contains(&option.value).then_some(option.value) + }) + .collect::>(); + values.sort(); + if values.is_empty() && !is_required { + Ok(None) + } else if schema + .min_items + .is_some_and(|min_items| values.len() < min_items as usize) + { + Err( + format!("{} needs more selections", property_title(name, property)) + .into(), + ) + } else if schema + .max_items + .is_some_and(|max_items| values.len() > max_items as usize) + { + Err( + format!("{} has too many selections", property_title(name, property)) + .into(), + ) + } else { + Ok(Some(values.into())) + } + } + _ => Ok(None), + }; + + match field_content { + Ok(Some(value)) => { + content.insert(name.clone(), value); + } + Ok(None) => {} + Err(error) => { + errors.insert(name.clone(), error); + } + } + } + + if errors.is_empty() { + Ok(content) + } else { + Err(errors) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[test] + fn string_validation_rejects_email_format_mismatch() { + let schema = acp::StringPropertySchema::email(); + + validate_string_value("Email".into(), &schema, "user@example.com") + .expect("valid email should be accepted"); + assert_eq!( + validate_string_value("Email".into(), &schema, "not-an-email") + .expect_err("invalid email should be rejected") + .to_string(), + "Email must be an email address" + ); + } + + #[test] + fn string_validation_rejects_pattern_mismatch() { + let schema = acp::StringPropertySchema::new().pattern("^prod-[0-9]+$"); + + validate_string_value("Environment".into(), &schema, "prod-42") + .expect("matching pattern should be accepted"); + assert_eq!( + validate_string_value("Environment".into(), &schema, "dev-42") + .expect_err("pattern mismatch should be rejected") + .to_string(), + "Environment does not match the requested pattern" + ); + } + + #[test] + fn string_validation_supports_bounded_advanced_patterns() { + let schema = acp::StringPropertySchema::new().pattern(r"^prod-(?=[0-9]+$)([0-9])\1$"); + + validate_string_value("Environment".into(), &schema, "prod-44") + .expect("lookahead and backreference should be accepted"); + assert_eq!( + validate_string_value("Environment".into(), &schema, "prod-45") + .expect_err("backreference mismatch should be rejected") + .to_string(), + "Environment does not match the requested pattern" + ); + } + + #[test] + fn string_validation_rejects_patterns_outside_resource_limits() { + let oversized_pattern = + acp::StringPropertySchema::new().pattern("a".repeat(MAX_ELICITATION_PATTERN_BYTES + 1)); + assert_eq!( + validate_string_value("Value".into(), &oversized_pattern, "a") + .expect_err("oversized pattern should be rejected") + .to_string(), + "Value has an invalid validation pattern" + ); + + let schema = acp::StringPropertySchema::new().pattern(".*"); + let oversized_value = "a".repeat(MAX_ELICITATION_PATTERN_INPUT_BYTES + 1); + assert_eq!( + validate_string_value("Value".into(), &schema, &oversized_value) + .expect_err("oversized pattern input should be rejected") + .to_string(), + "Value is too long to validate safely" + ); + } + + #[test] + fn number_validation_rejects_non_finite_values() { + let schema = acp::NumberPropertySchema::new(); + + assert_eq!( + validate_number_value("Amount".into(), &schema, "42.5") + .expect("finite number should be accepted"), + 42.5 + ); + + for value in ["NaN", "inf", "-inf", "1e309"] { + assert_eq!( + validate_number_value("Amount".into(), &schema, value) + .expect_err("non-finite number should be rejected") + .to_string(), + "Amount must be a finite number" + ); + } + } + + #[test] + fn should_render_pending_and_accepted_url_elicitations() { + let pending = Elicitation { + id: ElicitationEntryId("pending".into()), + request: acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + preview_request_scope(0), + acp::ElicitationSchema::new(), + ), + "Review this request.", + ), + status: pending_status(), + }; + assert!(should_render_elicitation(&pending)); + + let accepted_url = Elicitation { + id: ElicitationEntryId("accepted-url".into()), + request: acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + preview_request_scope(1), + acp::ElicitationId::new("accepted-url"), + "https://auth.example.com/device", + ), + "Authorize Zed in your browser.", + ), + status: ElicitationStatus::Accepted, + }; + assert!(should_render_elicitation(&accepted_url)); + + let accepted_form = Elicitation { + id: ElicitationEntryId("accepted-form".into()), + request: acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + preview_request_scope(2), + acp::ElicitationSchema::new(), + ), + "Review this request.", + ), + status: ElicitationStatus::Accepted, + }; + assert!(!should_render_elicitation(&accepted_form)); + + let pending_unknown = Elicitation { + id: ElicitationEntryId("pending-unknown".into()), + request: acp::CreateElicitationRequest::new( + acp::OtherElicitationMode::new("future", preview_request_scope(3), BTreeMap::new()), + "Use a future input mode.", + ), + status: pending_status(), + }; + assert!(!should_render_elicitation(&pending_unknown)); + } + + #[test] + fn url_host_presentation_highlights_destination_and_suspicious_idn() { + assert_eq!( + url_host_presentation("https://auth.example.com/device"), + Some(UrlHostPresentation { + host: "auth.example.com".to_string(), + suspicious_decoded_host: None, + }) + ); + assert_eq!( + url_host_presentation("https://xn--pple-43d.com/device"), + Some(UrlHostPresentation { + host: "xn--pple-43d.com".to_string(), + suspicious_decoded_host: Some("\u{0430}pple.com".to_string()), + }) + ); + } + + #[test] + fn display_url_segments_never_elide_url_characters() { + let url = format!( + "https://auth.example.com/oauth/authorize?state={}", + "a".repeat(MAX_URL_DISPLAY_SEGMENT_CHARS * 2) + ); + + let display_url_segments = display_url_segments(&url) + .into_iter() + .map(|segment| segment.to_string()) + .collect::>(); + assert!(display_url_segments.len() > 1); + assert!( + !display_url_segments + .iter() + .any(|segment| segment.contains('…')) + ); + assert!( + display_url_segments + .iter() + .all(|segment| segment.chars().count() <= MAX_URL_DISPLAY_SEGMENT_CHARS) + ); + assert_eq!(display_url_segments.concat(), url); + } + + #[test] + fn display_url_segments_use_larger_url_boundaries() { + let url = "https://auth.example.com/oauth/authorize?client_id=zed-desktop&scope=repository"; + + let display_url_segments = display_url_segments(url) + .into_iter() + .map(|segment| segment.to_string()) + .collect::>(); + assert_eq!(display_url_segments.concat(), url); + assert_eq!(display_url_segments[0], "https://auth.example.com/"); + assert!( + display_url_segments + .iter() + .any(|segment| segment.ends_with('?')) + ); + assert!( + display_url_segments + .iter() + .any(|segment| segment.ends_with('&')) + ); + } + + #[test] + fn single_select_options_include_titled_descriptions() { + let schema = acp::StringPropertySchema::new().one_of(vec![ + acp::EnumOption::new("production", "Production").description("Use live resources"), + ]); + + let options = single_select_options(&schema); + + let [option] = options.as_slice() else { + panic!("expected one option, got {}", options.len()); + }; + assert_eq!(option.value, "production"); + assert_eq!(option.label.to_string(), "Production"); + assert_eq!( + option + .description + .as_ref() + .map(|description| description.to_string()), + Some("Use live resources".to_string()) + ); + } + + #[test] + fn multi_select_options_include_titled_descriptions() { + let schema = acp::MultiSelectPropertySchema::titled(vec![ + acp::EnumOption::new("repository", "Repository Access") + .description("Read and update repositories"), + ]); + + let options = multi_select_options(&schema); + + let [option] = options.as_slice() else { + panic!("expected one option, got {}", options.len()); + }; + assert_eq!(option.value, "repository"); + assert_eq!(option.label.to_string(), "Repository Access"); + assert_eq!( + option + .description + .as_ref() + .map(|description| description.to_string()), + Some("Read and update repositories".to_string()) + ); + } + + #[gpui::test] + fn form_state_preserves_string_whitespace(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "token", + acp::StringPropertySchema::new() + .title("Token") + .default_value(" secret "), + true, + ); + let form_state = ElicitationFormState::new(&schema, window, cx); + let content = form_state + .collect(&schema, cx) + .expect("string with whitespace should be submitted"); + + assert_eq!( + content.get("token"), + Some(&acp::ElicitationContentValue::String( + " secret ".to_string() + )) + ); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_prevents_duplicate_submissions(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().string("name", true); + let mut form_state = ElicitationFormState::new(&schema, window, cx); + + assert!(form_state.begin_submission(cx).is_some()); + assert!(form_state.begin_submission(cx).is_none()); + + form_state.set_errors(HashMap::default()); + assert!(form_state.begin_submission(cx).is_some()); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_discards_validation_for_stale_values(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "name", + acp::StringPropertySchema::new().default_value("before"), + true, + ); + let mut form_state = ElicitationFormState::new(&schema, window, cx); + let submission = form_state + .begin_submission(cx) + .expect("first submission should start"); + let editor = match form_state.fields.get("name") { + Some(ElicitationFieldState::Text(editor)) => editor.clone(), + _ => panic!("expected a text field"), + }; + + editor.update(cx, |editor, cx| editor.set_text("after", window, cx)); + + assert!(!form_state.validation_matches_current_values(&submission, cx)); + assert!(form_state.begin_submission(cx).is_some()); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_discards_invalid_optional_single_select_default(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .enum_values(vec!["production".to_string(), "staging".to_string()]) + .default_value("development"), + false, + ); + let form_state = ElicitationFormState::new(&schema, window, cx); + let content = form_state + .collect(&schema, cx) + .expect("invalid optional default should be ignored"); + + assert_eq!(content.get("environment"), None); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_replaces_invalid_required_single_select_default(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .enum_values(vec!["production".to_string(), "staging".to_string()]) + .default_value("development"), + true, + ); + let form_state = ElicitationFormState::new(&schema, window, cx); + let content = form_state + .collect(&schema, cx) + .expect("required select should use the first valid choice"); + + assert_eq!( + content.get("environment"), + Some(&acp::ElicitationContentValue::String( + "production".to_string() + )) + ); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_rejects_invalid_single_select_value(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new().property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .enum_values(vec!["production".to_string(), "staging".to_string()]), + false, + ); + let mut form_state = ElicitationFormState::new(&schema, window, cx); + form_state.set_single_select("environment", "development".to_string()); + + let errors = form_state + .collect(&schema, cx) + .expect_err("invalid selected value should be rejected"); + assert_eq!( + errors + .get("environment") + .expect("environment should have an error") + .to_string(), + "Environment must be one of the provided options" + ); + + Editor::single_line(window, cx) + }); + } + + #[gpui::test] + fn form_state_reports_all_validation_errors(cx: &mut TestAppContext) { + crate::conversation_view::tests::init_test(cx); + + cx.add_window(|window, cx| { + let schema = acp::ElicitationSchema::new() + .string("account", true) + .property( + "age", + acp::IntegerPropertySchema::new().title("Age").minimum(18), + true, + ) + .property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .enum_values(vec!["production".to_string(), "staging".to_string()]), + false, + ); + let mut form_state = ElicitationFormState::new(&schema, window, cx); + if let Some(ElicitationFieldState::Text(editor)) = form_state.fields.get("age") { + editor.update(cx, |editor, cx| editor.set_text("abc", window, cx)); + } + form_state.set_single_select("environment", "development".to_string()); + + let errors = form_state + .collect(&schema, cx) + .expect_err("all invalid fields should be reported"); + assert_eq!( + errors + .get("account") + .expect("account should have an error") + .to_string(), + "account is required" + ); + assert_eq!( + errors + .get("age") + .expect("age should have an error") + .to_string(), + "Age must be an integer" + ); + assert_eq!( + errors + .get("environment") + .expect("environment should have an error") + .to_string(), + "Environment must be one of the provided options" + ); + + Editor::single_line(window, cx) + }); + } +} + +#[derive(RegisterComponent)] +pub struct ElicitationCardPreview; + +impl Component for ElicitationCardPreview { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn description() -> &'static str { + "ACP elicitation request cards as rendered in the agent panel." + } + + fn preview(window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children([ + example_group_with_title( + "Form Requests", + vec![ + single_example( + "Pending Form", + render_form_preview(0, pending_status(), &[], window, cx), + ) + .width(px(640.)), + single_example( + "Validation Errors", + render_form_preview( + 1, + pending_status(), + &[ + ("account_name", "Account name is required"), + ("environment", "Choose an environment"), + ("scopes", "Choose at least one access scope"), + ], + window, + cx, + ), + ) + .width(px(640.)), + ], + ) + .vertical() + .into_any_element(), + example_group_with_title( + "URL Requests", + vec![ + single_example( + "URL Consent", + render_url_preview(3, pending_status(), window, cx), + ) + .width(px(640.)), + ], + ) + .vertical() + .into_any_element(), + example_group_with_title( + "Terminal States", + vec![ + single_example( + "Declined", + render_form_preview(6, ElicitationStatus::Declined, &[], window, cx), + ) + .width(px(640.)), + single_example( + "Canceled", + render_form_preview(7, ElicitationStatus::Canceled, &[], window, cx), + ) + .width(px(640.)), + ], + ) + .vertical() + .into_any_element(), + ]) + .into_any_element() + } +} + +fn render_form_preview( + entry_ix: usize, + status: ElicitationStatus, + field_errors: &[(&'static str, &'static str)], + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let request = acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new(preview_request_scope(entry_ix), preview_form_schema()), + "Choose how Zed should connect to this account.", + ); + let mut form_state = matches!(status, ElicitationStatus::Pending { .. }).then(|| { + let acp::ElicitationMode::Form(mode) = &request.mode else { + unreachable!(); + }; + ElicitationFormState::new(&mode.requested_schema, window, cx) + }); + if let Some(form_state) = &mut form_state { + for (field_name, error) in field_errors { + form_state.set_field_error(*field_name, *error); + } + } + + render_preview_card(entry_ix, request, status, form_state.as_ref(), cx) +} + +fn preview_url() -> &'static str { + "https://auth.example.com/oauth/authorize?client_id=zed-desktop&redirect_uri=zed%3A%2F%2Fagent%2Facp%2Fcallback&scope=profile%20repository%20terminal&state=9b8b0a873a1e4b57b7f9f7b6d2d3d0f4" +} + +fn render_url_preview( + entry_ix: usize, + status: ElicitationStatus, + _window: &mut Window, + cx: &mut App, +) -> AnyElement { + let request = acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + preview_request_scope(entry_ix), + acp::ElicitationId::new(format!("preview-url-{entry_ix}")), + preview_url(), + ), + "Authorize Zed in your browser to finish signing in.", + ); + + render_preview_card(entry_ix, request, status, None, cx) +} + +fn render_preview_card( + entry_ix: usize, + request: acp::CreateElicitationRequest, + status: ElicitationStatus, + form_state: Option<&ElicitationFormState>, + cx: &App, +) -> AnyElement { + let elicitation = Elicitation { + id: ElicitationEntryId(format!("preview-elicitation-{entry_ix}").into()), + request, + status, + }; + + div() + .w_full() + .max_w(px(640.)) + .child( + ElicitationCard::new( + entry_ix, + &elicitation, + "Example Agent".into(), + form_state, + ElicitationCardHandlers::noop(), + ) + .render(cx), + ) + .into_any_element() +} + +fn pending_status() -> ElicitationStatus { + let (respond_tx, _response_rx) = oneshot::channel(); + ElicitationStatus::Pending { respond_tx } +} + +fn preview_request_scope(index: usize) -> acp::ElicitationRequestScope { + acp::ElicitationRequestScope::new(acp::RequestId::Number(index as i64)) +} + +fn preview_form_schema() -> acp::ElicitationSchema { + acp::ElicitationSchema::new() + .property( + "account_name", + acp::StringPropertySchema::new() + .title("Account Name") + .description("Used to label this connection in the agent panel.") + .default_value("Work"), + true, + ) + .property( + "environment", + acp::StringPropertySchema::new() + .title("Environment") + .description("Select the environment this credential should target.") + .one_of(vec![ + acp::EnumOption::new("production", "Production") + .description("Use the live account and production resources."), + acp::EnumOption::new("staging", "Staging") + .description("Validate changes against staging data first."), + acp::EnumOption::new("development", "Development"), + ]) + .default_value("staging"), + true, + ) + .property( + "scopes", + acp::MultiSelectPropertySchema::titled(vec![ + acp::EnumOption::new("profile", "Profile") + .description("Read account identity and basic profile details."), + acp::EnumOption::new("repository", "Repository Access") + .description("Read and update repositories connected to this account."), + acp::EnumOption::new("terminal", "Terminal Commands"), + ]) + .title("Access") + .description("Choose what the agent can use for this authorization.") + .min_items(1) + .default_value(vec!["profile".to_string(), "repository".to_string()]), + true, + ) + .property( + "remember", + acp::BooleanPropertySchema::new() + .title("Remember Authorization") + .description("Store this authorization for future sessions.") + .default_value(true), + false, + ) +} + +fn single_select_options(schema: &acp::StringPropertySchema) -> Vec { + if let Some(options) = &schema.one_of { + return options + .iter() + .map(|option| ElicitationOption { + value: option.value.clone(), + label: SharedString::from(option.title.clone()), + description: option.description.clone().map(SharedString::from), + }) + .collect(); + } + + schema + .enum_values + .as_deref() + .unwrap_or_default() + .iter() + .map(|value| ElicitationOption { + value: value.clone(), + label: SharedString::from(value.clone()), + description: None, + }) + .collect() +} + +fn single_select_default_value( + schema: &acp::StringPropertySchema, + options: &[ElicitationOption], +) -> Option { + schema + .default + .as_ref() + .filter(|default| { + options + .iter() + .any(|option| option.value.as_str() == default.as_str()) + }) + .cloned() +} + +fn multi_select_options(schema: &acp::MultiSelectPropertySchema) -> Vec { + match &schema.items { + acp::MultiSelectItems::String(items) => items + .values + .iter() + .map(|value| ElicitationOption { + value: value.clone(), + label: SharedString::from(value.clone()), + description: None, + }) + .collect(), + acp::MultiSelectItems::Titled(items) => items + .options + .iter() + .map(|option| ElicitationOption { + value: option.value.clone(), + label: SharedString::from(option.title.clone()), + description: option.description.clone().map(SharedString::from), + }) + .collect(), + _ => Vec::new(), + } +} + +fn validate_number_value( + title: SharedString, + schema: &acp::NumberPropertySchema, + value: &str, +) -> Result { + let parsed = value + .parse::() + .map_err(|_| SharedString::from(format!("{title} must be a number")))?; + if !parsed.is_finite() { + return Err(format!("{title} must be a finite number").into()); + } + if let Some(minimum) = schema.minimum + && parsed < minimum + { + return Err(format!("{title} must be at least {minimum}").into()); + } + if let Some(maximum) = schema.maximum + && parsed > maximum + { + return Err(format!("{title} must be at most {maximum}").into()); + } + + Ok(parsed) +} + +fn validate_integer_value( + title: SharedString, + schema: &acp::IntegerPropertySchema, + value: &str, +) -> Result { + let parsed = value + .parse::() + .map_err(|_| SharedString::from(format!("{title} must be an integer")))?; + if let Some(minimum) = schema.minimum + && parsed < minimum + { + return Err(format!("{title} must be at least {minimum}").into()); + } + if let Some(maximum) = schema.maximum + && parsed > maximum + { + return Err(format!("{title} must be at most {maximum}").into()); + } + + Ok(parsed) +} + +fn validate_string_value( + title: SharedString, + schema: &acp::StringPropertySchema, + value: &str, +) -> Result<(), SharedString> { + let length = value.chars().count(); + if schema + .min_length + .is_some_and(|min_length| length < min_length as usize) + { + return Err(format!("{title} is too short").into()); + } + if schema + .max_length + .is_some_and(|max_length| length > max_length as usize) + { + return Err(format!("{title} is too long").into()); + } + + validate_string_pattern_and_format(title, schema, value) +} + +fn validate_single_select_value( + title: SharedString, + schema: &acp::StringPropertySchema, + value: &str, +) -> Result<(), SharedString> { + let options = single_select_options(schema); + if options.iter().any(|option| option.value.as_str() == value) { + Ok(()) + } else { + Err(format!("{title} must be one of the provided options").into()) + } +} + +const MAX_ELICITATION_PATTERN_BYTES: usize = 16 * 1024; +const MAX_ELICITATION_PATTERN_INPUT_BYTES: usize = 1024 * 1024; +const ELICITATION_PATTERN_BACKTRACK_LIMIT: usize = 10_000; +const ELICITATION_PATTERN_COMPILED_SIZE_LIMIT: usize = 1024 * 1024; +const ELICITATION_PATTERN_DFA_SIZE_LIMIT: usize = 1024 * 1024; + +fn validate_string_pattern_and_format( + title: SharedString, + schema: &acp::StringPropertySchema, + value: &str, +) -> Result<(), SharedString> { + if schema.pattern.is_none() && schema.format.and_then(string_format_json_name).is_none() { + return Ok(()); + } + if schema + .pattern + .as_ref() + .is_some_and(|pattern| pattern.len() > MAX_ELICITATION_PATTERN_BYTES) + { + return Err(format!("{title} has an invalid validation pattern").into()); + } + if schema.pattern.is_some() && value.len() > MAX_ELICITATION_PATTERN_INPUT_BYTES { + return Err(format!("{title} is too long to validate safely").into()); + } + + let mut validation_schema = serde_json::Map::new(); + validation_schema.insert( + "type".to_string(), + serde_json::Value::String("string".into()), + ); + if let Some(pattern) = &schema.pattern { + validation_schema.insert( + "pattern".to_string(), + serde_json::Value::String(pattern.clone()), + ); + } + if let Some(format) = schema.format.and_then(string_format_json_name) { + validation_schema.insert( + "format".to_string(), + serde_json::Value::String(format.into()), + ); + } + + let validation_schema = serde_json::Value::Object(validation_schema); + let validator = jsonschema::options() + .should_validate_formats(true) + .with_pattern_options( + jsonschema::PatternOptions::fancy_regex() + .backtrack_limit(ELICITATION_PATTERN_BACKTRACK_LIMIT) + .size_limit(ELICITATION_PATTERN_COMPILED_SIZE_LIMIT) + .dfa_size_limit(ELICITATION_PATTERN_DFA_SIZE_LIMIT), + ) + .build(&validation_schema) + .map_err(|_| { + if schema.pattern.is_some() { + format!("{title} has an invalid validation pattern") + } else { + format!("{title} has an invalid validation format") + } + })?; + let value = serde_json::Value::String(value.to_string()); + if let Err(error) = validator.validate(&value) { + if matches!( + error.kind(), + jsonschema::error::ValidationErrorKind::BacktrackLimitExceeded { .. } + ) { + return Err(format!("{title} has a validation pattern that is too complex").into()); + } + } else { + return Ok(()); + } + + match ( + schema.pattern.is_some(), + schema.format.and_then(string_format_label), + ) { + (true, Some(_)) => Err(format!("{title} does not match the requested constraints").into()), + (true, None) => Err(format!("{title} does not match the requested pattern").into()), + (false, Some(format)) => Err(format!("{title} must be {format}").into()), + (false, None) => Ok(()), + } +} + +fn string_format_json_name(format: acp::StringFormat) -> Option<&'static str> { + match format { + acp::StringFormat::Email => Some("email"), + acp::StringFormat::Uri => Some("uri"), + acp::StringFormat::Date => Some("date"), + acp::StringFormat::DateTime => Some("date-time"), + _ => None, + } +} + +fn string_format_label(format: acp::StringFormat) -> Option<&'static str> { + match format { + acp::StringFormat::Email => Some("an email address"), + acp::StringFormat::Uri => Some("a URI"), + acp::StringFormat::Date => Some("a date"), + acp::StringFormat::DateTime => Some("a date and time"), + _ => None, + } +} + +fn property_title(name: &str, property: &acp::ElicitationPropertySchema) -> SharedString { + let title = match property { + acp::ElicitationPropertySchema::String(schema) => schema.title.as_deref(), + acp::ElicitationPropertySchema::Number(schema) => schema.title.as_deref(), + acp::ElicitationPropertySchema::Integer(schema) => schema.title.as_deref(), + acp::ElicitationPropertySchema::Boolean(schema) => schema.title.as_deref(), + acp::ElicitationPropertySchema::Array(schema) => schema.title.as_deref(), + _ => None, + }; + SharedString::from(title.unwrap_or(name).to_string()) +} + +fn property_description(property: &acp::ElicitationPropertySchema) -> Option { + match property { + acp::ElicitationPropertySchema::String(schema) => schema.description.clone(), + acp::ElicitationPropertySchema::Number(schema) => schema.description.clone(), + acp::ElicitationPropertySchema::Integer(schema) => schema.description.clone(), + acp::ElicitationPropertySchema::Boolean(schema) => schema.description.clone(), + acp::ElicitationPropertySchema::Array(schema) => schema.description.clone(), + _ => None, + } + .map(SharedString::from) +} + +type RespondHandler = Rc; +type OpenUrlHandler = Rc; +type BooleanHandler = Rc; +type SelectHandler = Rc; +type MultiSelectHandler = Rc; + +#[derive(Clone)] +pub(crate) struct ElicitationCardHandlers { + on_submit: RespondHandler, + on_decline: RespondHandler, + on_cancel: RespondHandler, + on_dismiss_url: RespondHandler, + on_open_url: OpenUrlHandler, + on_boolean_change: BooleanHandler, + on_single_select_change: SelectHandler, + on_multi_select_change: MultiSelectHandler, +} + +impl ElicitationCardHandlers { + pub(crate) fn new( + on_submit: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static, + on_decline: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static, + on_cancel: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static, + on_dismiss_url: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static, + on_open_url: impl Fn(ElicitationEntryId, String, &mut Window, &mut App) + 'static, + on_boolean_change: impl Fn(ElicitationEntryId, String, bool, &mut App) + 'static, + on_single_select_change: impl Fn(ElicitationEntryId, String, String, &mut App) + 'static, + on_multi_select_change: impl Fn(ElicitationEntryId, String, String, bool, &mut App) + 'static, + ) -> Self { + Self { + on_submit: Rc::new(on_submit), + on_decline: Rc::new(on_decline), + on_cancel: Rc::new(on_cancel), + on_dismiss_url: Rc::new(on_dismiss_url), + on_open_url: Rc::new(on_open_url), + on_boolean_change: Rc::new(on_boolean_change), + on_single_select_change: Rc::new(on_single_select_change), + on_multi_select_change: Rc::new(on_multi_select_change), + } + } + + pub(crate) fn noop() -> Self { + Self::new( + |_, _, _| {}, + |_, _, _| {}, + |_, _, _| {}, + |_, _, _| {}, + |_, _, _, _| {}, + |_, _, _, _| {}, + |_, _, _, _| {}, + |_, _, _, _, _| {}, + ) + } +} + +pub(crate) fn should_render_elicitation(elicitation: &Elicitation) -> bool { + matches!( + (&elicitation.status, &elicitation.request.mode), + ( + ElicitationStatus::Pending { .. }, + acp::ElicitationMode::Form(_) | acp::ElicitationMode::Url(_) + ) | (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_)) + ) +} + +const MIN_URL_DISPLAY_SEGMENT_CHARS: usize = 16; +const MAX_URL_DISPLAY_SEGMENT_CHARS: usize = 64; + +#[derive(Debug, PartialEq, Eq)] +struct UrlHostPresentation { + host: String, + suspicious_decoded_host: Option, +} + +fn url_host_presentation(url: &str) -> Option { + let url = url::Url::parse(url).ok()?; + let host = url.host_str()?.to_string(); + let (decoded_host, suspicious_characters) = crate::unicode_confusables::scan_host(&host); + + Some(UrlHostPresentation { + host, + suspicious_decoded_host: (!suspicious_characters.is_empty()).then_some(decoded_host), + }) +} + +fn display_url_segments(url: &str) -> Vec { + let mut segments = Vec::new(); + let mut segment = String::new(); + let mut segment_chars = 0; + let mut characters = url.chars().peekable(); + + while let Some(character) = characters.next() { + segment.push(character); + segment_chars += 1; + + let should_split_at_boundary = segment_chars >= MIN_URL_DISPLAY_SEGMENT_CHARS + && is_url_display_segment_boundary(character); + let should_split_at_length = segment_chars >= MAX_URL_DISPLAY_SEGMENT_CHARS; + + if characters.peek().is_some() && (should_split_at_boundary || should_split_at_length) { + segments.push(std::mem::take(&mut segment).into()); + segment_chars = 0; + } + } + + if !segment.is_empty() { + segments.push(segment.into()); + } + + segments +} + +fn is_url_display_segment_boundary(character: char) -> bool { + matches!(character, '/' | '?' | '&' | '#') +} + +pub(crate) struct ElicitationCard<'a> { + entry_ix: usize, + elicitation: &'a Elicitation, + requester_name: SharedString, + form_state: Option<&'a ElicitationFormState>, + handlers: ElicitationCardHandlers, +} + +impl<'a> ElicitationCard<'a> { + pub(crate) fn new( + entry_ix: usize, + elicitation: &'a Elicitation, + requester_name: SharedString, + form_state: Option<&'a ElicitationFormState>, + handlers: ElicitationCardHandlers, + ) -> Self { + Self { + entry_ix, + elicitation, + requester_name, + form_state, + handlers, + } + } + + pub(crate) fn render(self, cx: &App) -> Div { + let border_color = cx.theme().colors().border.opacity(0.8); + let header_background = cx + .theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)); + let tool_name_font_size = rems_from_px(13.); + let is_pending = matches!(&self.elicitation.status, ElicitationStatus::Pending { .. }); + let is_accepted_url = matches!( + (&self.elicitation.status, &self.elicitation.request.mode), + (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_)) + ); + let (status_label, status_icon, status_color) = match &self.elicitation.status { + ElicitationStatus::Pending { .. } => ("Waiting for input", IconName::Info, Color::Info), + ElicitationStatus::Accepted if is_accepted_url => { + ("Waiting for completion", IconName::Info, Color::Info) + } + ElicitationStatus::Accepted => ("Submitted", IconName::Check, Color::Success), + ElicitationStatus::Declined => ("Declined", IconName::Close, Color::Muted), + ElicitationStatus::Canceled => ("Canceled", IconName::Circle, Color::Muted), + ElicitationStatus::Completed => ("Completed", IconName::Check, Color::Success), + }; + + let body = v_flex() + .gap_2() + .p_3() + .child(Label::new(self.elicitation.request.message.clone()).size(LabelSize::Small)); + let body = match &self.elicitation.request.mode { + acp::ElicitationMode::Form(mode) if is_pending => { + body.child(self.render_form(mode, cx)) + } + acp::ElicitationMode::Url(mode) if is_pending || is_accepted_url => { + body.child(self.render_url_elicitation(mode)) + } + _ => body, + }; + + v_flex() + .mx_5() + .my_1p5() + .rounded_md() + .border_1() + .border_color(border_color) + .overflow_hidden() + .child( + h_flex() + .h_8() + .p_1() + .w_full() + .justify_between() + .bg(header_background) + .child( + h_flex() + .min_w_0() + .gap_1p5() + .px_1() + .child( + Icon::new(status_icon) + .size(IconSize::Small) + .color(status_color), + ) + .child( + Label::new(format!("Input Requested by {}", self.requester_name)) + .size(LabelSize::Custom(tool_name_font_size)) + .truncate(), + ), + ) + .child( + Label::new(status_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child(body) + .when(is_pending || is_accepted_url, |this| { + this.child(self.render_actions(cx)) + }) + } + + fn render_form(&self, mode: &acp::ElicitationFormMode, cx: &App) -> AnyElement { + let Some(state) = self.form_state else { + return Empty.into_any_element(); + }; + + v_flex() + .gap_2() + .when_some(mode.requested_schema.title.clone(), |this, title| { + this.child(Label::new(title).size(LabelSize::Small)) + }) + .when_some( + mode.requested_schema.description.clone(), + |this, description| { + this.child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ) + .children(mode.requested_schema.properties.iter().filter_map( + |(field_name, property)| { + let field = state.fields.get(field_name)?; + Some(self.render_field( + field_name, + property, + field, + state.field_errors.get(field_name), + cx, + )) + }, + )) + .into_any_element() + } + + fn render_field( + &self, + field_name: &str, + property: &acp::ElicitationPropertySchema, + field: &ElicitationFieldState, + error: Option<&SharedString>, + cx: &App, + ) -> AnyElement { + let label = property_title(field_name, property); + let description = property_description(property); + let border_color = cx.theme().colors().border.opacity(0.8); + let field_border_color = if error.is_some() { + Color::Error.color(cx) + } else { + border_color + }; + let editor_background = cx.theme().colors().editor_background; + let label_color = if error.is_some() { + Color::Error + } else { + Color::Default + }; + + if let ElicitationFieldState::Boolean(value) = field { + let checkbox_state = if *value { + ToggleState::Selected + } else { + ToggleState::Unselected + }; + let next_value = !*value; + let on_boolean_change = self.handlers.on_boolean_change.clone(); + let elicitation_id = self.elicitation.id.clone(); + let field_name = field_name.to_string(); + let row_id = format!("elicitation-bool-row-{}-{field_name}", self.entry_ix); + let checkbox_id = format!("elicitation-bool-{}-{field_name}", self.entry_ix); + + return v_flex() + .gap_1() + .child( + h_flex() + .id(row_id) + .w_full() + .items_start() + .gap_1() + .cursor_pointer() + .on_click(move |_, _window, cx| { + on_boolean_change( + elicitation_id.clone(), + field_name.clone(), + next_value, + cx, + ); + }) + .child(div().child(Checkbox::new(checkbox_id, checkbox_state))) + .child( + v_flex() + .gap_0p5() + .child(Label::new(label).size(LabelSize::Small).color(label_color)) + .when_some(description, |this, description| { + this.child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ), + ) + .when_some(error.cloned(), |this, error| { + this.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) + }) + .into_any_element(); + } + + let label = if error.is_some() { + Label::new(label).size(LabelSize::Small).color(Color::Error) + } else { + Label::new(label).size(LabelSize::Small) + }; + + v_flex() + .gap_1() + .child(label) + .when_some(description, |this, description| { + this.child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .child(match field { + ElicitationFieldState::Text(editor) => div() + .rounded_sm() + .border_1() + .border_color(field_border_color) + .bg(editor_background) + .px_1() + .py_0p5() + .text_xs() + .child(editor.clone().into_any_element()) + .into_any_element(), + ElicitationFieldState::Boolean(_) => Empty.into_any_element(), + ElicitationFieldState::SingleSelect { value } => { + let options = match property { + acp::ElicitationPropertySchema::String(schema) => { + single_select_options(schema) + } + _ => Vec::new(), + }; + self.render_single_select( + field_name, + value.as_ref(), + options, + error.is_some(), + cx, + ) + } + ElicitationFieldState::MultiSelect(selected) => { + let options = match property { + acp::ElicitationPropertySchema::Array(schema) => { + multi_select_options(schema) + } + _ => Vec::new(), + }; + v_flex() + .gap_1() + .children(options.into_iter().map(|option| { + let is_selected = selected.contains(&option.value); + let checkbox_state = if is_selected { + ToggleState::Selected + } else { + ToggleState::Unselected + }; + let row_background = Self::option_row_background(is_selected, cx); + let hover_background = + Self::option_row_hover_background(is_selected, cx); + let on_multi_select_change = + self.handlers.on_multi_select_change.clone(); + let elicitation_id = self.elicitation.id.clone(); + let field_name = field_name.to_string(); + let value = option.value.clone(); + let checkbox_id = format!( + "elicitation-multi-{}-{field_name}-{}", + self.entry_ix, option.value + ); + h_flex() + .id(SharedString::from(format!( + "elicitation-multi-option-{}-{field_name}-{}", + self.entry_ix, option.value + ))) + .w_full() + .min_h(rems_from_px(28.)) + .items_start() + .gap_1p5() + .rounded_sm() + .border_1() + .border_color(field_border_color.opacity(0.5)) + .bg(row_background) + .px_2() + .py_1() + .hover(move |this| this.bg(hover_background).cursor_pointer()) + .on_click(move |_, _window, cx| { + on_multi_select_change( + elicitation_id.clone(), + field_name.clone(), + value.clone(), + !is_selected, + cx, + ); + }) + .child(div().child(Checkbox::new(checkbox_id, checkbox_state))) + .child(Self::render_option_content(option)) + })) + .into_any_element() + } + }) + .when_some(error.cloned(), |this, error| { + this.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) + }) + .into_any_element() + } + + fn render_single_select( + &self, + field_name: &str, + selected_value: Option<&String>, + options: Vec, + has_error: bool, + cx: &App, + ) -> AnyElement { + let entry_ix = self.entry_ix; + let border_color = if has_error { + Color::Error.color(cx) + } else { + cx.theme().colors().border.opacity(0.8) + }; + let elicitation_id = self.elicitation.id.clone(); + let field_name = field_name.to_string(); + let on_single_select_change = self.handlers.on_single_select_change.clone(); + + v_flex() + .gap_1() + .children(options.into_iter().map(move |option| { + let option_value = option.value.clone(); + let option_id = + format!("elicitation-select-option-{entry_ix}-{field_name}-{option_value}"); + let is_selected = + selected_value.is_some_and(|selected_value| selected_value == &option.value); + let row_background = Self::option_row_background(is_selected, cx); + let hover_background = Self::option_row_hover_background(is_selected, cx); + let control_background = Self::option_control_background(cx); + let elicitation_id = elicitation_id.clone(); + let field_name = field_name.clone(); + let on_single_select_change = on_single_select_change.clone(); + + h_flex() + .id(option_id) + .w_full() + .min_h(rems_from_px(28.)) + .items_start() + .gap_1p5() + .rounded_sm() + .border_1() + .border_color(border_color.opacity(0.5)) + .bg(row_background) + .px_2() + .py_1() + .hover(move |this| this.bg(hover_background).cursor_pointer()) + .on_click(move |_, _window, cx| { + on_single_select_change( + elicitation_id.clone(), + field_name.clone(), + option_value.clone(), + cx, + ); + }) + .child( + div() + .size(Checkbox::container_size()) + .flex_none() + .flex() + .items_center() + .justify_center() + .child(Self::render_radio_indicator( + is_selected, + border_color, + control_background, + )), + ) + .child(Self::render_option_content(option)) + })) + .into_any_element() + } + + fn render_option_content(option: ElicitationOption) -> Div { + v_flex() + .min_w_0() + .flex_1() + .gap_0p5() + .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .when_some(option.description, |this, description| { + this.child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + } + + fn option_row_background(is_selected: bool, cx: &App) -> Hsla { + let editor_background = cx.theme().colors().editor_background; + if is_selected { + editor_background.blend(Color::Accent.color(cx).opacity(0.08)) + } else { + editor_background + } + } + + fn option_row_hover_background(is_selected: bool, cx: &App) -> Hsla { + let editor_background = cx.theme().colors().editor_background; + if is_selected { + editor_background.blend(Color::Accent.color(cx).opacity(0.1)) + } else { + cx.theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)) + } + } + + fn option_control_background(cx: &App) -> Hsla { + cx.theme().colors().editor_background + } + + fn render_radio_indicator(is_selected: bool, border_color: Hsla, background: Hsla) -> Div { + div() + .size_3() + .flex() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(border_color) + .bg(background) + .when(is_selected, |this| { + this.child(Indicator::dot().color(Color::Accent)) + }) + } + + fn render_url_elicitation(&self, mode: &acp::ElicitationUrlMode) -> AnyElement { + v_flex() + .gap_2() + .when_some(url_host_presentation(&mode.url), |this, presentation| { + this.child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_1() + .child( + Label::new("Destination") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new(presentation.host).size(LabelSize::Small)), + ) + .when_some( + presentation.suspicious_decoded_host, + |this, decoded_host| { + this.child( + h_flex() + .items_start() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new(format!( + "This internationalized address displays as {decoded_host}. Verify it carefully." + )) + .size(LabelSize::Small) + .color(Color::Warning), + ), + ) + }, + ), + ) + }) + .child(Self::render_url_summary(&mode.url)) + .into_any_element() + } + + fn render_url_summary(url: &str) -> AnyElement { + h_flex() + .gap_1() + .w_full() + .min_w_0() + .items_start() + .child( + div().h(rems_from_px(16.)).flex().items_center().child( + Icon::new(IconName::Link) + .size(IconSize::XSmall) + .color(Color::Muted), + ), + ) + .child(h_flex().min_w_0().flex_1().flex_wrap().children( + display_url_segments(url).into_iter().map(|segment| { + Label::new(segment) + .size(LabelSize::Small) + .color(Color::Muted) + }), + )) + .into_any_element() + } + + fn render_actions(&self, cx: &App) -> AnyElement { + let open_url = match &self.elicitation.request.mode { + acp::ElicitationMode::Url(mode) => Some(mode.url.clone()), + _ => None, + }; + let is_accepted_url = + open_url.is_some() && matches!(self.elicitation.status, ElicitationStatus::Accepted); + let is_submitting = self.form_state.is_some_and(|state| state.is_submitting); + let (accept_label, accept_icon, accept_icon_color) = if is_accepted_url { + ("Open Again", IconName::ArrowUpRight, Color::Muted) + } else if open_url.is_some() { + ("Open", IconName::ArrowUpRight, Color::Muted) + } else { + ("Submit", IconName::Check, Color::Success) + }; + let border_color = cx.theme().colors().border.opacity(0.8); + let on_submit = self.handlers.on_submit.clone(); + let on_open_url = self.handlers.on_open_url.clone(); + let on_decline = self.handlers.on_decline.clone(); + let on_cancel = self.handlers.on_cancel.clone(); + let on_dismiss_url = self.handlers.on_dismiss_url.clone(); + let submit_id = self.elicitation.id.clone(); + let decline_id = self.elicitation.id.clone(); + let cancel_id = self.elicitation.id.clone(); + let dismiss_id = self.elicitation.id.clone(); + + h_flex() + .w_full() + .p_1() + .gap_1() + .justify_end() + .border_t_1() + .border_color(border_color) + .child( + Button::new(("elicitation-accept", self.entry_ix), accept_label) + .start_icon( + Icon::new(accept_icon) + .size(IconSize::XSmall) + .color(accept_icon_color), + ) + .label_size(LabelSize::Small) + .disabled(is_submitting) + .on_click(move |_, window, cx| { + if let Some(url) = &open_url { + on_open_url(submit_id.clone(), url.clone(), window, cx); + if !is_accepted_url { + on_submit(submit_id.clone(), window, cx); + } + } else { + on_submit(submit_id.clone(), window, cx); + } + }), + ) + .when(!is_accepted_url, |this| { + this.child( + Button::new(("elicitation-decline", self.entry_ix), "Decline") + .start_icon( + Icon::new(IconName::Close) + .size(IconSize::XSmall) + .color(Color::Error), + ) + .label_size(LabelSize::Small) + .on_click(move |_, window, cx| { + on_decline(decline_id.clone(), window, cx); + }), + ) + .child( + Button::new(("elicitation-cancel", self.entry_ix), "Cancel") + .label_size(LabelSize::Small) + .on_click(move |_, window, cx| { + on_cancel(cancel_id.clone(), window, cx); + }), + ) + }) + .when(is_accepted_url, |this| { + this.child( + Button::new(("elicitation-dismiss-url", self.entry_ix), "Cancel") + .label_size(LabelSize::Small) + .on_click(move |_, window, cx| { + on_dismiss_url(dismiss_id.clone(), window, cx); + }), + ) + }) + .into_any_element() + } +} diff --git a/crates/agent_ui/src/conversation_view/message_queue.rs b/crates/agent_ui/src/conversation_view/message_queue.rs new file mode 100644 index 00000000000000..31f1a000235fe3 --- /dev/null +++ b/crates/agent_ui/src/conversation_view/message_queue.rs @@ -0,0 +1,191 @@ +use std::collections::VecDeque; + +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct QueueEntryId(usize); + +pub struct QueueEntry { + pub id: QueueEntryId, + pub content: Vec, + pub tracked_buffers: Vec>, + /// When true, this message interrupts the agent at the next turn boundary + /// instead of waiting for generation to fully complete. Only the front + /// entry's value matters, since messages are delivered in FIFO order. + pub steer: bool, + pub editor: Entity, + pub _subscription: Subscription, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProcessingState { + AutoProcess, + Paused, + // Sending a message out of turn cancelled the current generation; we must + // absorb the Stopped event from that cancellation before resuming + // auto-processing, otherwise the queue would double-send. + AbsorbingCancel, +} + +/// Holds follow-up messages typed while the agent is generating, along with +/// the state machine that decides when they're auto-sent. +pub struct MessageQueue { + entries: VecDeque, + processing_state: ProcessingState, + can_fast_track: bool, + next_id: usize, +} + +impl Default for MessageQueue { + fn default() -> Self { + Self { + entries: VecDeque::new(), + processing_state: ProcessingState::AutoProcess, + can_fast_track: false, + next_id: 0, + } + } +} + +impl MessageQueue { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn first(&self) -> Option<&QueueEntry> { + self.entries.front() + } + + pub fn first_id(&self) -> Option { + self.entries.front().map(|entry| entry.id) + } + + pub fn last_id(&self) -> Option { + self.entries.back().map(|entry| entry.id) + } + + /// Whether the next message should interrupt the agent at the next turn + /// boundary. Drives the native thread's boundary flag. + pub fn front_wants_steer(&self) -> bool { + self.entries.front().is_some_and(|entry| entry.steer) + } + + pub fn toggle_steer(&mut self, id: QueueEntryId) { + if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) { + entry.steer = !entry.steer; + } + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + pub fn can_fast_track(&self) -> bool { + self.can_fast_track && !self.entries.is_empty() + } + + pub fn entry_by_id(&self, id: QueueEntryId) -> Option<&QueueEntry> { + self.entries.iter().find(|entry| entry.id == id) + } + + pub fn entry_by_id_mut(&mut self, id: QueueEntryId) -> Option<&mut QueueEntry> { + self.entries.iter_mut().find(|entry| entry.id == id) + } + + /// Allocates a stable ID for a new entry. This is separate from `enqueue` + /// because the editor event subscription must capture the ID before the + /// `QueueEntry` (which owns that subscription) can be constructed. + pub fn next_id(&mut self) -> QueueEntryId { + let id = QueueEntryId(self.next_id); + self.next_id += 1; + id + } + + /// Queuing a message is active engagement, so it also resumes + /// auto-processing if the queue was paused. + pub fn enqueue(&mut self, entry: QueueEntry) { + self.entries.push_back(entry); + self.processing_state = ProcessingState::AutoProcess; + self.can_fast_track = true; + } + + pub fn remove(&mut self, id: QueueEntryId) -> Option { + let index = self.entries.iter().position(|entry| entry.id == id)?; + self.entries.remove(index) + } + + pub fn clear(&mut self) { + self.entries.clear(); + self.can_fast_track = false; + } + + /// Pops the front entry if a fast-track send is allowed (the user just + /// queued a message and pressed Enter on an empty main editor). + /// + /// This works even when paused — pressing Enter is an explicit user + /// action, distinct from auto-processing. If a generation is in flight, + /// the dispatch will cancel it, so we must absorb that cancellation's + /// Stopped event to avoid double-sending the next entry. + pub fn try_fast_track(&mut self, is_generating: bool) -> Option { + if !self.can_fast_track { + return None; + } + self.can_fast_track = false; + let entry = self.entries.pop_front()?; + self.processing_state = if is_generating { + ProcessingState::AbsorbingCancel + } else { + ProcessingState::AutoProcess + }; + Some(entry) + } + + /// Handles a generation Stopped event, returning the entry to auto-send, + /// if any. + pub fn on_generation_stopped(&mut self, is_first_editor_focused: bool) -> Option { + match self.processing_state { + ProcessingState::AbsorbingCancel => { + // This Stopped event came from a cancellation we initiated + // ourselves (e.g. "Send Now"); swallow it and resume. + self.processing_state = ProcessingState::AutoProcess; + None + } + ProcessingState::Paused => None, + ProcessingState::AutoProcess => { + // Don't auto-send while the user is editing the next message. + if is_first_editor_focused { + None + } else { + self.entries.pop_front() + } + } + } + } + + /// Removes an entry for an explicit "Send Now". If a generation is in + /// flight, the dispatch will cancel it, so we must absorb that + /// cancellation's Stopped event. + pub fn send_now(&mut self, id: QueueEntryId, is_generating: bool) -> Option { + let entry = self.remove(id)?; + if is_generating { + self.processing_state = ProcessingState::AbsorbingCancel; + } + Some(entry) + } + + /// Called when the user stops generation; queued messages stay put until + /// the user re-engages. + pub fn pause(&mut self) { + self.processing_state = ProcessingState::Paused; + } + + /// Called when the user sends a new message, re-enabling auto-processing. + /// This is what un-freezes the queue after a manual stop. + pub fn resume(&mut self) { + self.processing_state = ProcessingState::AutoProcess; + } +} diff --git a/crates/agent_ui/src/conversation_view/thread_search_bar.rs b/crates/agent_ui/src/conversation_view/thread_search_bar.rs index b1c15cca1747de..643cceda36a003 100644 --- a/crates/agent_ui/src/conversation_view/thread_search_bar.rs +++ b/crates/agent_ui/src/conversation_view/thread_search_bar.rs @@ -12,8 +12,8 @@ use editor::{ scroll::Autoscroll, }; use gpui::{ - Action, App, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Hsla, KeyContext, - SharedString, Subscription, Task, TextStyle, WeakEntity, Window, actions, relative, rems, + Action, Entity, EntityId, EventEmitter, FocusHandle, Focusable, KeyContext, Subscription, Task, + TextStyle, WeakEntity, actions, prelude::*, }; use markdown::Markdown; use multi_buffer::{Anchor, MultiBufferOffset, MultiBufferSnapshot}; @@ -21,10 +21,7 @@ use project::search::SearchQuery; use search::{SearchOption, SearchOptions, SearchSource}; use settings::Settings as _; use theme_settings::ThemeSettings; -use ui::{ - ActiveTheme, ButtonStyle, Color, IconButton, IconButtonShape, IconName, IntoElement, Label, - LabelSize, Tooltip, div, h_flex, prelude::*, v_flex, -}; +use ui::{IconButtonShape, Tooltip, prelude::*}; use util::paths::PathMatcher; use crate::entry_view_state::EntryViewState; @@ -715,20 +712,15 @@ impl Render for ThreadSearchBar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let focus_handle = self.query_editor.focus_handle(cx); let theme = cx.theme().colors(); + let has_matches = !self.matches.is_empty(); let query_empty = self.query_editor.read(cx).text(cx).is_empty(); let in_error_state = self.query_error || (!query_empty && !has_matches); - let border_color = theme.border; let mut key_context = KeyContext::new_with_defaults(); key_context.add("AcpThreadSearchBar"); let counter_text = self.active_match_text(cx).unwrap_or_default(); - let counter_color = if has_matches { - Color::Default - } else { - Color::Muted - }; let bar_row = h_flex() .track_focus(&focus_handle) @@ -742,16 +734,17 @@ impl Render for ThreadSearchBar { .on_action(cx.listener(Self::focus_search)) .w_full() .gap_2() - .px_2() - .py_1() - .border_b_1() - .border_color(theme.border) - .bg(theme.toolbar_background) .child( - input_box(border_color) - .flex_1() + h_flex() + .min_h_8() .min_w_32() - .child(div().flex_1().min_w_0().py_1().child(render_query_input( + .flex_1() + .px_1p5() + .border_1() + .border_color(theme.border) + .bg(theme.editor_background) + .rounded_md() + .child(div().px_1().flex_1().child(render_query_input( &self.query_editor, in_error_state, cx, @@ -801,7 +794,7 @@ impl Render for ThreadSearchBar { div().ml_1().min_w(rems(2.5)).child( Label::new(counter_text) .size(LabelSize::Small) - .color(counter_color), + .when(!has_matches, |this| this.color(Color::Muted)), ), ) .child(nav_button( @@ -814,51 +807,44 @@ impl Render for ThreadSearchBar { )), ); - let error_row = self.query_error_message.clone().map(|msg| { - div() - .w_full() - .px_2() - .py_0p5() - .border_b_1() - .border_color(theme.border) - .bg(theme.toolbar_background) - .child(Label::new(msg).size(LabelSize::Small).color(Color::Error)) - }); + let error_row = self + .query_error_message + .clone() + .map(|msg| Label::new(msg).size(LabelSize::Small).color(Color::Error)); - v_flex().w_full().child(bar_row).children(error_row) + v_flex() + .w_full() + .p_1p5() + .bg(theme.panel_background) + .border_b_1() + .border_color(theme.border.opacity(0.6)) + .child(bar_row) + .children(error_row) } } -fn input_box(border_color: Hsla) -> gpui::Div { - h_flex() - .min_h_8() - .pl_2() - .pr_1() - .border_1() - .border_color(border_color) - .rounded_md() -} - fn render_query_input(editor: &Entity, has_error: bool, app: &App) -> impl IntoElement { let theme = app.theme().colors(); let (color, use_syntax) = if has_error { - (ui::Color::Error.color(app), false) + (Color::Error.color(app), false) } else { (theme.text, true) }; + let settings = ThemeSettings::get_global(app); + let text_style = TextStyle { color, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), font_size: rems(0.875).into(), - font_weight: settings.buffer_font.weight, + font_weight: settings.ui_font.weight, line_height: relative(1.3), ..TextStyle::default() }; let mut style = EditorStyle { - background: theme.toolbar_background, + background: theme.editor_background, local_player: app.theme().players().local(), text: text_style, ..EditorStyle::default() @@ -879,7 +865,6 @@ fn nav_button( ) -> IconButton { let action_for_dispatch = action; IconButton::new(id, icon) - .style(ButtonStyle::Subtle) .shape(IconButtonShape::Square) .disabled(disabled) .on_click({ @@ -906,12 +891,12 @@ fn collect_markdowns( AgentThreadEntry::AssistantMessage(message) => { for (chunk_ix, chunk) in message.chunks.iter().enumerate() { match chunk { - AssistantMessageChunk::Message { block } => { + AssistantMessageChunk::Message { block, .. } => { if let Some(md) = block.markdown() { out.push(md.clone()); } } - AssistantMessageChunk::Thought { block } + AssistantMessageChunk::Thought { block, .. } if entry_view_state .thinking_block_state((entry_ix, chunk_ix), cx) .0 => @@ -935,8 +920,13 @@ fn collect_markdowns( ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => { Some(markdown.clone()) } + ToolCallContent::ContentBlock(ContentBlock::EmbeddedResource { + markdown: Some(markdown), + .. + }) => Some(markdown.clone()), ToolCallContent::ContentBlock( ContentBlock::Empty + | ContentBlock::EmbeddedResource { markdown: None, .. } | ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. }, ) @@ -956,6 +946,7 @@ fn collect_markdowns( out.push(summary.clone()); } } + AgentThreadEntry::Elicitation(_) => {} AgentThreadEntry::ContextCompaction(_) => {} } out diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 471bb9c4fbc75c..d0441a99655878 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -5,21 +5,30 @@ use crate::{ open_abs_path_at_point, thread_metadata_store::{ThreadId, ThreadMetadataStore}, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use std::cell::RefCell; use acp_thread::{ - ContentBlock, PlanEntry, SandboxAuthorizationDetails, SandboxFallbackAuthorizationDetails, - SandboxNotAppliedReason, + Elicitation, ElicitationEntryId, ElicitationStatus, PlanEntry, SandboxAuthorizationDetails, + SandboxFallbackAuthorizationDetails, SandboxNotAppliedReason, decode_path_escapes, +}; +use agent::{ + SandboxStatusKey, SandboxStatusRefresh, SkillLoadingIssue, SkillLoadingIssueKind, + SkillLoadingIssuesUpdated, ThreadSandbox, VerifiedSandboxStatus, }; -use agent::{SkillLoadingIssue, SkillLoadingIssueKind, SkillLoadingIssuesUpdated}; use agent_settings::UserAgentsMd; use agent_skills::MAX_SKILL_DESCRIPTION_LEN; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; +use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; -use crate::completion_provider::AvailableSkill; +use crate::completion_provider::{AvailableSkill, PromptLocalCommand, pluralize}; use crate::message_editor::SharedSessionCapabilities; +use crate::ui::{ + SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip, TerminalSandboxWarning, + TerminalToolHeader, +}; +use crate::unicode_confusables; use db::kvp::KeyValueStore; use gpui::List; @@ -30,14 +39,18 @@ use language_model::{ FastModeConfirmation, LanguageModel, LanguageModelEffortLevel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, Speed, }; +use notifications::status_toast::StatusToast; use settings::{update_settings_file, update_settings_file_with_completion}; use ui::{ - ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, - Tab, + ButtonLike, CalloutBorderPosition, Checkbox, SpinnerLabel, SpinnerVariant, SplitButton, + SplitButtonStyle, Tab, ToggleState, }; -use workspace::notifications::NotificationId; +use util::markdown::{source_position_from_fragment, split_local_url_fragment}; use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME}; +use super::elicitation::{ + ElicitationCard, ElicitationCardHandlers, ElicitationFormState, should_render_elicitation, +}; use super::*; const DATA_RETENTION_LEARN_MORE_URL: &str = "https://support.claude.com/en/articles/15425996-data-retention-practices-for-mythos-class-models"; @@ -560,6 +573,7 @@ pub struct ThreadView { pub agent_icon: IconName, pub agent_icon_from_external_svg: Option, pub agent_id: AgentId, + pub agent_display_name: SharedString, pub focus_handle: FocusHandle, pub workspace: WeakEntity, pub entry_view_state: Entity, @@ -580,6 +594,10 @@ pub struct ThreadView { pub expanded_tool_call_raw_inputs: HashSet, collapsed_sandbox_authorization_details: HashSet, collapsed_sandbox_network_details: HashSet, + /// Sandbox escalation prompts whose "surprising Unicode" warning the user + /// has explicitly acknowledged. Until a prompt's tool call is in this set, + /// its allow buttons stay disabled. See [`Self::sandbox_confusable_findings`]. + acknowledged_confusable_warnings: HashSet, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -587,22 +605,18 @@ pub struct ThreadView { pub editor_expanded: bool, pub should_be_following: bool, pub editing_message: Option, - pub local_queued_messages: Vec, - pub queued_message_editors: Vec>, - pub queued_message_editor_subscriptions: Vec, - pub last_synced_queue_length: usize, + pub message_queue: MessageQueue, pub turn_fields: TurnFields, pub discarded_partial_edits: HashSet, pub is_loading_contents: bool, pub new_server_version_available: Option, pub resumed_without_history: bool, pub(crate) permission_selections: HashMap, + elicitation_form_states: HashMap, pub _cancel_task: Option>, _save_task: Option>, _draft_resolve_task: Option>, - pub skip_queue_processing_count: usize, - pub user_interrupted_generation: bool, - pub can_fast_track_queue: bool, + _sandbox_status_refresh_task: Option>, pub hovered_edited_file_buttons: Option, pub in_flight_prompt: Option>, pub _subscriptions: Vec, @@ -617,6 +631,9 @@ pub struct ThreadView { pub(crate) code_span_resolver: AgentCodeSpanResolver, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, + sandbox_status: Option, + sandbox_status_key: Option, + pending_sandbox_status_key: Option, pub multi_root_callout_dismissed: bool, pub generating_indicator_in_list: bool, pub skill_loading_issues: Vec, @@ -630,7 +647,13 @@ pub struct ThreadView { pub(crate) thread_search_visible: bool, } impl Focusable for ThreadView { - fn focus_handle(&self, cx: &App) -> FocusHandle { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ThreadView { + pub(crate) fn activation_focus_handle(&self, cx: &App) -> FocusHandle { if self.parent_session_id.is_some() { self.focus_handle.clone() } else { @@ -664,6 +687,19 @@ enum ToolCallLayout { Floating, } +impl ToolCallLayout { + /// Stable discriminant used to disambiguate element ids when the same tool + /// call is rendered in more than one layout at once (e.g. inline in the + /// list *and* in the floating awaiting-permission row). + fn id_str(self) -> &'static str { + match self { + ToolCallLayout::Standalone => "standalone", + ToolCallLayout::Embedded => "embedded", + ToolCallLayout::Floating => "floating", + } + } +} + fn full_path_for_empty_project_path(file: &dyn language::File, cx: &App) -> Option { if file.path().file_name().is_some() { return None; @@ -903,6 +939,20 @@ impl ThreadView { cx.notify(); }, )); + + // A "no model selected" error is stale as soon as the thread has a + // usable model + if let Some(native_thread) = native_connection.thread(thread.read(cx).session_id(), cx) + { + subscriptions.push(cx.subscribe( + &native_thread, + |this: &mut Self, _thread, _event: &agent::ModelChanged, cx| { + if matches!(this.thread_error, Some(ThreadError::NoModelSelected)) { + this.clear_thread_error(cx); + } + }, + )); + } } subscriptions.push(cx.observe(&message_editor, |this, editor, cx| { @@ -940,6 +990,7 @@ impl ThreadView { agent_icon, agent_icon_from_external_svg, agent_id, + agent_display_name, workspace, entry_view_state, title_editor, @@ -961,6 +1012,7 @@ impl ThreadView { expanded_tool_call_raw_inputs: HashSet::default(), collapsed_sandbox_authorization_details: HashSet::default(), collapsed_sandbox_network_details: HashSet::default(), + acknowledged_confusable_warnings: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -968,21 +1020,17 @@ impl ThreadView { editor_expanded: false, should_be_following: true, editing_message: None, - local_queued_messages: Vec::new(), - queued_message_editors: Vec::new(), - queued_message_editor_subscriptions: Vec::new(), - last_synced_queue_length: 0, + message_queue: MessageQueue::default(), turn_fields: TurnFields::default(), discarded_partial_edits: HashSet::default(), is_loading_contents: false, new_server_version_available: None, permission_selections: HashMap::default(), + elicitation_form_states: HashMap::default(), _cancel_task: None, _save_task: None, _draft_resolve_task: None, - skip_queue_processing_count: 0, - user_interrupted_generation: false, - can_fast_track_queue: false, + _sandbox_status_refresh_task: None, hovered_edited_file_buttons: None, in_flight_prompt: None, message_editor, @@ -993,6 +1041,9 @@ impl ThreadView { code_span_resolver, show_external_source_prompt_warning, show_codex_windows_warning, + sandbox_status: None, + sandbox_status_key: None, + pending_sandbox_status_key: None, multi_root_callout_dismissed: false, generating_indicator_in_list: false, skill_loading_issues: Vec::new(), @@ -1002,7 +1053,8 @@ impl ThreadView { }; this.sync_generating_indicator(cx); - this.sync_editor_mode_for_empty_state(cx); + this.sync_editor_mode(cx); + this.sync_existing_elicitation_states(window, cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); @@ -1079,17 +1131,58 @@ impl ThreadView { match event { MessageEditorEvent::Send => self.send(window, cx), MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx), - MessageEditorEvent::Cancel => self.cancel_generation(cx), + MessageEditorEvent::Cancel => { + if !self.close_thread_search(window, cx) { + self.cancel_generation(cx); + } + } MessageEditorEvent::Focus => { self.cancel_editing(&Default::default(), window, cx); } MessageEditorEvent::LostFocus => {} MessageEditorEvent::SlashAutocompleteOpened => {} + MessageEditorEvent::LocalCommandInvoked(command) => { + self.run_local_command(*command, window, cx); + } MessageEditorEvent::InputAttempted { .. } => {} MessageEditorEvent::Edited => {} } } + fn run_local_command( + &mut self, + command: PromptLocalCommand, + window: &mut Window, + cx: &mut Context, + ) { + match command { + PromptLocalCommand::ThumbsUp => { + self.handle_feedback_click(ThreadFeedback::Positive, window, cx); + self.show_local_command_toast("Thanks for your feedback!", cx); + } + PromptLocalCommand::ThumbsDown => { + self.handle_feedback_click(ThreadFeedback::Negative, window, cx); + } + } + } + + fn show_local_command_toast(&self, message: impl Into, cx: &mut Context) { + // Shown after positive feedback, replacing the inline button state. + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + workspace.update(cx, |workspace, cx| { + let toast = StatusToast::new(message, cx, |this, _cx| { + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + }); + workspace.toggle_status_toast(toast, cx); + }); + } + pub(crate) fn as_native_connection( &self, cx: &App, @@ -1167,14 +1260,7 @@ impl ThreadView { } pub fn has_queued_messages(&self) -> bool { - !self.local_queued_messages.is_empty() - } - - pub fn is_imported_thread(&self, cx: &App) -> bool { - let Some(thread) = self.as_native_thread(cx) else { - return false; - }; - thread.read(cx).is_imported() + !self.message_queue.is_empty() } // events @@ -1210,7 +1296,7 @@ impl ThreadView { if let Some(AgentThreadEntry::UserMessage(user_message)) = self.thread.read(cx).entries().get(event.entry_index) && self.thread.read(cx).supports_truncate(cx) - && user_message.id.is_some() + && user_message.client_id.is_some() && !self.is_subagent() { self.editing_message = Some(event.entry_index); @@ -1221,7 +1307,7 @@ impl ThreadView { if let Some(AgentThreadEntry::UserMessage(user_message)) = self.thread.read(cx).entries().get(event.entry_index) && self.thread.read(cx).supports_truncate(cx) - && user_message.id.is_some() + && user_message.client_id.is_some() && !self.is_subagent() { if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) { @@ -1241,6 +1327,7 @@ impl ThreadView { } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SlashAutocompleteOpened) => { } + ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::LocalCommandInvoked(_)) => {} ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Edited) => {} ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {} ViewEvent::OpenDiffLocation { @@ -1410,14 +1497,10 @@ impl ThreadView { let is_editor_empty = message_editor.read(cx).is_empty(cx); let is_generating = thread.read(cx).status() != ThreadStatus::Idle; - let has_queued = self.has_queued_messages(); - if is_editor_empty && self.can_fast_track_queue && has_queued { - self.can_fast_track_queue = false; - self.send_queued_message_at_index(0, true, window, cx); - return; - } - if is_editor_empty { + if let Some(entry) = self.message_queue.try_fast_track(is_generating) { + self.dispatch_queued_entry(entry, window, cx); + } return; } @@ -1446,13 +1529,11 @@ impl ThreadView { let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { - let agent_id = self.agent_id.clone(); let server_view = self.server_view.clone(); move |window, cx| { ConversationView::handle_auth_required( server_view.clone(), AuthRequired::new(), - agent_id, connection, window, cx, @@ -1526,7 +1607,7 @@ impl ThreadView { // Queue the remainder first, then start the command turn; the // queue auto-processes when the command turn stops. if !content.is_empty() { - this.add_to_queue(content, tracked_buffers, cx); + this.add_to_queue(content, tracked_buffers, window, cx); } this.send_content( Task::ready(Ok(Some((vec![command_block], Vec::new())))), @@ -1551,6 +1632,9 @@ impl ThreadView { self.thread_error.take(); self.thread_feedback.clear(); self.editing_message.take(); + // Sending a message is active engagement: un-freeze the queue if it + // was paused by a manual stop. + self.message_queue.resume(); if self.should_be_following { self.workspace @@ -1759,8 +1843,7 @@ impl ThreadView { cx: &mut Context, ) { let thread = self.thread.clone(); - self.skip_queue_processing_count = 0; - self.user_interrupted_generation = true; + self.message_queue.pause(); let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx)); @@ -1899,7 +1982,7 @@ impl ThreadView { pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_retry_status.take(); self.thread_error.take(); - self.user_interrupted_generation = true; + self.message_queue.pause(); self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx))); self.sync_generating_indicator(cx); cx.notify(); @@ -1941,8 +2024,13 @@ impl ThreadView { } let thread = self.thread.clone(); - let Some(user_message_id) = thread.update(cx, |thread, _| { - thread.entries().get(entry_ix)?.user_message()?.id.clone() + let Some(client_id) = thread.update(cx, |thread, _| { + thread + .entries() + .get(entry_ix)? + .user_message()? + .client_id + .clone() }) else { return; }; @@ -1978,12 +2066,12 @@ impl ThreadView { } thread - .update(cx, |thread, cx| thread.rewind(user_message_id, cx)) + .update(cx, |thread, cx| thread.rewind(client_id, cx)) .await?; this.update_in(cx, |thread, window, cx| { cx.emit(AcpThreadViewEvent::Interacted); thread.send_impl(message_editor, window, cx); - thread.focus_handle(cx).focus(window, cx); + thread.activation_focus_handle(cx).focus(window, cx); })?; anyhow::Ok(()) }) @@ -2015,8 +2103,7 @@ impl ThreadView { } this.update_in(cx, |this, window, cx| { - this.add_to_queue(content, tracked_buffers, cx); - this.can_fast_track_queue = true; + this.add_to_queue(content, tracked_buffers, window, cx); message_editor.update(cx, |message_editor, cx| { message_editor.clear(window, cx); }); @@ -2031,55 +2118,165 @@ impl ThreadView { &mut self, content: Vec, tracked_buffers: Vec>, + window: &mut Window, cx: &mut Context, ) { - self.local_queued_messages.push(QueuedMessage { + // The ID must be allocated up front so the editor event subscription + // can capture it before the entry (which owns the subscription) exists. + let id = self.message_queue.next_id(); + + let editor = cx.new(|cx| { + let mut editor = MessageEditor::new( + self.workspace.clone(), + self.project.clone(), + None, + self.session_capabilities.clone(), + self.agent_id.clone(), + "", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(10), + }, + window, + cx, + ); + editor.set_read_only(true, cx); + editor.set_message(content.clone(), window, cx); + editor + }); + + let subscription = + cx.subscribe_in(&editor, window, move |this, _editor, event, window, cx| { + this.handle_queue_editor_event(id, event, window, cx); + }); + + self.message_queue.enqueue(QueueEntry { + id, content, tracked_buffers, + steer: false, + editor, + _subscription: subscription, }); self.sync_queue_flag_to_native_thread(cx); + cx.notify(); + } + + fn handle_queue_editor_event( + &mut self, + id: QueueEntryId, + event: &MessageEditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + MessageEditorEvent::InputAttempted { + attempt, + cursor_offset, + } => { + self.move_queued_message_to_main_editor( + id, + Some(attempt.clone()), + Some(*cursor_offset), + window, + cx, + ); + } + MessageEditorEvent::LostFocus => { + self.save_queued_message(id, cx); + } + MessageEditorEvent::Cancel | MessageEditorEvent::Send => { + window.focus(&self.message_editor.focus_handle(cx), cx); + } + MessageEditorEvent::SendImmediately => { + self.send_queued_message_now(id, window, cx); + } + _ => {} + } + } + + fn save_queued_message(&mut self, id: QueueEntryId, cx: &mut Context) { + let Some(entry) = self.message_queue.entry_by_id(id) else { + return; + }; + let contents_task = entry + .editor + .update(cx, |editor, cx| editor.contents(false, cx)); + + cx.spawn(async move |this, cx| { + let (content, tracked_buffers) = contents_task.await?; + + this.update(cx, |this, cx| { + if let Some(entry) = this.message_queue.entry_by_id_mut(id) { + entry.content = content; + entry.tracked_buffers = tracked_buffers; + } + cx.notify(); + })?; + + Ok::<(), anyhow::Error>(()) + }) + .detach_and_log_err(cx); } pub fn remove_from_queue( &mut self, - index: usize, + id: QueueEntryId, cx: &mut Context, - ) -> Option { - if index < self.local_queued_messages.len() { - let removed = self.local_queued_messages.remove(index); + ) -> Option { + let removed = self.message_queue.remove(id); + if removed.is_some() { self.sync_queue_flag_to_native_thread(cx); - Some(removed) - } else { - None } + removed + } + + fn toggle_queue_entry_steer(&mut self, id: QueueEntryId, cx: &mut Context) { + self.message_queue.toggle_steer(id); + self.sync_queue_flag_to_native_thread(cx); + cx.notify(); } pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context) { if let Some(native_thread) = self.as_native_thread(cx) { - let has_queued = self.has_queued_messages(); + // By default queued messages wait for the turn to fully complete. + // Only a "steering" front message ends the turn at the next boundary. + let end_at_boundary = self.message_queue.front_wants_steer(); native_thread.update(cx, |thread, _| { - thread.set_has_queued_message(has_queued); + thread.set_end_turn_at_next_boundary(end_at_boundary); }); } } - pub fn send_queued_message_at_index( + pub fn send_queued_message_now( &mut self, - index: usize, - is_send_now: bool, + id: QueueEntryId, window: &mut Window, cx: &mut Context, ) { - let Some(queued) = self.remove_from_queue(index, cx) else { - return; - }; + let is_generating = self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating; + if let Some(entry) = self.message_queue.send_now(id, is_generating) { + self.dispatch_queued_entry(entry, window, cx); + } + } + + /// The shared "actually send this entry" path, used by fast-track, + /// auto-processing on Stopped, and "Send Now". The entry must already have + /// been removed from the queue. + pub fn dispatch_queued_entry( + &mut self, + entry: QueueEntry, + window: &mut Window, + cx: &mut Context, + ) { + self.sync_queue_flag_to_native_thread(cx); cx.emit(AcpThreadViewEvent::Interacted); self.message_editor.focus_handle(cx).focus(window, cx); - let content = queued.content; - let tracked_buffers = queued.tracked_buffers; + let content = entry.content; + let tracked_buffers = entry.tracked_buffers; // A queued message can itself be a built-in command (e.g. the user typed // `/compact` while a turn was generating). Detect that so we run it as a @@ -2096,16 +2293,6 @@ impl ThreadView { }) .is_some(); - // Only increment skip count for "Send Now" operations (out-of-order sends) - // Normal auto-processing from the Stopped handler doesn't need to skip. - // We only skip the Stopped event from the cancelled generation, NOT the - // Stopped event from the newly sent message (which should trigger queue processing). - if is_send_now { - let is_generating = - self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating; - self.skip_queue_processing_count += if is_generating { 1 } else { 0 }; - } - let cancelled = self.thread.update(cx, |thread, cx| thread.cancel(cx)); let workspace = self.workspace.clone(); @@ -2129,13 +2316,13 @@ impl ThreadView { pub fn move_queued_message_to_main_editor( &mut self, - index: usize, + id: QueueEntryId, attempt: Option, cursor_offset: Option, window: &mut Window, cx: &mut Context, ) -> bool { - let Some(queued_message) = self.remove_from_queue(index, cx) else { + let Some(queued_message) = self.remove_from_queue(id, cx) else { return false; }; let queued_content = queued_message.content; @@ -2182,12 +2369,15 @@ impl ThreadView { window: &mut Window, cx: &mut Context, ) { - if !self.message_editor.read(cx).is_empty(cx) || self.local_queued_messages.is_empty() { + if !self.message_editor.read(cx).is_empty(cx) { cx.propagate(); return; } - let last_index = self.local_queued_messages.len() - 1; - self.move_queued_message_to_main_editor(last_index, None, None, window, cx); + let Some(last_id) = self.message_queue.last_id() else { + cx.propagate(); + return; + }; + self.move_queued_message_to_main_editor(last_id, None, None, window, cx); } // editor methods @@ -2208,27 +2398,7 @@ impl ThreadView { pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { self.editor_expanded = is_expanded; - self.message_editor.update(cx, |editor, cx| { - if is_expanded { - editor.set_mode( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, - }, - cx, - ) - } else { - let agent_settings = AgentSettings::get_global(cx); - editor.set_mode( - EditorMode::AutoHeight { - min_lines: agent_settings.message_editor_min_lines, - max_lines: Some(agent_settings.set_message_editor_max_lines()), - }, - cx, - ) - } - }); + self.sync_editor_mode(cx); cx.notify(); } @@ -2341,13 +2511,40 @@ impl ThreadView { } pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context) { + if self.pending_allow_blocked_by_confusables(cx) { + return; + } self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); } pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { + if self.pending_allow_blocked_by_confusables(cx) { + return; + } self.authorize_pending_with_granularity(true, window, cx); } + /// Whether the currently pending permission prompt is blocked by an + /// unacknowledged surprising-Unicode warning, so the keyboard allow + /// shortcuts must be ignored (mirroring the disabled allow buttons). + fn pending_allow_blocked_by_confusables(&self, cx: &Context) -> bool { + let session_id = self.thread.read(cx).session_id().clone(); + let Some((_, tool_call_id, _)) = self + .conversation + .read(cx) + .pending_tool_call(&session_id, cx) + else { + return false; + }; + self.thread.read(cx).entries().iter().any(|entry| { + matches!( + entry, + AgentThreadEntry::ToolCall(call) + if call.id == tool_call_id && self.sandbox_confusables_block_allow(call, cx) + ) + }) + } + pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { self.authorize_pending_with_granularity(false, window, cx); } @@ -2373,17 +2570,209 @@ impl ThreadView { Some(()) } - fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool { - if let AgentThreadEntry::ToolCall(tool_call) = entry { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } + fn has_pending_request_elicitation(&self, cx: &App) -> bool { + self.server_view + .read_with(cx, |server_view, cx| { + server_view + .request_elicitation_store() + .is_some_and(|store| { + store.read(cx).elicitations().iter().any(|elicitation| { + matches!(elicitation.status, ElicitationStatus::Pending { .. }) + }) + }) + }) + .unwrap_or(false) + } + + pub fn sync_elicitation_state_for_entry( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let elicitation_id = { + let thread = self.thread.read(cx); + let Some(AgentThreadEntry::Elicitation(elicitation_id)) = thread.entries().get(index) + else { + return; + }; + elicitation_id.clone() + }; + + let thread = self.thread.read(cx); + let entry = thread.elicitation(&elicitation_id).map(|(_, elicitation)| { + ( + elicitation_id.clone(), + matches!(elicitation.status, ElicitationStatus::Pending { .. }), + match &elicitation.request.mode { + acp::ElicitationMode::Form(mode) => Some(mode.requested_schema.clone()), + _ => None, + }, ) - } else { - false + }); + + let Some((id, is_pending, schema)) = entry else { + return; + }; + + if is_pending + && let Some(schema) = schema + && !self.elicitation_form_states.contains_key(&id) + { + self.elicitation_form_states + .insert(id, ElicitationFormState::new(&schema, window, cx)); + } else if !is_pending { + self.elicitation_form_states.remove(&id); + } + } + + fn sync_existing_elicitation_states(&mut self, window: &mut Window, cx: &mut Context) { + let entry_count = self.thread.read(cx).entries().len(); + for index in 0..entry_count { + self.sync_elicitation_state_for_entry(index, window, cx); + } + } + + #[cfg(test)] + pub(crate) fn has_elicitation_form_state(&self, id: &ElicitationEntryId) -> bool { + self.elicitation_form_states.contains_key(id) + } + + fn submit_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + let mode = self + .thread + .read(cx) + .elicitation(&elicitation_id) + .map(|(_, elicitation)| elicitation.request.mode.clone()); + + let Some(mode) = mode else { + return; + }; + + match mode { + acp::ElicitationMode::Form(mode) => { + let Some(state) = self.elicitation_form_states.get_mut(&elicitation_id) else { + return; + }; + let Some(submission) = state.begin_submission(cx) else { + return; + }; + let schema = mode.requested_schema; + let validation_task = cx.background_spawn(async move { + let result = submission.validate(&schema); + (submission, result) + }); + cx.notify(); + cx.spawn(async move |this, cx| { + let (submission, result) = validation_task.await; + this.update(cx, |this, cx| { + let is_current = this + .elicitation_form_states + .get_mut(&elicitation_id) + .is_some_and(|state| { + state.validation_matches_current_values(&submission, cx) + }); + if !is_current { + cx.notify(); + return; + } + match result { + Ok(content) => { + this.respond_to_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new( + acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(content), + ), + ), + cx, + ); + } + Err(errors) => { + if let Some(state) = + this.elicitation_form_states.get_mut(&elicitation_id) + { + state.set_errors(errors); + } + cx.notify(); + } + } + }) + .log_err(); + }) + .detach(); + } + acp::ElicitationMode::Url(_) => { + self.respond_to_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + } + _ => {} } } + fn decline_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.respond_to_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + } + + fn cancel_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.respond_to_elicitation( + elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), + cx, + ); + } + + fn dismiss_url_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + _window: &mut Window, + cx: &mut Context, + ) { + self.elicitation_form_states.remove(&elicitation_id); + self.thread.update(cx, |thread, cx| { + thread.cancel_elicitation(&elicitation_id, cx); + }); + cx.notify(); + } + + fn respond_to_elicitation( + &mut self, + elicitation_id: ElicitationEntryId, + response: acp::CreateElicitationResponse, + cx: &mut Context, + ) { + let session_id = self.session_id.clone(); + self.elicitation_form_states.remove(&elicitation_id); + self.conversation.update(cx, |conversation, cx| { + conversation.respond_to_elicitation(session_id, elicitation_id, response, cx); + }); + cx.notify(); + } + fn handle_authorize_tool_call( &mut self, action: &AuthorizeToolCall, @@ -2583,128 +2972,10 @@ impl ThreadView { // thread stuff - fn share_thread(&mut self, _window: &mut Window, cx: &mut Context) { - let Some((thread, project)) = self.as_native_thread(cx).zip(self.project.upgrade()) else { - return; - }; - - let client = project.read(cx).client(); - let workspace = self.workspace.clone(); - let session_id = thread.read(cx).id().to_string(); - - let load_task = thread.read(cx).to_db(cx); - - cx.spawn(async move |_this, cx| { - let db_thread = load_task.await; - - let shared_thread = SharedThread::from_db_thread(&db_thread); - let thread_data = shared_thread.to_bytes()?; - let title = shared_thread.title.to_string(); - - client - .request(proto::ShareAgentThread { - session_id: session_id.clone(), - title, - thread_data, - }) - .await?; - - let share_url = client::zed_urls::shared_agent_thread_url(&session_id); - - cx.update(|cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - struct ThreadSharedToast; - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Thread shared!", - ) - .on_click( - "Copy URL", - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - share_url.clone(), - )); - }, - ), - cx, - ); - }); - } - }); - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn sync_thread( - &mut self, - project: Entity, - server_view: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if !self.is_imported_thread(cx) { - return; - } - - let Some(session_list) = self - .as_native_connection(cx) - .and_then(|connection| connection.session_list(cx)) - .and_then(|list| list.downcast::()) - else { - return; - }; - let thread_store = session_list.thread_store().clone(); - - let client = project.read(cx).client(); - let session_id = self.thread.read(cx).session_id().clone(); - cx.spawn_in(window, async move |this, cx| { - let response = client - .request(proto::GetSharedAgentThread { - session_id: session_id.to_string(), - }) - .await?; - - let shared_thread = SharedThread::from_bytes(&response.thread_data)?; - - let db_thread = shared_thread.to_db_thread(); - - thread_store - .update(&mut cx.clone(), |store, cx| { - store.save_thread(session_id.clone(), db_thread, Default::default(), cx) - }) - .await?; - - server_view.update_in(cx, |server_view, window, cx| server_view.reset(window, cx))?; - - this.update_in(cx, |this, _window, cx| { - if let Some(workspace) = this.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - struct ThreadSyncedToast; - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Thread synced with latest version", - ) - .autohide(), - cx, - ); - }); - } - })?; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - pub fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context) { + pub fn restore_checkpoint(&mut self, client_id: &ClientUserMessageId, cx: &mut Context) { self.thread .update(cx, |thread, cx| { - thread.restore_checkpoint(message_id.clone(), cx) + thread.restore_checkpoint(client_id.clone(), cx) }) .detach_and_log_err(cx); } @@ -3011,7 +3282,7 @@ impl ThreadView { .size(IconSize::Small) }); - let file_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + let file_stats = DiffStats::single_file(diff.read(cx)); let buttons = self.render_edited_files_buttons( index, @@ -3377,7 +3648,7 @@ impl ThreadView { _window: &mut Window, cx: &Context, ) -> impl IntoElement { - let queue_count = self.local_queued_messages.len(); + let queue_count = self.message_queue.len(); let title: SharedString = if queue_count == 1 { "1 Queued Message".into() } else { @@ -3412,16 +3683,15 @@ impl ThreadView { ) .on_click(cx.listener(|this, _, _, cx| { this.clear_queue(cx); - this.can_fast_track_queue = false; - cx.notify(); })), ) .into_any_element() } fn clear_queue(&mut self, cx: &mut Context) { - self.local_queued_messages.clear(); + self.message_queue.clear(); self.sync_queue_flag_to_native_thread(cx); + cx.notify(); } fn render_plan_summary( @@ -4077,7 +4347,7 @@ impl ThreadView { let fills_container = !has_messages || editor_expanded; h_flex() - .p_2() + .py_2() .bg(editor_bg_color) .justify_center() .on_action(cx.listener(Self::handle_message_editor_move_up)) @@ -4095,7 +4365,9 @@ impl ThreadView { v_flex() .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) .when(max_content_width.is_none(), |this| this.w_full()) + .min_w_0() .when(fills_container, |this| this.h_full()) + .px_2() .flex_shrink_1() .flex_grow_0() .justify_between() @@ -4145,11 +4417,14 @@ impl ThreadView { .child( h_flex() .w_full() + .min_w_0() .flex_none() .flex_wrap() .justify_between() .child( h_flex() + .min_w_0() + .flex_wrap() .gap_0p5() .child(self.render_add_context_button(cx)) .child(self.render_follow_toggle(cx)) @@ -4158,6 +4433,7 @@ impl ThreadView { ) .child( h_flex() + .min_w_0() .flex_wrap() .gap_1() .children(self.render_token_usage(cx)) @@ -4175,6 +4451,40 @@ impl ThreadView { .into_any() } + fn render_queue_steer_button( + &self, + entry_id: QueueEntryId, + index: usize, + is_next: bool, + steer_on: bool, + cx: &Context, + ) -> impl IntoElement { + let focus_handle = self.message_editor.focus_handle(cx); + + Button::new(("steer", index), "Steer") + .label_size(LabelSize::Small) + .toggle_state(steer_on) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .when(is_next, |this| { + this.key_binding( + KeyBinding::for_action_in(&ToggleSteerFirstQueuedMessage, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + }) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + "Steer", + None, + "Interrupt the agent at its next step to send this message. \ + When off, queued messages wait for the agent to finish.", + cx, + ) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.toggle_queue_entry_steer(entry_id, cx); + })) + } + fn render_message_queue_entries( &self, _window: &mut Window, @@ -4183,176 +4493,178 @@ impl ThreadView { let message_editor = self.message_editor.read(cx); let focus_handle = message_editor.focus_handle(cx); - let queued_message_editors = &self.queued_message_editors; - let queue_len = queued_message_editors.len(); - let can_fast_track = self.can_fast_track_queue && queue_len > 0; + let queue_len = self.message_queue.len(); + let can_fast_track = self.message_queue.can_fast_track(); + let is_native = self.as_native_thread(cx).is_some(); v_flex() .id("message_queue_list") .max_h_40() .overflow_y_scroll() - .children( - queued_message_editors - .iter() - .enumerate() - .map(|(index, editor)| { - let is_next = index == 0; - let (icon_color, tooltip_text) = if is_next { - (Color::Accent, "Next in Queue") - } else { - (Color::Muted, "In Queue") - }; + .children(self.message_queue.iter().enumerate().map(|(index, entry)| { + let entry_id = entry.id; + let editor = &entry.editor; + let is_next = index == 0; + let (icon_color, tooltip_text) = if is_next { + (Color::Accent, "Next in Queue") + } else { + (Color::Muted, "In Queue") + }; + + let editor_focused = editor.focus_handle(cx).is_focused(_window); + let keybinding_size = rems_from_px(12.); + let steer_on = entry.steer; - let editor_focused = editor.focus_handle(cx).is_focused(_window); - let keybinding_size = rems_from_px(12.); + let min_width = rems_from_px(160.); + h_flex() + .group("queue_entry") + .w_full() + .p_1p5() + .gap_1() + .bg(cx.theme().colors().editor_background) + .when(index < queue_len - 1, |this| { + this.border_b_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + div() + .id("next_in_queue") + .child( + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(icon_color), + ) + .tooltip(Tooltip::text(tooltip_text)), + ) + .child(editor.clone()) + .child(if editor_focused { h_flex() - .group("queue_entry") - .w_full() - .p_1p5() .gap_1() - .bg(cx.theme().colors().editor_background) - .when(index < queue_len - 1, |this| { - this.border_b_1() - .border_color(cx.theme().colors().border_variant) + .min_w(min_width) + .justify_end() + .child( + IconButton::new(("edit", index), IconName::Pencil) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::with_meta( + "Edit Queued Message", + None, + "Type anything to edit", + cx, + ) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.move_queued_message_to_main_editor( + entry_id, None, None, window, cx, + ); + })), + ) + .when(is_native, |row| { + row.child(self.render_queue_steer_button( + entry_id, index, is_next, steer_on, cx, + )) }) .child( - div() - .id("next_in_queue") - .child( - Icon::new(IconName::Circle) - .size(IconSize::Small) - .color(icon_color), + Button::new(("send_now_focused", index), "Send Now") + .label_size(LabelSize::Small) + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in( + &SendImmediately, + &editor.focus_handle(cx), + cx, + ) + .map(|kb| kb.size(keybinding_size)), ) - .tooltip(Tooltip::text(tooltip_text)), + .on_click(cx.listener(move |this, _, window, cx| { + this.send_queued_message_now(entry_id, window, cx); + })), ) - .child(editor.clone()) - .child(if editor_focused { - h_flex() - .gap_1() - .min_w(rems_from_px(150.)) - .justify_end() - .child( - IconButton::new(("edit", index), IconName::Pencil) - .icon_size(IconSize::Small) - .tooltip(|_window, cx| { - Tooltip::with_meta( - "Edit Queued Message", - None, - "Type anything to edit", + } else { + h_flex() + .when(!is_next, |this| this.visible_on_hover("queue_entry")) + .gap_1() + .min_w(min_width) + .justify_end() + .child( + IconButton::new(("delete", index), IconName::Trash) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + if is_next { + Tooltip::for_action_in( + "Remove Message from Queue", + &RemoveFirstQueuedMessage, + &focus_handle, cx, ) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.move_queued_message_to_main_editor( - index, None, None, window, cx, - ); - })), - ) - .child( - Button::new(("send_now_focused", index), "Send Now") - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in( - &SendImmediately, - &editor.focus_handle(cx), + } else { + Tooltip::simple("Remove Message from Queue", cx) + } + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_from_queue(entry_id, cx); + cx.notify(); + })), + ) + .child( + IconButton::new(("edit", index), IconName::Pencil) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + if is_next { + Tooltip::for_action_in( + "Edit", + &EditFirstQueuedMessage, + &focus_handle, cx, ) - .map(|kb| kb.size(keybinding_size)), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.send_queued_message_at_index( - index, true, window, cx, - ); - })), - ) - } else { - h_flex() - .when(!is_next, |this| this.visible_on_hover("queue_entry")) - .gap_1() - .min_w(rems_from_px(150.)) - .justify_end() - .child( - IconButton::new(("delete", index), IconName::Trash) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - if is_next { - Tooltip::for_action_in( - "Remove Message from Queue", - &RemoveFirstQueuedMessage, - &focus_handle, - cx, - ) - } else { - Tooltip::simple( - "Remove Message from Queue", - cx, - ) - } - } - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_from_queue(index, cx); - cx.notify(); - })), - ) - .child( - IconButton::new(("edit", index), IconName::Pencil) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - if is_next { - Tooltip::for_action_in( - "Edit", - &EditFirstQueuedMessage, - &focus_handle, - cx, - ) - } else { - Tooltip::simple("Edit", cx) - } - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.move_queued_message_to_main_editor( - index, None, None, window, cx, - ); - })), - ) - .child( - Button::new(("send_now", index), "Send Now") - .label_size(LabelSize::Small) - .when(is_next, |this| this.style(ButtonStyle::Outlined)) - .when(is_next && message_editor.is_empty(cx), |this| { - let action: Box = - if can_fast_track { - Box::new(Chat) - } else { - Box::new(SendNextQueuedMessage) - }; - - this.key_binding( - KeyBinding::for_action_in( - action.as_ref(), - &focus_handle.clone(), - cx, - ) - .map(|kb| kb.size(keybinding_size)), - ) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.send_queued_message_at_index( - index, true, window, cx, - ); - })), - ) + } else { + Tooltip::simple("Edit", cx) + } + } + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.move_queued_message_to_main_editor( + entry_id, None, None, window, cx, + ); + })), + ) + .when(is_native, |row| { + row.child(self.render_queue_steer_button( + entry_id, index, is_next, steer_on, cx, + )) }) - }), - ) + .child( + Button::new(("send_now", index), "Send Now") + .label_size(LabelSize::Small) + .when(is_next, |this| this.style(ButtonStyle::Outlined)) + .when(is_next && message_editor.is_empty(cx), |this| { + let action: Box = if can_fast_track { + Box::new(Chat) + } else { + Box::new(SendNextQueuedMessage) + }; + + this.key_binding( + KeyBinding::for_action_in( + action.as_ref(), + &focus_handle.clone(), + cx, + ) + .map(|kb| kb.size(keybinding_size)), + ) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.send_queued_message_now(entry_id, window, cx); + })), + ) + }) + })) .into_any_element() } @@ -4555,6 +4867,119 @@ impl ThreadView { .unwrap_or(false) } + fn refresh_sandbox_status(&mut self, cx: &mut Context) -> Option { + let thread = self.as_native_thread(cx)?; + let (key, refresh) = + thread.update(cx, |thread, cx| thread.refresh_verified_sandbox_status(cx))?; + + if self.sandbox_status_key.as_ref() == Some(&key) { + return self.sandbox_status.clone(); + } + + match refresh { + SandboxStatusRefresh::Ready(status) => { + self.sandbox_status = Some(status.clone()); + self.sandbox_status_key = Some(key); + self.pending_sandbox_status_key = None; + Some(status) + } + SandboxStatusRefresh::Pending(task) => { + if self.pending_sandbox_status_key.as_ref() != Some(&key) { + self.sandbox_status = None; + self.sandbox_status_key = None; + self.pending_sandbox_status_key = Some(key.clone()); + self._sandbox_status_refresh_task = Some(cx.spawn(async move |this, cx| { + let status = task.await; + this.update(cx, |this, cx| { + if this.pending_sandbox_status_key.as_ref() == Some(&key) { + this.sandbox_status = Some(status); + this.sandbox_status_key = Some(key); + this.pending_sandbox_status_key = None; + cx.notify(); + } + }) + .ok(); + })); + } + None + } + } + } + + pub fn render_sandbox_status(&mut self, cx: &mut Context) -> Option { + let status = self.refresh_sandbox_status(cx)?; + let settings_sandbox = status.settings_sandbox.clone(); + let thread_sandbox = status.thread_sandbox.clone(); + let baseline = status.baseline_writable_paths; + + // The lock is struck only when the *merged* result is unsandboxed (the + // agent runs with ambient permissions). A layer that is merely wide open + // but still sandboxed keeps the closed lock. + let (icon, icon_color) = if settings_sandbox + .clone() + .merge(thread_sandbox.clone()) + .is_unsandboxed() + { + (IconName::LockOff, Color::Muted) + } else { + (IconName::Lock, Color::Default) + }; + + let tooltip = match (settings_sandbox, thread_sandbox) { + // No sandbox at all because the user turned it off in settings: the + // per-thread layer is moot, so don't show it. + (ThreadSandbox::Unsandboxed, _) => SandboxStatusTooltip::disabled_in_settings(), + // Sandboxed by settings, but disabled for this thread: show the + // settings scope (greyed) for context above the disabled status. + (ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Unsandboxed) => { + let settings = augment_settings_sandbox_policy(&settings_policy, baseline); + SandboxStatusTooltip::disabled_for_thread(sandbox_section( + "Defined in your settings:", + &settings, + true, + )) + } + ( + ThreadSandbox::Sandboxed(settings_policy), + ThreadSandbox::Sandboxed(thread_policy), + ) => { + let settings = augment_settings_sandbox_policy(&settings_policy, baseline); + let thread = SandboxPolicyDisplay::from_policy(&thread_policy); + // Omit the per-thread section when it grants nothing extra. + let thread = (!sandbox_policy_grants_nothing(&thread)) + .then(|| sandbox_section("Allowed for this thread:", &thread, false)); + SandboxStatusTooltip::enabled( + sandbox_section("Defined in your settings:", &settings, true), + thread, + ) + } + }; + + Some( + h_flex() + .gap_1() + .child( + IconButton::new("sandbox-status", icon) + .icon_size(IconSize::Small) + .icon_color(icon_color) + .tooltip(Tooltip::element(move |_window, _cx| { + tooltip.clone().into_any_element() + })) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(), + target: None, + }), + cx, + ); + }), + ) + .child(Divider::vertical()) + .into_any_element(), + ) + } + fn render_fast_mode_control(&self, cx: &mut Context) -> Option { if !self.fast_mode_available(cx) { return None; @@ -4727,7 +5152,7 @@ impl ThreadView { ( "Disable Thinking Mode", IconName::ThinkingMode, - Color::Muted, + Color::Accent, ) } else { ( @@ -4873,7 +5298,7 @@ impl ThreadView { .child( Icon::new(IconName::ThinkingMode) .size(IconSize::Small) - .color(label_color), + .color(Color::Accent), ) .child(Label::new(label).size(LabelSize::Small).color(label_color)) .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), @@ -5427,8 +5852,12 @@ impl Render for TokenUsageTooltip { Button::new( "open-project-rules", format!( - "{} project rules", - project_rules_count + "{} {}", + project_rules_count, + pluralize( + "project rule", + project_rules_count + ) ), ) .end_icon( @@ -5467,6 +5896,186 @@ impl Render for TokenUsageTooltip { } } +/// A display-ready snapshot of a sandbox policy for the status tooltip. +/// +/// The opaque `HostFilesystemLocation`s in a policy are stringified up front, +/// when this is built, so the tooltip state (which outlives the build and is +/// captured by the lazy tooltip closure) never holds the locations' fds open. +#[derive(Clone)] +struct SandboxPolicyDisplay { + fs: SandboxFsDisplay, + network: SandboxNetPolicy, +} + +/// The filesystem write-access portion of a [`SandboxPolicyDisplay`]. +#[derive(Clone)] +enum SandboxFsDisplay { + Unrestricted, + Restricted(Vec), +} + +/// A single writable entry to display in the sandbox tooltip: either a real host +/// location (already stringified for display) or the Linux-only host-isolated +/// `/tmp` overlay, which has no backing host path and is purely a label. +#[derive(Clone)] +enum WritableEntryDisplay { + Path(String), + // Only ever constructed on Linux (the bwrap `--tmpfs /tmp` overlay), so the + // variant is gated to match and avoid dead-code warnings elsewhere. + #[cfg(target_os = "linux")] + IsolatedTmp, +} + +impl SandboxPolicyDisplay { + /// Display a policy verbatim (used for the per-thread overrides, which carry + /// no implicit baseline grants). Takes the policy by reference and stringifies + /// its locations immediately, so no fd is retained past this call. + fn from_policy(policy: &SandboxPolicy) -> Self { + let fs = match &policy.fs { + SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted, + SandboxFsPolicy::Restricted { writable_paths, .. } => SandboxFsDisplay::Restricted( + writable_paths + .iter() + .map(|location| { + WritableEntryDisplay::Path(location.untrusted_path_display().to_string()) + }) + .collect(), + ), + }; + SandboxPolicyDisplay { + fs, + network: policy.network.clone(), + } + } +} + +/// Fold the always-granted baseline writable paths (the project's worktree +/// roots, derived from the same source the terminal tool uses) and, on Linux, +/// the host-isolated `/tmp` overlay into a settings policy for display. These +/// are part of what the sandbox grants whenever it's active but aren't +/// persistent-settings entries, so they're shown in the "from your settings" +/// section rather than stored. A no-op when the fs is unrestricted (rendered as +/// "All paths"), since there's nothing to scope. +fn augment_settings_sandbox_policy( + policy: &SandboxPolicy, + baseline: Vec, +) -> SandboxPolicyDisplay { + let fs = match &policy.fs { + SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted, + SandboxFsPolicy::Restricted { writable_paths, .. } => { + // Dedup by display string. We deliberately don't open the locations' + // fds to dedup by inode here: this is a display-only tooltip and the + // string is the location's identity for that purpose. The string can + // only diverge from the captured inode while a symlink-swap is + // actively in progress, and in that case the bind validator refuses + // to run the command at all (see the `sandbox` crate) — so showing the + // requested path is always safe, and not worth a blocking syscall on + // the render path. + let mut merged: Vec = Vec::new(); + let baseline_paths = baseline.iter().map(|path| path.display().to_string()); + let granted_paths = writable_paths + .iter() + .map(|location| location.untrusted_path_display().to_string()); + for path in baseline_paths.chain(granted_paths) { + if !merged.contains(&path) { + merged.push(path); + } + } + // `mut` is only needed on Linux, where the isolated `/tmp` entry is + // pushed below. + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] + let mut entries: Vec = + merged.into_iter().map(WritableEntryDisplay::Path).collect(); + // The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the + // bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a + // real host path, so it can't be a captured location. + #[cfg(target_os = "linux")] + entries.push(WritableEntryDisplay::IsolatedTmp); + SandboxFsDisplay::Restricted(entries) + } + }; + SandboxPolicyDisplay { + fs, + network: policy.network.clone(), + } +} + +fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool) -> SandboxSection { + let write_empty = fs_grants_nothing(&policy.fs); + let network_empty = network_grants_nothing(&policy.network); + let mut section = SandboxSection::new(title.to_string()); + + if show_empty || !write_empty { + section = + section.group(SandboxGroup::new("Write Access").rows(sandbox_fs_rows(&policy.fs))); + } + + if show_empty || !network_empty { + section = section + .group(SandboxGroup::new("Network Access").rows(sandbox_network_rows(&policy.network))); + } + + section +} + +/// Whether a policy grants nothing worth surfacing, used to decide whether to +/// show the per-thread overrides section at all. +fn sandbox_policy_grants_nothing(policy: &SandboxPolicyDisplay) -> bool { + fs_grants_nothing(&policy.fs) && network_grants_nothing(&policy.network) +} + +fn fs_grants_nothing(fs: &SandboxFsDisplay) -> bool { + matches!(fs, SandboxFsDisplay::Restricted(entries) if entries.is_empty()) +} + +fn network_grants_nothing(network: &SandboxNetPolicy) -> bool { + match network { + SandboxNetPolicy::Blocked => true, + SandboxNetPolicy::Restricted { allowed_domains } => allowed_domains.is_empty(), + SandboxNetPolicy::Unrestricted => false, + } +} + +/// Rows for the write-access group: a message for the "all"/"none" cases, or one +/// row per granted path. +fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec { + match fs { + SandboxFsDisplay::Unrestricted => vec![SandboxRow::message( + "All paths except protected Git metadata", + )], + SandboxFsDisplay::Restricted(entries) if entries.is_empty() => { + vec![SandboxRow::message("None")] + } + SandboxFsDisplay::Restricted(entries) => entries + .iter() + .map(|entry| match entry { + // The display string was captured up front. + WritableEntryDisplay::Path(path) => SandboxRow::path(PathBuf::from(path)), + #[cfg(target_os = "linux")] + WritableEntryDisplay::IsolatedTmp => { + SandboxRow::path(PathBuf::from("/tmp (isolated)")) + } + }) + .collect(), + } +} + +/// Rows for the network-access group: a message for the "all"/"none" cases, or +/// one row per allowed domain. +fn sandbox_network_rows(network: &SandboxNetPolicy) -> Vec { + match network { + SandboxNetPolicy::Unrestricted => vec![SandboxRow::message("All domains (unrestricted)")], + SandboxNetPolicy::Blocked => vec![SandboxRow::message("None")], + SandboxNetPolicy::Restricted { allowed_domains } if allowed_domains.is_empty() => { + vec![SandboxRow::message("None")] + } + SandboxNetPolicy::Restricted { allowed_domains } => allowed_domains + .iter() + .map(|domain| SandboxRow::domain(domain.clone())) + .collect(), + } +} + impl ThreadView { fn render_entries(&mut self, cx: &mut Context) -> List { let max_content_width = AgentSettings::get_global(cx).max_content_width; @@ -5487,9 +6096,8 @@ impl ThreadView { let rendered = this.render_entry(index, entries.len(), entry, window, cx); centered_container(rendered.into_any_element()).into_any_element() } else if this.generating_indicator_in_list { - let confirmation = entries - .last() - .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); + let confirmation = this.thread.read(cx).is_waiting_for_confirmation() + || this.has_pending_request_elicitation(cx); let rendered = this.render_generating(confirmation, cx); centered_container(rendered.into_any_element()).into_any_element() } else { @@ -5518,6 +6126,8 @@ impl ThreadView { .get(entry_ix.saturating_sub(1)) .is_none_or(|entry| !entry.is_indented()); + let mut assistant_message_is_blank = false; + let primary = match &entry { AgentThreadEntry::UserMessage(message) => { let Some(editor) = self @@ -5544,7 +6154,7 @@ impl ThreadView { let is_subagent = self.is_subagent(); let can_rewind = self.thread.read(cx).supports_truncate(cx); - let is_editable = can_rewind && message.id.is_some() && !is_subagent; + let is_editable = can_rewind && message.client_id.is_some() && !is_subagent; let agent_name = if is_subagent { "subagents".into() } else { @@ -5565,7 +6175,7 @@ impl ThreadView { .gap_1p5() .w_full() .when(is_editable && has_checkpoint_button, |this| { - this.children(message.id.clone().map(|message_id| { + this.children(message.client_id.clone().map(|client_id| { h_flex() .px_3() .gap_2() @@ -5577,7 +6187,7 @@ impl ThreadView { .color(Color::Muted) .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation.")) .on_click(cx.listener(move |this, _, _window, cx| { - this.restore_checkpoint(&message_id, cx); + this.restore_checkpoint(&client_id, cx); })) ) .child(Divider::horizontal()) @@ -5715,7 +6325,7 @@ impl ThreadView { .gap_3() .children(chunks.iter().enumerate().filter_map( |(chunk_ix, chunk)| match chunk { - AssistantMessageChunk::Message { block } => { + AssistantMessageChunk::Message { block, .. } => { block.markdown().and_then(|md| { let this_is_blank = md.read(cx).source().trim().is_empty(); is_blank = is_blank && this_is_blank; @@ -5729,7 +6339,7 @@ impl ThreadView { ) }) } - AssistantMessageChunk::Thought { block } => { + AssistantMessageChunk::Thought { block, .. } => { block.markdown().and_then(|md| { let this_is_blank = md.read(cx).source().trim().is_empty(); is_blank = is_blank && this_is_blank; @@ -5752,6 +6362,8 @@ impl ThreadView { )) .into_any(); + assistant_message_is_blank = is_blank; + if is_blank { Empty.into_any() } else { @@ -5779,15 +6391,7 @@ impl ThreadView { if matches!(tool_call.status, ToolCallStatus::Canceled) { let has_visible_content = tool_call.content.iter().any(|content| match content { - ToolCallContent::ContentBlock(block) => match block { - ContentBlock::Empty => false, - ContentBlock::Markdown { markdown } => { - !markdown.read(cx).source().trim().is_empty() - } - ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => { - true - } - }, + ToolCallContent::ContentBlock(block) => block.visible_content(cx), ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => true, }); if !has_visible_content { @@ -5816,6 +6420,27 @@ impl ThreadView { tool_call.into_any() } } + AgentThreadEntry::Elicitation(elicitation_id) => { + let thread = self.thread.read(cx); + if let Some((_, elicitation)) = thread.elicitation(elicitation_id) + && should_render_elicitation(elicitation) + { + let elicitation = self.render_elicitation(entry_ix, elicitation, window, cx); + + if let Some(handle) = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.focus_handle(cx)) + { + elicitation.track_focus(&handle).into_any() + } else { + elicitation.into_any() + } + } else { + Empty.into_any() + } + } AgentThreadEntry::CompletedPlan(entries) => { self.render_completed_plan(entries, window, cx) } @@ -5889,16 +6514,58 @@ impl ThreadView { primary }; - let needs_confirmation = Self::is_waiting_for_confirmation(entry); + let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); + + let is_turn_end = Self::entry_is_finalized_turn_end(thread.read(cx).entries(), entry_ix) + .unwrap_or(!is_generating); + + let primary = if is_turn_end && !assistant_message_is_blank { + let user_message_index = thread + .read(cx) + .entries() + .iter() + .take(entry_ix) + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))); + + v_flex() + .w_full() + .child(primary) + .child(self.render_thread_controls( + &thread, + entry_ix, + Some(entry_ix), + entry_ix + 1 == total_entries, + user_message_index, + cx, + )) + .into_any_element() + } else { + primary + }; + + let is_assistant = matches!(entry, AgentThreadEntry::AssistantMessage(_)); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { + let last_assistant_index = thread + .read(cx) + .entries() + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_))); + v_flex() .w_full() .child(primary) - .when(!needs_confirmation, |this| { - this.child(self.render_thread_controls(&thread, cx)) + .when(!is_assistant, |this| { + this.child(self.render_thread_controls( + &thread, + entry_ix, + last_assistant_index, + true, + None, + cx, + )) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) @@ -5933,6 +6600,100 @@ impl ThreadView { } } + fn render_elicitation( + &self, + entry_ix: usize, + elicitation: &Elicitation, + _window: &Window, + cx: &Context, + ) -> Div { + ElicitationCard::new( + entry_ix, + elicitation, + self.agent_display_name.clone(), + self.elicitation_form_states.get(&elicitation.id), + self.elicitation_card_handlers(cx), + ) + .render(cx) + } + + fn elicitation_card_handlers(&self, cx: &Context) -> ElicitationCardHandlers { + let view = cx.entity().downgrade(); + + ElicitationCardHandlers::new( + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.submit_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.decline_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.cancel_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, window, cx| { + view.update(cx, |this, cx| { + this.dismiss_url_elicitation(elicitation_id, window, cx); + }) + .log_err(); + } + }, + move |_elicitation_id, url, _window, cx| cx.open_url(&url), + { + let view = view.clone(); + move |elicitation_id, field_name, value, cx| { + view.update(cx, |this, cx| { + if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) { + form.set_boolean(&field_name, value); + cx.notify(); + } + }) + .log_err(); + } + }, + { + let view = view.clone(); + move |elicitation_id, field_name, value, cx| { + view.update(cx, |this, cx| { + if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) { + form.set_single_select(&field_name, value); + cx.notify(); + } + }) + .log_err(); + } + }, + move |elicitation_id, field_name, value, selected, cx| { + view.update(cx, |this, cx| { + if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) { + form.set_multi_select(&field_name, value, selected); + cx.notify(); + } + }) + .log_err(); + }, + ) + } + fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { h_flex() .key_context("AgentFeedbackMessageEditor") @@ -5975,48 +6736,84 @@ impl ThreadView { ) } + /// A turn ends when no further assistant output (message or tool call) + /// follows before the next user message, and it's finalized once a user + /// message follows it. + fn entry_is_finalized_turn_end(entries: &[AgentThreadEntry], entry_ix: usize) -> Option { + if !matches!( + entries.get(entry_ix), + Some(AgentThreadEntry::AssistantMessage(_)) + ) { + return Some(false); + } + + for entry in &entries[entry_ix + 1..] { + match entry { + AgentThreadEntry::UserMessage(_) => return Some(true), + AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::ToolCall(_) => { + return Some(false); + } + _ => {} + } + } + + None + } + fn render_thread_controls( &self, thread: &Entity, + entry_ix: usize, + copy_response_index: Option, + is_thread_bottom: bool, + user_message_index: Option, cx: &Context, ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); - if is_generating { + let needs_confirmation = thread.read(cx).is_waiting_for_confirmation() + || self.has_pending_request_elicitation(cx); + + if is_thread_bottom && (is_generating || needs_confirmation) { return Empty.into_any_element(); } - let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Open Thread as Markdown")) - .on_click(cx.listener(move |this, _, window, cx| { - if let Some(workspace) = this.workspace.upgrade() { - this.open_thread_as_markdown(workspace, window, cx) - .detach_and_log_err(cx); - } - })); + let copy_response_button = copy_response_index.map(|response_index| { + IconButton::new(("copy_agent_response", entry_ix), IconName::Copy) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Copy This Agent Response")) + .on_click(cx.listener(move |this, _, _, cx| { + let entries = this.thread.read(cx).entries(); + if let Some(text) = Self::get_agent_message_content(entries, response_index, cx) + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + })) + }); + + let scroll_to_recent_user_prompt = IconButton::new( + ("scroll_to_recent_user_prompt", entry_ix), + IconName::UserArrowUp, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Scroll to User Message")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_user_message_index(user_message_index, cx); + })); - let scroll_to_recent_user_prompt = - IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) - .shape(ui::IconButtonShape::Square) + let scroll_to_top = is_thread_bottom.then(|| { + IconButton::new(("scroll_to_top", entry_ix), IconName::ArrowUp) .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Scroll to Top")) .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to_most_recent_user_prompt(cx); - })); + this.scroll_to_top(cx); + })) + }); - let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Top")) - .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to_top(cx); - })); + let show_stats = is_thread_bottom && AgentSettings::get_global(cx).show_turn_stats; - let show_stats = AgentSettings::get_global(cx).show_turn_stats; let last_turn_clock = show_stats .then(|| { self.turn_fields @@ -6044,29 +6841,104 @@ impl ThreadView { }) .flatten(); - let mut container = h_flex() + let feedback_buttons = is_thread_bottom + .then(|| { + (self.is_subagent() && self.is_thread_feedback_enabled(cx)).then(|| { + let feedback = self.thread_feedback.feedback; + let tooltip_meta = + "Rating the thread sends all of your current conversation to the Zed team."; + + h_flex() + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Muted, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Positive) => { + Tooltip::text("Thanks for your feedback!")(window, cx) + } + _ => Tooltip::with_meta( + "Helpful Response", + None, + tooltip_meta, + cx, + ), + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Muted, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Negative) => Tooltip::text( + "We appreciate your feedback and will use it to improve in the future.", + )( + window, cx + ), + _ => Tooltip::with_meta( + "Not Helpful Response", + None, + tooltip_meta, + cx, + ), + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ) + }) + }) + .flatten(); + + let separator_dots = || { + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5) + }; + + h_flex() .w_full() - .py_2() - .px_5() - .gap_px() - .opacity(0.6) - .hover(|s| s.opacity(1.)) + .py_1p5() + .px_4() .justify_end() + .opacity(0.4) + .hover(|s| s.opacity(1.)) .when( last_turn_tokens_label.is_some() || last_turn_clock.is_some(), |this| { this.child( h_flex() - .gap_1() .px_1() - .when_some(last_turn_tokens_label, |this, label| this.child(label)) - .when_some(last_turn_clock, |this, label| this.child(label)), + .gap_1() + .when_some(last_turn_tokens_label, |this, label| { + this.child(label).child(separator_dots()) + }) + .when_some(last_turn_clock, |this, label| { + this.child(label).child(separator_dots()) + }), ) }, - ); + ) + .when_some(feedback_buttons, |this, buttons| this.child(buttons)) + .when_some(copy_response_button, |this, button| this.child(button)) + .child(scroll_to_recent_user_prompt) + .when_some(scroll_to_top, |this, button| this.child(button)) + .into_any_element() + } - let enable_thread_feedback = util::maybe!({ - let project = thread.read(cx).project().read(cx); + fn is_thread_feedback_enabled(&self, cx: &App) -> bool { + util::maybe!({ + let project = self.thread.read(cx).project().read(cx); let user_store = project.user_store(); if let Some(configuration) = user_store.read(cx).current_organization_configuration() { if !configuration.is_agent_thread_feedback_enabled { @@ -6076,114 +6948,60 @@ impl ThreadView { AgentSettings::get_global(cx).enable_feedback && self.thread.read(cx).connection().telemetry().is_some() - }); - - if enable_thread_feedback { - let feedback = self.thread_feedback.feedback; + }) + } - let tooltip_meta = || { - SharedString::new( - "Rating the thread sends all of your current conversation to the Zed team.", - ) - }; + // The local slash commands the message editor should currently expose. + // Kept in sync with the availability of the corresponding actions via + // `sync_local_commands`. + fn available_local_commands(&self, cx: &App) -> Vec { + let mut commands = Vec::new(); - container = container - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Positive) => { - Tooltip::text("Thanks for your feedback!")(window, cx) - } - _ => { - Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Positive, window, cx); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Negative) => { - Tooltip::text( - "We appreciate your feedback and will use it to improve in the future.", - )(window, cx) - } - _ => { - Tooltip::with_meta( - "Not Helpful Response", - None, - tooltip_meta(), - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Negative, window, cx); - })), - ); + if self.is_thread_feedback_enabled(cx) { + commands.push(PromptLocalCommand::ThumbsUp); + commands.push(PromptLocalCommand::ThumbsDown); } - if let Some(project) = self.project.upgrade() - && let Some(server_view) = self.server_view.upgrade() - && cx.has_flag::() - && project.read(cx).client().status().borrow().is_connected() - { - let button = if self.is_imported_thread(cx) { - IconButton::new("sync-thread", IconName::ArrowCircle) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Sync with source thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.sync_thread(project.clone(), server_view.clone(), window, cx); - })) - } else { - IconButton::new("share-thread", IconName::ArrowUpRight) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Share Thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.share_thread(window, cx); - })) - }; + commands + } - container = container.child(button); - } + // Pushes the current set of available local commands to the message + // editor so they appear in its slash-command popup. + pub(crate) fn sync_local_commands(&self, cx: &App) { + let commands = self.available_local_commands(cx); + self.message_editor.read(cx).set_local_commands(commands); + } - container - .child(open_as_markdown) - .child(scroll_to_recent_user_prompt) - .child(scroll_to_top) - .into_any_element() + fn render_request_elicitations(&self, cx: &Context) -> Vec { + let server_view = self.server_view.clone(); + let handlers_view = server_view.clone(); + server_view + .read_with(cx, |server_view, cx| { + let Some(connection) = server_view.request_elicitation_connection() else { + return Vec::new(); + }; + server_view.render_request_elicitations(&connection, handlers_view, cx) + }) + .unwrap_or_default() } - pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { + pub(crate) fn scroll_to_user_message_index( + &mut self, + user_message_index: Option, + cx: &mut Context, + ) { let entries = self.thread.read(cx).entries(); if entries.is_empty() { return; } - // Find the most recent user message and scroll it to the top of the viewport. + // Scroll to the provided user message, or fall back to the most recent one. // (Fallback: if no user message exists, scroll to the bottom.) - if let Some(ix) = entries - .iter() - .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) - { + if let Some(ix) = user_message_index.or_else(|| { + entries + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) + }) { self.list_state.scroll_to(ListOffset { item_ix: ix, offset_in_item: px(0.0), @@ -6329,6 +7147,27 @@ impl ThreadView { } } + /// Hides the thread search bar, clears its highlights, and returns focus to + /// the message editor. Returns `true` if the search bar was visible. + pub(crate) fn close_thread_search( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.thread_search_visible { + return false; + } + + if let Some(bar) = self.thread_search_bar.clone() { + bar.update(cx, |bar, cx| bar.clear_highlights(cx)); + } + + self.thread_search_visible = false; + self.message_editor.focus_handle(cx).focus(window, cx); + cx.notify(); + true + } + pub(crate) fn toggle_search( &mut self, _: &crate::ToggleSearch, @@ -6415,11 +7254,21 @@ impl ThreadView { open_markdown_in_workspace(thread_title, markdown, workspace, window, cx) } - pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { + pub(crate) fn sync_editor_mode(&mut self, cx: &mut Context) { let has_messages = self.list_state.item_count() > 0; let v2_empty_state = !has_messages; - let mode = if v2_empty_state { + if !has_messages { + self.editor_expanded = false; + } + + let mode = if self.editor_expanded { + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, + } + } else if v2_empty_state { EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, @@ -6716,10 +7565,14 @@ impl ThreadView { .map(|chunks| { chunks.iter().any(|chunk| { let md = match chunk { - AssistantMessageChunk::Message { block } => block.markdown(), - AssistantMessageChunk::Thought { block } => block.markdown(), + AssistantMessageChunk::Message { block, .. } => { + block.markdown() + } + AssistantMessageChunk::Thought { block, .. } => { + block.markdown() + } }; - md.map_or(false, |m| m.read(cx).selected_text().is_some()) + md.map_or(false, |m| m.read(cx).has_selection()) }) }) .unwrap_or(false); @@ -6727,8 +7580,8 @@ impl ThreadView { let context_menu_link = chunks.and_then(|chunks| { chunks.iter().find_map(|chunk| { let md = match chunk { - AssistantMessageChunk::Message { block } => block.markdown(), - AssistantMessageChunk::Thought { block } => block.markdown(), + AssistantMessageChunk::Message { block, .. } => block.markdown(), + AssistantMessageChunk::Thought { block, .. } => block.markdown(), }; md.and_then(|m| m.read(cx).context_menu_link().cloned()) }) @@ -6834,7 +7687,7 @@ impl ThreadView { .chunks .iter() .filter_map(|chunk| match chunk { - AssistantMessageChunk::Message { block } => { + AssistantMessageChunk::Message { block, .. } => { let markdown = block.to_markdown(cx); if markdown.trim().is_empty() { None @@ -6882,6 +7735,7 @@ impl ThreadView { } } AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::Elicitation(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) | AgentThreadEntry::ContextCompaction(_) => {} @@ -6908,10 +7762,17 @@ impl ThreadView { .unwrap_or(&command_source) .to_string(); - let mut style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_buffer_font(cx); + let mut style = + MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_agent_buffer_font(cx); style.container_style.text.font_size = Some(rems_from_px(12.).into()); style.container_style.text.line_height = Some(rems_from_px(17.).into()); style.height_is_multiple_of_line_height = true; + // Soft-wrap the command instead of horizontally scrolling it: the card is + // narrow, and in scroll mode a long command wraps anyway but its wrapped + // lines don't pick up the code block's left padding. Wrap mode lays the + // text out as a normal block inside the padded content box, so every + // line (wrapped or not) is padded consistently. + style.code_block_overflow_x_scroll = false; let header_bg = self.tool_card_header_bg(cx); let run_command_label = if is_preview { @@ -6936,7 +7797,8 @@ impl ThreadView { wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }); - let copy_button = CopyButton::new("copy-command", command_text) + let copy_button_id = SharedString::from(format!("{group}-copy-command")); + let copy_button = CopyButton::new(copy_button_id, command_text) .tooltip_label("Copy Command") .visible_on_hover(group.clone()); @@ -6995,17 +7857,10 @@ impl ThreadView { started_at.elapsed() }; - let header_id = - SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); let header_group = SharedString::from(format!( "terminal-tool-header-group-{}", terminal.entity_id() )); - let header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); let border_color = cx.theme().colors().border.opacity(0.6); let working_dir = working_dir @@ -7026,148 +7881,70 @@ impl ThreadView { .read(cx) .is_tool_call_expanded(&tool_call.id); - let header = h_flex() - .id(header_id) - .pt_1() - .pl_1p5() - .pr_1() - .flex_none() - .gap_1() - .justify_between() - .rounded_t_md() - .child( - div() - .id(("command-target-path", terminal.entity_id())) - .w_full() - .max_w_full() - .overflow_x_scroll() - .child( - Label::new(working_dir) - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .child( - Disclosure::new( - SharedString::from(format!( - "terminal-tool-disclosure-{}", - terminal.entity_id() - )), - is_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&header_group) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this, _event, window, cx| { - this.entry_view_state.update(cx, |state, _cx| { - state.toggle_tool_call_expansion(&id); - }); - this.refresh_thread_search(window, cx); - cx.notify(); - } - })), - ) - .when(time_elapsed > Duration::from_secs(10), |header| { - header.child( - Label::new(format!("({})", duration_alt_display(time_elapsed))) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - }) - .when(!command_finished && !needs_confirmation, |header| { - header - .gap_1p5() - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Muted) - .with_rotate_animation(2) - ) - .child(div().h(relative(0.6)).ml_1p5().child(Divider::vertical().color(DividerColor::Border))) - .child( - IconButton::new( - SharedString::from(format!("stop-terminal-{}", terminal.entity_id())), - IconName::Stop - ) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Stop This Command", - None, - "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", - cx, - ) - }) - .on_click({ - let terminal = terminal.clone(); - cx.listener(move |this, _event, _window, cx| { - terminal.update(cx, |terminal, cx| { - terminal.stop_by_user(cx); - }); - if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { - this.cancel_generation(cx); - } - }) - }), + let truncated_tooltip = truncated_output.then(|| { + if let Some(output) = output { + if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { + format!( + "Output exceeded terminal max lines and was \ + truncated, the model received the first {}.", + format_file_size(output.content.len() as u64, true) ) - }) - .when(truncated_output, |header| { - let tooltip = if let Some(output) = output { - if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { - format!("Output exceeded terminal max lines and was \ - truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true)) - } else { - format!( - "Output is {} long, and to avoid unexpected token usage, \ - only {} was sent back to the agent.", - format_file_size(output.original_content_len as u64, true), - format_file_size(output.content.len() as u64, true) - ) - } } else { - "Output was truncated".to_string() - }; + format!( + "Output is {} long, and to avoid unexpected token usage, \ + only {} was sent back to the agent.", + format_file_size(output.original_content_len as u64, true), + format_file_size(output.content.len() as u64, true) + ) + } + } else { + "Output was truncated".to_string() + } + }); - header.child( - h_flex() - .id(("terminal-tool-truncated-label", terminal.entity_id())) - .gap_1() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Ignored), - ) - .child( - Label::new("Truncated") - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .tooltip(Tooltip::text(tooltip)), - ) - }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", terminal.entity_id())) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when_some(output.and_then(|o| o.exit_status), |this, status| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - status.code().unwrap_or(-1), - ))) - }), - ) + let header = TerminalToolHeader::new( + terminal.entity_id().to_string(), + header_group, + working_dir, + is_expanded, + ) + .elapsed(time_elapsed) + .running(!command_finished && !needs_confirmation) + .on_toggle_expand(cx.listener({ + let id = tool_call.id.clone(); + move |this, _event, window, cx| { + this.entry_view_state.update(cx, |state, _cx| { + state.toggle_tool_call_expansion(&id); + }); + this.refresh_thread_search(window, cx); + cx.notify(); + } + })) + .on_stop({ + let terminal = terminal.clone(); + cx.listener(move |this, _event, _window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.stop_by_user(cx); + }); + if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { + this.cancel_generation(cx); + } }) -; + }) + .when_some(truncated_tooltip, |header, tooltip| { + header.truncated(tooltip) + }) + .when(tool_failed || command_failed, |header| { + header.failed( + output + .and_then(|o| o.exit_status) + .map(|status| status.code().unwrap_or(-1)), + ) + }) + .when_some(tool_call.sandbox_not_applied.as_ref(), |header, reason| { + header.sandbox_warning(self.sandbox_not_applied_warning(reason, cx)) + }) + .command_slot(command_element); let terminal_view = self .entry_view_state @@ -7185,17 +7962,7 @@ impl ThreadView { .rounded_md() }) .overflow_hidden() - .child( - v_flex() - .group(&header_group) - .bg(header_bg) - .text_xs() - .child(header) - .child(command_element), - ) - .when_some(tool_call.sandbox_not_applied.as_ref(), |this, reason| { - this.child(self.render_sandbox_not_applied_warning(reason, terminal, cx)) - }) + .child(header) .when(is_expanded && terminal_view.is_some(), |this| { this.child( div() @@ -7230,6 +7997,7 @@ impl ThreadView { }) .when_some(confirmation_options, |this, options| { let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx); + let allow_disabled = self.sandbox_confusables_block_allow(tool_call, cx); this.child(self.render_permission_buttons( self.thread.read(cx).session_id().clone(), is_first, @@ -7237,38 +8005,32 @@ impl ThreadView { entry_ix, tool_call.id.clone(), focus_handle, + allow_disabled, cx, )) }) .into_any() } - /// Render the "ran without sandbox" warning shown on a terminal tool card, - /// tailored to *why* the sandbox wasn't applied. - fn render_sandbox_not_applied_warning( + fn sandbox_not_applied_warning( &self, reason: &SandboxNotAppliedReason, - terminal: &Entity, cx: &Context, - ) -> AnyElement { - // (title, optional detail line, whether to offer the settings shortcut) - let (title, detail, show_settings_button): (SharedString, Option, bool) = + ) -> TerminalSandboxWarning { + // (title, detail line, docs section slug) + let (title, detail, docs_section): (SharedString, SharedString, Option<&'static str>) = match reason { - SandboxNotAppliedReason::DisabledForever => ( - "Ran without sandbox".into(), - Some("Unsandboxed execution is enabled in settings.".into()), - true, - ), SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( "Couldn't create a sandbox".into(), - Some(error.user_facing_message().into()), - false, + error.user_facing_message().into(), + Some(error.docs_section()), ), SandboxNotAppliedReason::DisabledForThisThread => { // The grant only exists because an earlier command failed to // create a sandbox; surface that same explanation here. - let detail = self - .find_thread_sandbox_error(cx) + let thread_error = self.find_thread_sandbox_error(cx); + let detail = thread_error + .as_ref() .map(|error| { SharedString::from(format!( "Allowed for this thread after the sandbox failed: {}", @@ -7278,67 +8040,16 @@ impl ThreadView { .unwrap_or_else(|| { "Unsandboxed execution is allowed for the rest of this thread.".into() }); - ("Ran without sandbox".into(), Some(detail), false) + let docs_section = thread_error.as_ref().map(|error| error.docs_section()); + ("Ran without sandbox".into(), detail, docs_section) } }; - h_flex() - .px_2() - .py_1() - .gap_1() - .justify_between() - .border_t_1() - .border_color(cx.theme().status().warning_border) - .bg(cx.theme().status().warning_background.opacity(0.5)) - .child( - h_flex() - .min_w_0() - .flex_1() - .gap_1p5() - .items_start() - .child( - Icon::new(IconName::Warning) - .size(IconSize::XSmall) - .color(Color::Warning), - ) - .child( - v_flex() - .min_w_0() - .gap_0p5() - .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) - .when_some(detail, |this, detail| { - this.child( - Label::new(detail) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }), - ), - ) - .when(show_settings_button, |this| { - this.child( - IconButton::new( - SharedString::from(format!( - "open-sandbox-setting-{}", - terminal.entity_id() - )), - IconName::Settings, - ) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Open the sandbox permission settings")) - .on_click(|_event, window, cx| { - window.dispatch_action( - Box::new(zed_actions::OpenSettingsAt { - path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(), - target: None, - }), - cx, - ); - }), - ) - }) - .into_any_element() + TerminalSandboxWarning { + title, + detail, + docs_url: zed_urls::sandboxing_docs(docs_section, cx).into(), + } } /// Find the first terminal tool call in the thread whose sandbox couldn't be @@ -7380,10 +8091,22 @@ impl ThreadView { layout: ToolCallLayout, window: &Window, cx: &Context, - ) -> Div { + ) -> Stateful
{ let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().map(|this| { + // Give every tool-call subtree a unique element-id prefix derived from + // the globally-unique tool call id and the layout. This single wrapper + // is what keeps all the `entry_ix`-keyed element ids inside the card + // collision-free, even when the same tool call is rendered in multiple + // places at once (inline list + floating awaiting-permission row) or + // when subagent entries are inlined into the parent view's element tree. + let container_id = ElementId::Name(SharedString::from(format!( + "tool-call-{}-{}", + tool_call.id.0, + layout.id_str() + ))); + + div().w_full().id(container_id).map(|this| { if tool_call.is_subagent() { this.child( self.render_subagent_tool_call( @@ -7437,7 +8160,7 @@ impl ThreadView { cx: &Context, ) -> Div { let has_location = tool_call.locations.len() == 1; - let card_header_id = SharedString::from("inner-tool-call-header"); + let card_header_id = SharedString::from(format!("inner-tool-call-header-{entry_ix}")); let failed_or_canceled = match &tool_call.status { ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, @@ -7473,7 +8196,13 @@ impl ThreadView { let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); - let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; + + let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; + + let has_content = !tool_call.content.is_empty() + || (should_show_raw_input && tool_call.raw_input.is_some()); + + let is_collapsible = has_content && !needs_confirmation; let mut is_open = self .entry_view_state .read(cx) @@ -7481,8 +8210,6 @@ impl ThreadView { is_open |= needs_confirmation; - let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; - let input_output_header = |label: SharedString| { Label::new(label) .size(LabelSize::XSmall) @@ -7492,7 +8219,7 @@ impl ThreadView { let tool_output_display = if is_open { match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => { + ToolCallStatus::WaitingForConfirmation { .. } => { let confirmation_content = v_flex() .w_full() .children(tool_call.content.iter().enumerate().map( @@ -7520,6 +8247,7 @@ impl ThreadView { entry_ix, &tool_call.id, details, + window, cx, )) }, @@ -7601,36 +8329,7 @@ impl ThreadView { ) }); - v_flex() - .w_full() - .map(|this| { - if layout == ToolCallLayout::Floating { - // Cap the content (e.g. a full plan awaiting - // approval) so the floating row can never - // consume the entire panel and squeeze the - // conversation list to zero height, while the - // permission buttons below stay visible. - this.child( - div() - .id(("floating-confirmation-content", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .child(confirmation_content), - ) - } else { - this.child(confirmation_content) - } - }) - .child(self.render_permission_buttons( - self.thread.read(cx).session_id().clone(), - self.is_first_tool_call(active_session_id, &tool_call.id, cx), - options, - entry_ix, - tool_call.id.clone(), - focus_handle, - cx, - )) - .into_any() + confirmation_content.into_any() } ToolCallStatus::Pending | ToolCallStatus::InProgress if is_edit @@ -7672,8 +8371,13 @@ impl ThreadView { .iter() .enumerate() .map(|(content_ix, content)| { - div().id(("tool-call-output", entry_ix)).child( - self.render_tool_call_content( + let output_id = SharedString::from(format!( + "tool-call-output-{entry_ix}-{content_ix}" + )); + div() + .id(output_id.clone()) + .debug_selector(move || output_id.to_string()) + .child(self.render_tool_call_content( active_session_id, entry_ix, content, @@ -7684,8 +8388,7 @@ impl ThreadView { focus_handle, window, cx, - ), - ) + )) }), ) .when(!use_card_layout, |this| { @@ -7728,35 +8431,23 @@ impl ThreadView { None }; - v_flex() - .map(|this| { - if matches!( - layout, - ToolCallLayout::Embedded | ToolCallLayout::Floating - ) { - this - } else if use_card_layout { - this.my_1p5() - .rounded_md() - .border_1() - .when(failed_or_canceled, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() - } else { - this.my_1() - } - }) - .when(layout == ToolCallLayout::Standalone, |this| { - this.map(|this| { - if has_location && !use_card_layout { - this.ml_4() - } else { - this.ml_5() - } - }) - .mr_5() - }) + let permission_buttons = + if let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status { + Some(self.render_permission_buttons( + self.thread.read(cx).session_id().clone(), + self.is_first_tool_call(active_session_id, &tool_call.id, cx), + options, + entry_ix, + tool_call.id.clone(), + focus_handle, + self.sandbox_confusables_block_allow(tool_call, cx), + cx, + )) + } else { + None + }; + + let body = v_flex() .map(|this| { if is_terminal_tool { this.child(self.render_collapsible_command( @@ -7931,34 +8622,113 @@ impl ThreadView { ) } }) - .children(tool_output_display) - } - - fn render_sandbox_authorization_details( - &self, - entry_ix: usize, - tool_call_id: &acp::ToolCallId, - details: &SandboxAuthorizationDetails, - cx: &Context, - ) -> AnyElement { - let has_network = details.network_all_hosts || !details.network_hosts.is_empty(); - let command = details - .command - .as_deref() - .filter(|command| !command.is_empty()); - if details.write_paths.is_empty() - && !has_network - && command.is_none() - && details.reason.is_empty() - { - return Empty.into_any_element(); - } + .children(tool_output_display); - let network_section = has_network.then(|| { - let summary = if details.network_all_hosts { - "any host".to_string() - } else { - format!( + v_flex() + .map(|this| { + if matches!(layout, ToolCallLayout::Embedded | ToolCallLayout::Floating) { + this + } else if use_card_layout { + this.my_1p5() + .rounded_md() + .border_1() + .when(failed_or_canceled, |this| this.border_dashed()) + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + } else { + this.my_1() + } + }) + .when(layout == ToolCallLayout::Standalone, |this| { + this.map(|this| { + if has_location && !use_card_layout { + this.ml_4() + } else { + this.ml_5() + } + }) + .mr_5() + }) + .map(|this| { + if layout == ToolCallLayout::Floating { + this.child( + div() + .id(("floating-tool-call-body", entry_ix)) + .max_h_40() + .overflow_y_scroll() + .child(body), + ) + } else { + this.child(body) + } + }) + .children(permission_buttons) + } + + /// A small "Learn more" link to the sandboxing docs, deep-linked to + /// `section` when provided. Shared by the sandbox warning and the two + /// sandbox approval prompts so the user can always reach an explanation of + /// what they're being asked about. + fn render_sandbox_docs_link( + &self, + id: &'static str, + section: Option<&str>, + cx: &Context, + ) -> AnyElement { + let url = zed_urls::sandboxing_docs(section, cx); + + Button::new(id, "View Sandboxing Docs") + .label_size(LabelSize::Small) + .color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .tooltip({ + let url = url.clone(); + move |_, cx| Tooltip::with_meta("Open Docs", None, url.clone(), cx) + }) + .on_click(move |_, _, cx| cx.open_url(&url)) + .into_any_element() + } + + fn render_sandbox_authorization_details( + &self, + entry_ix: usize, + tool_call_id: &acp::ToolCallId, + details: &SandboxAuthorizationDetails, + window: &Window, + cx: &Context, + ) -> AnyElement { + let has_network = details.network_all_hosts || !details.network_hosts.is_empty(); + let has_write = details.allow_fs_write_all || !details.write_paths.is_empty(); + // The dedicated Windows-drive warning prompt is only ever sent while the + // warning is enabled, so key the banner on the prompt itself. Keeping it + // visible even after the "Don't show again" checkbox flips the setting + // avoids the card disappearing out from under the user mid-decision. + let has_windows_fs_warning = details.warn_windows_fs; + if !has_network + && !has_write + && !details.unsandboxed + && details.reason.is_empty() + && !has_windows_fs_warning + { + return Empty.into_any_element(); + } + + let confusable_findings = if Self::confusable_warning_enabled(cx) { + Self::sandbox_confusable_findings(details) + } else { + Vec::new() + }; + + let network_section = has_network.then(|| { + let summary = if details.network_all_hosts { + "any host".to_string() + } else { + format!( "{} {}", details.network_hosts.len(), if details.network_hosts.len() == 1 { @@ -7979,7 +8749,10 @@ impl ThreadView { .child( h_flex() .id(("sandbox-network-details-header", entry_ix)) - .p_1() + // Align text with the allow/deny button icons below, + // which sit at p_1 (container) + Base04 (button) ≈ px_2. + .px_2() + .py_1() .justify_between() .when(has_host_list, |this| { this.cursor_pointer() @@ -8029,125 +8802,81 @@ impl ThreadView { }), ) .when(has_host_list && is_open, |this| { - this.child( - v_flex() - .id(("sandbox-network-hosts-list", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .children(hosts.iter().enumerate().map(|(host_ix, host)| { - h_flex() - .min_w_0() - .p_1p5() - .gap_2() - .bg(cx.theme().colors().editor_background) - .when(host_ix < hosts.len() - 1, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - Icon::new(IconName::Public) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child( - Label::new(host.clone()) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) - })), - ) + this.child(v_flex().children(hosts.iter().enumerate().map( + |(host_ix, host)| { + h_flex() + .min_w_0() + .px_2() + .py_1p5() + .bg(cx.theme().colors().editor_background) + .when(host_ix < hosts.len() - 1, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + Label::new(host.clone()) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + }, + ))) }) }); - if details.write_paths.is_empty() { - return v_flex() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .when_some(command, |this, command| { - this.child(Self::render_sandbox_authorization_command( - entry_ix, command, cx, - )) - }) - .children(network_section) - .into_any_element(); - } - - let is_open = !self - .collapsed_sandbox_authorization_details - .contains(tool_call_id); - let mut paths = details.write_paths.clone(); - paths.sort(); - - v_flex() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .when_some(command, |this, command| { - this.child(Self::render_sandbox_authorization_command( - entry_ix, command, cx, - )) - }) - .children(network_section) - .child( - h_flex() - .id(("sandbox-authorization-details-header", entry_ix)) - .p_1() - .justify_between() - .cursor_pointer() - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .child( - h_flex().gap_1().child( - Label::new("Write access") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - Disclosure::new(("sandbox-authorization-details", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - move |this, _event, _window, cx| { - if this - .collapsed_sandbox_authorization_details - .remove(&tool_call_id) - { - cx.notify(); - return; - } - - this.collapsed_sandbox_authorization_details - .insert(tool_call_id.clone()); - cx.notify(); - } - })), - ) - .when(is_open && !paths.is_empty(), |this| { - this.child( - v_flex() - .gap_0p5() - .child( - Label::new("Reason from agent") - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), - ) - .child(Label::new(details.reason.clone()).size(LabelSize::Small)), + let write_section = has_write.then(|| { + let summary = if details.allow_fs_write_all { + "unrestricted except Git metadata".to_string() + } else { + format!( + "{} {}", + details.write_paths.len(), + if details.write_paths.len() == 1 { + "path" + } else { + "paths" + } ) - }) - .when(!paths.is_empty(), |this| { - this.child( + }; + let has_path_list = !details.allow_fs_write_all && !details.write_paths.is_empty(); + let is_open = !self + .collapsed_sandbox_authorization_details + .contains(tool_call_id); + let mut paths = details.write_paths.clone(); + // Sort by the path that is actually granted (the resolved canonical + // when present, else the requested path). + paths.sort_by(|a, b| a.canonical_or_requested().cmp(b.canonical_or_requested())); + + v_flex() + .child( h_flex() .id(("sandbox-authorization-details-header", entry_ix)) - .p_1() + .px_2() + .py_1() .justify_between() - .cursor_pointer() - .hover(|style| style.bg(cx.theme().colors().element_hover)) + .when(has_path_list, |this| { + this.cursor_pointer() + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + move |this, _event, _window, cx| { + if this + .collapsed_sandbox_authorization_details + .remove(&tool_call_id) + { + cx.notify(); + return; + } + + this.collapsed_sandbox_authorization_details + .insert(tool_call_id.clone()); + cx.notify(); + } + })) + }) .child( h_flex() .gap_1() .child( - Label::new("Write access") + Label::new("Write Access") .size(LabelSize::Small) .color(Color::Muted), ) @@ -8157,55 +8886,363 @@ impl ThreadView { .color(Color::Disabled), ) .child( - Label::new(format!( - "{} {}", - paths.len(), - if paths.len() == 1 { "path" } else { "paths" } - )) - .size(LabelSize::Small) - .color(Color::Muted), + Label::new(summary) + .size(LabelSize::Small) + .color(Color::Muted), ), ) - .child( - Disclosure::new(("sandbox-authorization-details", entry_ix), is_open) + .when(has_path_list, |this| { + this.child( + Disclosure::new( + ("sandbox-authorization-details", entry_ix), + is_open, + ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - move |this, _event, _window, cx| { - if this - .collapsed_sandbox_authorization_details - .remove(&tool_call_id) - { - cx.notify(); - return; - } + ) + }), + ) + .when(has_path_list && is_open, |this| { + this.child(v_flex().children(paths.iter().enumerate().map( + |(path_ix, path)| { + self.render_sandbox_authorization_path_row(entry_ix, path_ix, path, cx) + }, + ))) + }) + }); - this.collapsed_sandbox_authorization_details - .insert(tool_call_id.clone()); - cx.notify(); - } - })), + let unsandboxed_section = details.unsandboxed.then(|| { + h_flex() + .px_2() + .py_1() + .gap_1p5() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), ) - .when(is_open, |this| { - this.child( - v_flex() - .id(("sandbox-authorization-paths-list", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .children(paths.iter().enumerate().map(|(path_ix, path)| { - self.render_sandbox_authorization_path_row( - entry_ix, - path_ix, - path, - path_ix < paths.len() - 1, + .child( + Label::new("Runs without the OS sandbox") + .size(LabelSize::Small) + .color(Color::Muted), + ) + }); + + let reason_section = (!details.reason.is_empty()).then(|| { + v_flex() + .px_2() + .py_1() + .gap_0p5() + .child( + Label::new("Reason") + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child(Label::new(details.reason.clone()).size(LabelSize::Small)) + }); + + // The command stays in the tool-call title above; here we show what the + // command is asking for (paths / domains) and the agent's reason. + v_flex() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .when(has_windows_fs_warning, |this| { + this.child(self.render_sandbox_windows_fs_warning(cx)) + }) + .when(!confusable_findings.is_empty(), |this| { + this.child(self.render_sandbox_confusable_warning( + tool_call_id, + &confusable_findings, + window, + cx, + )) + }) + .children(network_section) + .children(write_section) + .children(unsandboxed_section) + .children(reason_section) + .when(!has_windows_fs_warning, |this| { + // The Windows-drive warning banner carries its own docs link, so + // skip the default one that every other sandbox prompt appends. + this.child( + h_flex() + .px_1() + .py_0p5() + .child(self.render_sandbox_docs_link( + "sandbox-authorization-docs-link", + None, + cx, + )), + ) + }) + .into_any_element() + } + + /// Scan the hosts and paths in a sandbox escalation request for surprising + /// Unicode characters (homoglyphs, invisible characters, bidi overrides). + /// Returns, for each offending value, the display string shown to the user + /// and the distinct suspicious characters it contains. Hosts are decoded from + /// Punycode first, so the display string is the Unicode form the user should + /// scrutinize. Empty when nothing is surprising. + fn sandbox_confusable_findings( + details: &SandboxAuthorizationDetails, + ) -> Vec<(String, Vec)> { + let mut findings = Vec::new(); + for host in &details.network_hosts { + let (decoded, suspicious) = unicode_confusables::scan_host(host); + if !suspicious.is_empty() { + findings.push((decoded, suspicious)); + } + } + for granted in &details.write_paths { + // Scan both the requested path and the resolved target (when they + // differ), so a confusable in either the shown request or the real + // grant destination is surfaced. + let requested = granted.requested.display().to_string(); + let resolved = granted.canonical_or_requested().display().to_string(); + for display in [requested, resolved] + .into_iter() + .collect::>() + { + let suspicious = unicode_confusables::scan(&display); + if !suspicious.is_empty() { + findings.push((display, suspicious)); + } + } + } + findings + } + + /// Whether the surprising-Unicode warning is enabled in settings (on by + /// default). When off, prompts neither show the banner nor gate their allow + /// buttons on it. + fn confusable_warning_enabled(cx: &App) -> bool { + AgentSettings::get_global(cx) + .sandbox_permissions + .warn_confusable_unicode + } + + /// Whether this tool call's sandbox escalation shows surprising Unicode that + /// the user hasn't acknowledged yet. While true, the prompt's allow buttons + /// stay disabled so the user can't grant access to a lookalike target + /// without first ticking the acknowledgement checkbox. + fn sandbox_confusables_block_allow(&self, tool_call: &ToolCall, cx: &App) -> bool { + if !Self::confusable_warning_enabled(cx) { + return false; + } + let Some(details) = tool_call.sandbox_authorization_details.as_ref() else { + return false; + }; + if self + .acknowledged_confusable_warnings + .contains(&tool_call.id) + { + return false; + } + !Self::sandbox_confusable_findings(details).is_empty() + } + + /// Red banner warning that a requested domain or path contains surprising + /// Unicode characters, with a checkbox the user must tick to unlock the + /// allow buttons. See [`Self::sandbox_confusables_block_allow`]. + fn render_sandbox_confusable_warning( + &self, + tool_call_id: &acp::ToolCallId, + findings: &[(String, Vec)], + window: &Window, + cx: &Context, + ) -> AnyElement { + let acknowledged = self.acknowledged_confusable_warnings.contains(tool_call_id); + let line_height = window.line_height(); + + v_flex() + .w_full() + .p_2() + .gap_2() + .border_t_1() + .border_color(cx.theme().status().error_border) + .bg(cx.theme().status().error_background.opacity(0.15)) + .child( + h_flex() + .w_full() + .gap_1p5() + .items_start() + .child( + h_flex() + .h(line_height) + .flex_none() + .justify_center() + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Error), + ), + ) + .child( + v_flex().min_w_0().flex_1().gap_1().children(findings.iter().map( + |(value, suspicious)| { + v_flex() + .min_w_0() + .gap_0p5() + .child( + Label::new(format!( + "“{value}” contains potentially surprising Unicode characters" + )) + .size(LabelSize::Small) + .color(Color::Error), + ) + .child(v_flex().min_w_0().pl_2().children( + suspicious.iter().map(|character| { + Label::new(format!("• {}", character.description())) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + }), + )) + }, + )), + ) + .child( + IconButton::new("configure-confusable-warning", IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Configure unicode confusables warning")) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(), + target: None, + }), cx, + ); + }), + ), + ) + .child( + Checkbox::new( + SharedString::from(format!("confusable-ack-{}", tool_call_id.0)), + if acknowledged { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("I understand and wish to proceed") + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + move |this, state: &ToggleState, _window, cx| { + if *state == ToggleState::Selected { + this.acknowledged_confusable_warnings + .insert(tool_call_id.clone()); + } else { + this.acknowledged_confusable_warnings.remove(&tool_call_id); + } + cx.notify(); + } + })), + ) + .into_any_element() + } + + /// Whether the Windows-drive (DrvFs) weaker-guarantee warning is enabled in + /// settings (on by default). Windows-only in effect: `warn_windows_fs` is + /// never set on other platforms. + fn ntfs_warning_enabled(cx: &App) -> bool { + AgentSettings::get_global(cx) + .sandbox_permissions + .warn_ntfs_grants + } + + /// Informational banner shown on a sandbox approval prompt when the command + /// will write to a file on a Windows drive (reached inside WSL via DrvFs), + /// whose sandbox-integrity guarantees are weaker than the distro's native + /// filesystem. Unlike the confusable-Unicode banner this does not gate the + /// allow buttons: the approval itself is the acknowledgement. A settings gear + /// links to where the warning can be suppressed. + fn render_sandbox_windows_fs_warning(&self, cx: &Context) -> AnyElement { + v_flex() + .w_full() + .p_2() + .gap_1() + .border_t_1() + .border_color(cx.theme().status().warning_border) + .bg(cx.theme().status().warning_background.opacity(0.15)) + .child( + h_flex() + .w_full() + .gap_1p5() + .items_start() + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child( + v_flex() + .min_w_0() + .flex_1() + .gap_0p5() + .child( + Label::new("This command can write to a file on a Windows drive") + .size(LabelSize::Small) + .color(Color::Warning), + ) + .child( + Label::new( + "Sandboxes with write access to a location on a Windows \ + drive may not provide full filesystem isolation.", ) - })), + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child(h_flex().child(self.render_sandbox_docs_link( + "sandbox-windows-fs-docs-link", + Some("windows"), + cx, + ))), ) - }) - }) + .child( + IconButton::new("configure-ntfs-warning", IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Configure Windows-drive warning")) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(), + target: None, + }), + cx, + ); + }), + ), + ) + .child( + Checkbox::new( + "sandbox-windows-fs-dont-warn", + if Self::ntfs_warning_enabled(cx) { + ToggleState::Unselected + } else { + ToggleState::Selected + }, + ) + .label("Don't show this warning again") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, state: &ToggleState, _window, cx| { + let disable = *state == ToggleState::Selected; + let fs = this.thread.read(cx).project().read(cx).fs().clone(); + update_settings_file(fs, cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .sandbox_permissions + .get_or_insert_default() + .warn_ntfs_grants = Some(!disable); + }); + cx.notify(); + })), + ) .into_any_element() } @@ -8241,116 +9278,77 @@ impl ThreadView { .size(LabelSize::Small) .color(Color::Muted), ) - .child(Label::new(details.reason.clone()).size(LabelSize::Small)), + .child(Label::new(details.reason.clone()).size(LabelSize::Small)) + .child(self.render_sandbox_docs_link( + "sandbox-fallback-docs-link", + details.docs_section.as_deref(), + cx, + )), ) .into_any_element() } - fn render_sandbox_authorization_command(entry_ix: usize, command: &str, cx: &App) -> Div { - let group = SharedString::from(format!("sandbox-authorization-command-{entry_ix}")); - let command = SharedString::from(command.to_string()); - - v_flex() - .group(group.clone()) - .relative() - .p_1p5() - .gap_1() - .bg(cx.theme().colors().editor_background) - .child( - Label::new("Command") - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - div() - .id(("sandbox-authorization-command-scroll", entry_ix)) - .flex() - .flex_1() - .w_full() - .min_w_0() - .overflow_x_scroll() - .whitespace_nowrap() - .rounded_sm() - .border_1() - .border_color(cx.theme().colors().border) - .p_1() - .child( - Label::new(command.clone()) - .buffer_font(cx) - .size(LabelSize::XSmall), - ), - ) - .child( - div().absolute().top_1().right_1().child( - CopyButton::new("copy-sandbox-authorization-command", command) - .tooltip_label("Copy Command") - .visible_on_hover(group), - ), - ) - } - fn render_sandbox_authorization_path_row( &self, entry_ix: usize, path_ix: usize, - path: &Path, - show_border: bool, + granted: &settings::GrantedWritePath, cx: &Context, ) -> Stateful
{ - let display_path = path.display().to_string(); - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| display_path.clone()); - let parent_path = path.parent().and_then(|parent| { - let parent = parent.display().to_string(); - (!parent.is_empty()).then_some(parent) - }); - let path_icon = FileIcons::get_icon(path, cx) - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) - .unwrap_or_else(|| { - Icon::new(IconName::Folder) - .color(Color::Muted) - .size(IconSize::Small) - }); + // The path that is actually granted is the resolved canonical target. + // When the request went through a symlink to a *different* target, both + // paths are shown, each explicitly captioned, so it's unmistakable which + // string was requested and which location write access is really granted + // to. + let granted_path = granted.canonical_or_requested(); + let requested_path = granted.requested.clone(); + // Grants are stored in the request's own namespace (a Windows path stays + // `C:\...`, a WSL path stays `/...`), so a genuine symlink/junction + // redirect is just a plain inequality between the request and its + // resolved canonical. + let is_redirected = granted + .resolved + .as_deref() + .is_some_and(|resolved| resolved != requested_path.as_path()); - h_flex() - .id(SharedString::from(format!( - "sandbox-authorization-path-{entry_ix}-{path_ix}" - ))) + let granted_display = granted_path.display().to_string(); + let requested_display = requested_path.display().to_string(); + + let captioned_path = |caption: SharedString, path: String, cx: &Context| { + v_flex() + .min_w_0() + .gap_0p5() + .child( + Label::new(caption) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child(Label::new(path).size(LabelSize::Small).buffer_font(cx)) + }; + + v_flex() + .id(format!("sandbox-authorization-path-{entry_ix}-{path_ix}")) .min_w_0() - .p_1p5() - .gap_2() + .gap_1() + .px_2() + .py_1p5() .bg(cx.theme().colors().editor_background) - .when(show_border, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - h_flex() - .id(SharedString::from(format!( - "sandbox-authorization-path-name-{entry_ix}-{path_ix}" - ))) - .min_w_0() - .gap_0p5() - .child(path_icon) - .child( - Label::new(file_name) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) - .when_some(parent_path, |this, parent_path| { - this.child( - Label::new(format!(" {parent_path}")) + .map(|this| { + if is_redirected { + this.child(captioned_path("Source".into(), requested_display, cx)) + .child( + Icon::new(IconName::ArrowDown) .color(Color::Muted) - .size(LabelSize::XSmall) - .buffer_font(cx), + .size(IconSize::Small), ) - }) - .tooltip(move |_window, cx| { - Tooltip::with_meta("Requested write path", None, display_path.clone(), cx) - }), - ) + .child(captioned_path("Target".into(), granted_display, cx)) + } else { + // Not a genuine redirect: show what the user asked for (e.g. + // the `C:\...` path), not the internal Linux canonical. + this.child(captioned_path("Write Path".into(), requested_display, cx)) + } + }) + .child(Divider::horizontal()) } fn render_permission_buttons( @@ -8361,6 +9359,9 @@ impl ThreadView { entry_ix: usize, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + // When true, the "allow" choices are disabled (e.g. an unacknowledged + // surprising-Unicode warning is showing). "Deny"/"Retry" stay enabled. + allow_disabled: bool, cx: &Context, ) -> Div { match options { @@ -8371,6 +9372,7 @@ impl ThreadView { entry_ix, tool_call_id, focus_handle, + allow_disabled, cx, ), PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown( @@ -8381,6 +9383,7 @@ impl ThreadView { session_id, tool_call_id, focus_handle, + allow_disabled, cx, ), PermissionOptions::DropdownWithPatterns { @@ -8395,6 +9398,7 @@ impl ThreadView { session_id, tool_call_id, focus_handle, + allow_disabled, cx, ), } @@ -8409,6 +9413,7 @@ impl ThreadView { session_id: acp::SessionId, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + allow_disabled: bool, cx: &Context, ) -> Div { let selection = self.permission_selections.get(&tool_call_id); @@ -8463,13 +9468,14 @@ impl ThreadView { .gap_0p5() .child( Button::new(("allow-btn", entry_ix), "Allow") + .disabled(allow_disabled) .start_icon( Icon::new(IconName::Check) .size(IconSize::XSmall) .color(Color::Success), ) .label_size(LabelSize::Small) - .when(is_first, |this| { + .when(is_first && !allow_disabled, |this| { this.key_binding( KeyBinding::for_action_in( &AllowOnce as &dyn Action, @@ -8795,6 +9801,7 @@ impl ThreadView { entry_ix: usize, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + allow_disabled: bool, cx: &Context, ) -> Div { let mut seen_kinds: ArrayVec = ArrayVec::new(); @@ -8858,13 +9865,22 @@ impl ThreadView { } }; - let this = this.start_icon(icon); + // An "allow" choice is disabled while a surprising-Unicode + // warning is unacknowledged; "deny"/"retry" stay enabled. + let is_allow = matches!( + option.kind, + acp::PermissionOptionKind::AllowOnce + | acp::PermissionOptionKind::AllowAlways + ) && !is_retry; + let disabled = allow_disabled && is_allow; + + let this = this.start_icon(icon).disabled(disabled); let Some(action) = action else { return this; }; - if !is_first || seen_kinds.contains(&option.kind) { + if !is_first || disabled || seen_kinds.contains(&option.kind) { return this; } @@ -9168,7 +10184,18 @@ impl ThreadView { ) -> AnyElement { match content { ToolCallContent::ContentBlock(content) => { - if let Some(resource_link) = content.resource_link() { + if let Some((resource, markdown)) = content.embedded_resource() { + self.render_embedded_resource_output( + resource, + markdown.cloned(), + entry_ix, + context_ix, + tool_call, + card_layout, + window, + cx, + ) + } else if let Some(resource_link) = content.resource_link() { self.render_resource_link(resource_link, cx) } else if let Some(markdown) = content.markdown() { self.render_markdown_output( @@ -9180,16 +10207,9 @@ impl ThreadView { window, cx, ) - } else if let Some((image, dimensions)) = content.image() { + } else if let Some((image, _)) = content.image() { let location = tool_call.locations.first().cloned(); - self.render_image_output( - entry_ix, - image.clone(), - dimensions, - location, - card_layout, - cx, - ) + self.render_image_output(entry_ix, image.clone(), location, card_layout, cx) } else { Empty.into_any_element() } @@ -9210,6 +10230,60 @@ impl ThreadView { } } + fn render_embedded_resource_output( + &self, + resource: &acp::EmbeddedResource, + markdown: Option>, + entry_ix: usize, + context_ix: usize, + tool_call: &ToolCall, + card_layout: bool, + window: &Window, + cx: &Context, + ) -> AnyElement { + if let Some(markdown) = markdown { + return self.render_markdown_output( + markdown, + entry_ix, + context_ix, + tool_call, + card_layout, + window, + cx, + ); + } + + let uri = match &resource.resource { + acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(), + acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(), + _ => "", + }; + + v_flex() + .gap_1() + .map(|this| { + if card_layout { + this.p_2().when(context_ix > 0, |this| { + this.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)) + } + }) + .when(!uri.is_empty(), |this| { + this.child( + Label::new(uri.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }) + .into_any_element() + } + fn render_resource_link( &self, resource_link: &acp::ResourceLink, @@ -9403,28 +10477,10 @@ impl ThreadView { &self, entry_ix: usize, image: Arc, - dimensions: Option>, location: Option, card_layout: bool, cx: &Context, ) -> AnyElement { - let format_name = match image.format() { - gpui::ImageFormat::Png => "PNG", - gpui::ImageFormat::Jpeg => "JPEG", - gpui::ImageFormat::Webp => "WebP", - gpui::ImageFormat::Gif => "GIF", - gpui::ImageFormat::Svg => "SVG", - gpui::ImageFormat::Bmp => "BMP", - gpui::ImageFormat::Tiff => "TIFF", - gpui::ImageFormat::Ico => "ICO", - gpui::ImageFormat::Pnm => "PNM", - }; - let dimensions_label = if let Some(size) = dimensions { - format!("{}×{} {}", size.width, size.height, format_name) - } else { - format_name.into() - }; - v_flex() .gap_2() .map(|this| { @@ -9437,27 +10493,17 @@ impl ThreadView { .border_color(self.tool_card_border_color(cx)) } }) - .child( - h_flex() - .w_full() - .justify_between() - .items_center() - .child( - Label::new(dimensions_label) - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), - ) - .when_some(location, |this, _loc| { - this.child( - Button::new(("go-to-file", entry_ix), "Go to File") - .label_size(LabelSize::Small) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tool_call_location(entry_ix, 0, window, cx); - })), - ) - }), - ) + .when_some(location, |this, _loc| { + this.child( + h_flex().w_full().justify_end().child( + Button::new(("go-to-file", entry_ix), "Go to File") + .label_size(LabelSize::Small) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_tool_call_location(entry_ix, 0, window, cx); + })), + ), + ) + }) .child( img(image) .max_w_96() @@ -9547,8 +10593,8 @@ impl ThreadView { let is_cancelled = matches!(tool_call.status, ToolCallStatus::Canceled) || tool_call.content.iter().any(|c| match c { - ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => { - markdown.read(cx).source() == "User canceled" + ToolCallContent::ContentBlock(block) => { + block.text_content(cx) == Some("User canceled") } _ => false, }); @@ -9901,7 +10947,13 @@ impl ThreadView { div() .pb_1() .min_h_0() - .id(format!("subagent-entries-{}", session_id)) + // Include the tool call id so the same subagent session + // rendered in multiple parent cards gets distinct element + // ids for its inlined entries (avoids duplicate a11y ids). + .id(format!( + "subagent-entries-{}-{}", + session_id, tool_call.id.0 + )) .track_scroll(&scroll_handle) .children(rendered_entries), ) @@ -9919,14 +10971,12 @@ impl ThreadView { if matches!(status, ToolCallStatus::Failed) { tool_call.content.iter().find_map(|content| { if let ToolCallContent::ContentBlock(block) = content { - if let acp_thread::ContentBlock::Markdown { markdown } = block { - let source = markdown.read(cx).source().to_string(); - if !source.is_empty() { - if source == "User canceled" { - return None; - } else { - return Some(SharedString::from(source)); - } + if let Some(source) = block.text_content(cx).filter(|source| !source.is_empty()) + { + if source == "User canceled" { + return None; + } else { + return Some(SharedString::from(source)); } } } @@ -10050,13 +11100,17 @@ impl ThreadView { false, cx, ), - ThreadError::NoModelSelected => self.render_error_callout( - "No Model Selected", - "Select a model from the model picker below to get started.".into(), - false, - false, - cx, - ), + ThreadError::NoModelSelected => self + .render_model_not_available_error(cx) + .unwrap_or_else(|| { + self.render_error_callout( + "No Model Selected", + "Select a model from the model picker below to get started.".into(), + false, + false, + cx, + ) + }), ThreadError::ApiError { provider } => self.render_error_callout( "API Error", format!( @@ -10157,6 +11211,101 @@ impl ThreadView { .dismiss_action(self.dismiss_error_button(cx)) } + fn render_model_not_available_error(&self, cx: &mut Context) -> Option { + let thread = self.as_native_thread(cx)?; + + let has_authenticated_provider = + LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx); + + let (title, description): (SharedString, SharedString) = + match thread.read(cx).thread_model() { + agent::ThreadModel::Ready(_) => return None, + agent::ThreadModel::Unresolved(selected_model) => { + if let Some(provider) = LanguageModelRegistry::global(cx) + .read(cx) + .provider(&&selected_model.provider) + { + if !provider.is_authenticated(cx) { + ( + format!("Failed to authenticate with {} provider", provider.name()) + .into(), + "Open the settings to configure the selected provider".into(), + ) + } else { + ( + format!("Model {} was not found", selected_model.model.0).into(), + "You may need to reconfigure authentication for this provider" + .into(), + ) + } + } else { + ( + format!("Provider {} was not found", selected_model.provider).into(), + "Open the settings to configure providers".into(), + ) + } + } + agent::ThreadModel::Unset => { + if has_authenticated_provider { + ( + "No model selected".into(), + "Choose a different model or configure other providers to get started" + .into(), + ) + } else { + ( + "No model selected".into(), + "Configure a provider to get started".into(), + ) + } + } + }; + + let callout = Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title(title) + .description(description) + .actions_slot( + h_flex() + .gap_1() + .child(self.open_llm_providers_settings_button(cx)) + .when(has_authenticated_provider, |this| { + this.child(self.open_model_selector_button(cx)) + }), + ) + .dismiss_action(self.dismiss_error_button(cx)); + + Some(callout) + } + + fn open_llm_providers_settings_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("configure-llm-provider", "Configure Provider") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, window, cx| { + this.clear_thread_error(cx); + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: "llm_providers".to_string(), + target: None, + }), + cx, + ); + })) + } + + fn open_model_selector_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("open-model-selector", "Select Model") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .key_binding(KeyBinding::for_action(&ToggleModelSelector, cx)) + .on_click(cx.listener(|this, _, window, cx| { + this.clear_thread_error(cx); + window.dispatch_action(ToggleModelSelector.boxed_clone(), cx); + })) + } + fn render_prompt_too_large_error(&self, cx: &mut Context) -> Callout { const MESSAGE: &str = "This conversation is too long for the model's context window. \ Start a new thread or remove some attached files to continue."; @@ -10213,7 +11362,6 @@ impl ThreadView { .on_click(cx.listener({ move |this, _, window, cx| { let server_view = this.server_view.clone(); - let agent_name = this.agent_id.clone(); this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { @@ -10226,7 +11374,6 @@ impl ThreadView { ConversationView::handle_auth_required( server_view, AuthRequired::new(), - agent_name, connection, window, cx, @@ -10708,7 +11855,7 @@ impl ThreadView { ), }; - let description = "To continue, start a new thread from a summary."; + let description = "To continue, run /compact or start a new thread and @-mention this one"; Some( Callout::new() @@ -10979,6 +12126,11 @@ impl ThreadView { impl Render for ThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Keep the message editor's local slash commands in sync with the + // current availability of feedback/sharing, which can change between + // renders (settings, connection state, feature flags). + self.sync_local_commands(cx); + let has_messages = self.list_state.item_count() > 0; let list_state = self.list_state.clone(); @@ -11008,27 +12160,15 @@ impl Render for ThreadView { })) .on_action(cx.listener( |this, _: &super::thread_search_bar::DismissThreadSearch, window, cx| { - if let Some(bar) = this.thread_search_bar.clone() { - bar.update(cx, |bar, cx| bar.clear_highlights(cx)); - } - this.thread_search_visible = false; - this.message_editor.focus_handle(cx).focus(window, cx); - cx.notify(); + this.close_thread_search(window, cx); }, )) // Esc can arrive as `editor::Cancel` from the query editor. .on_action( cx.listener(|this, _: &editor::actions::Cancel, window, cx| { - if !this.thread_search_visible { + if !this.close_thread_search(window, cx) { cx.propagate(); - return; - } - if let Some(bar) = this.thread_search_bar.clone() { - bar.update(cx, |bar, cx| bar.clear_highlights(cx)); } - this.thread_search_visible = false; - this.message_editor.focus_handle(cx).focus(window, cx); - cx.notify(); }), ) .on_action(cx.listener( @@ -11190,20 +12330,33 @@ impl Render for ThreadView { }), ) .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| { - this.send_queued_message_at_index(0, true, window, cx); + if let Some(id) = this.message_queue.first_id() { + this.send_queued_message_now(id, window, cx); + } })) .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| { - this.remove_from_queue(0, cx); - cx.notify(); + if let Some(id) = this.message_queue.first_id() { + this.remove_from_queue(id, cx); + cx.notify(); + } })) .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| { - this.move_queued_message_to_main_editor(0, None, None, window, cx); + if let Some(id) = this.message_queue.first_id() { + this.move_queued_message_to_main_editor(id, None, None, window, cx); + } })) + .on_action( + cx.listener(|this, _: &ToggleSteerFirstQueuedMessage, _, cx| { + if this.as_native_thread(cx).is_none() { + return; + } + if let Some(id) = this.message_queue.first_id() { + this.toggle_queue_entry_steer(id, cx); + } + }), + ) .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| { - this.local_queued_messages.clear(); - this.sync_queue_flag_to_native_thread(cx); - this.can_fast_track_queue = false; - cx.notify(); + this.clear_queue(cx); })) .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { if let Some(config_options_view) = this.config_options_view.clone() { @@ -11325,6 +12478,7 @@ impl Render for ThreadView { |this, version| this.child(self.render_new_version_callout(&version, cx)), ) .children(self.render_token_limit_callout(cx)) + .children(self.render_request_elicitations(cx)) .child(self.render_message_editor(window, cx)) } } @@ -11340,19 +12494,58 @@ pub(crate) fn open_link( return; }; - if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() { + let path_style = workspace.read(cx).path_style(cx); + let (relative_path, fragment) = split_local_url_fragment(&url); + if let Some(fragment) = fragment + && !relative_path.is_empty() + && !path_style.is_absolute(relative_path) + { + let project = workspace.read(cx).project().clone(); + let decoded_path = decode_path_escapes(relative_path); + let abs_path = project.update(cx, |project, cx| { + let resolve_path = |path: &str| { + let project_path = project.find_project_path(path, cx)?; + project.entry_for_path(&project_path, cx)?; + project.absolute_path(&project_path, cx) + }; + resolve_path(&decoded_path).or_else(|| resolve_path(relative_path)) + }); + if let Some(abs_path) = abs_path { + let point = fragment + .strip_prefix('L') + .and_then(source_position_from_fragment) + .map(|(row, _)| Point::new(row, 0)); + workspace.update(cx, |workspace, cx| { + open_abs_path_at_point(workspace, abs_path, point, window, cx); + }); + return; + } + } + + if let Some(mention) = MentionUri::parse_hyperlink(&url, path_style).log_err() { + // Percent escapes in bare paths are ambiguous: prefer the decoded + // interpretation, falling back to the literal one (e.g. a file + // actually named `a%20b.rs`) only when the decoded path doesn't + // resolve in the project but the literal one does. + let resolves_in_project = |mention: &MentionUri, cx: &App| { + mention.abs_path().is_some_and(|abs_path| { + let project = workspace.read(cx).project().read(cx); + project + .find_project_path(abs_path, cx) + .is_some_and(|path| project.entry_for_path(&path, cx).is_some()) + }) + }; + let mention = match MentionUri::parse_hyperlink_literal(&url, path_style) { + Some(literal) + if !resolves_in_project(&mention, cx) && resolves_in_project(&literal, cx) => + { + literal + } + _ => mention, + }; workspace.update(cx, |workspace, cx| match mention { MentionUri::File { abs_path } => { - let project = workspace.project(); - let Some(path) = - project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) - else { - return; - }; - - workspace - .open_path(path, None, true, window, cx) - .detach_and_log_err(cx); + open_abs_path_at_point(workspace, abs_path, None, window, cx); } MentionUri::PastedImage { .. } => {} MentionUri::Directory { abs_path } => { @@ -11376,7 +12569,7 @@ pub(crate) fn open_link( open_abs_path_at_point( workspace, path, - Point::new(*line_range.start(), 0), + Some(Point::new(*line_range.start(), 0)), window, cx, ); @@ -11389,7 +12582,7 @@ pub(crate) fn open_link( open_abs_path_at_point( workspace, path, - Point::new(*line_range.start(), column.unwrap_or(0)), + Some(Point::new(*line_range.start(), column.unwrap_or(0))), window, cx, ); @@ -11476,6 +12669,7 @@ mod tests { use super::*; use project::{FakeFs, Project}; use serde_json::json; + use std::path::Path; use util::path; use workspace::MultiWorkspace; @@ -11544,8 +12738,11 @@ mod tests { crate::test_support::init_test(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/project"), json!({"src": {"main.rs": ""}})) - .await; + fs.insert_tree( + path!("/project"), + json!({"src": {"main.rs": "first\nsecond\nthird\n"}}), + ) + .await; let project = Project::test(fs, [path!("/project").as_ref()], cx).await; let (multi_workspace, cx) = @@ -11566,6 +12763,21 @@ mod tests { assert!(*active.path == *"src/main.rs"); }); + multi_workspace.update_in(cx, |_, window, cx| { + open_link("src/main.rs#L2".into(), &workspace_weak, window, cx); + }); + cx.run_until_parked(); + let editor = workspace.read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("file should be open in an editor") + }); + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!(editor.selections.newest::(&snapshot).head().row, 1); + }); + // Absolute path let abs_path: SharedString = path!("/project/src/main.rs").to_string().into(); multi_workspace.update_in(cx, |_, window, cx| { @@ -11580,6 +12792,137 @@ mod tests { assert!(*active.path == *"src/main.rs"); }); } + + #[gpui::test] + async fn test_open_link_percent_escape_disambiguation(cx: &mut gpui::TestAppContext) { + crate::test_support::init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "a%20b.rs": "literal\nsecond\n", + "a b.rs": "decoded\nsecond\n", + "c d.rs": "first\nsecond\n", + "e%20f.rs": "first\nsecond\n", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + let open_link_and_active_path = |url: String, cx: &mut gpui::VisualTestContext| { + multi_workspace.update_in(cx, |_, window, cx| { + open_link(url.into(), &workspace_weak, window, cx); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.project_path(cx)) + .expect("file should be open") + .path + }) + }; + + // Both interpretations exist: the decoded one wins. + let path = open_link_and_active_path(path!("/project/a%20b.rs").to_string(), cx); + assert_eq!(*path, *"a b.rs"); + + // Only the decoded file exists. + let path = open_link_and_active_path(path!("/project/c%20d.rs").to_string(), cx); + assert_eq!(*path, *"c d.rs"); + + // Only the literally-named file exists: fall back to it. + let path = open_link_and_active_path(path!("/project/e%20f.rs").to_string(), cx); + assert_eq!(*path, *"e%20f.rs"); + + let path = open_link_and_active_path("a%20b.rs#L2".to_string(), cx); + assert_eq!(*path, *"a b.rs"); + + let path = open_link_and_active_path("c%20d.rs#L2".to_string(), cx); + assert_eq!(*path, *"c d.rs"); + + let path = open_link_and_active_path("e%20f.rs#L2".to_string(), cx); + assert_eq!(*path, *"e%20f.rs"); + let editor = workspace.read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("file should be open in an editor") + }); + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!(editor.selections.newest::(&snapshot).head().row, 1); + }); + } + + #[gpui::test] + async fn test_open_link_out_of_project_path(cx: &mut gpui::TestAppContext) { + crate::test_support::init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({"src": {"main.rs": ""}})) + .await; + fs.insert_tree(path!("/outside"), json!({"notes.md": "one\ntwo\nthree\n"})) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + // A nonexistent out-of-project path opens nothing, not even an + // empty buffer. + multi_workspace.update_in(cx, |_, window, cx| { + open_link( + path!("/outside/missing.md").to_string().into(), + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + assert!( + workspace.active_item(cx).is_none(), + "nothing should open for a nonexistent path" + ); + }); + + // An existing out-of-project file opens at the linked line. + multi_workspace.update_in(cx, |_, window, cx| { + open_link( + format!("{}:2", path!("/outside/notes.md")).into(), + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + let editor = workspace.read_with(cx, |workspace, cx| { + let item = workspace.active_item(cx).expect("file should be open"); + let project_path = item.project_path(cx).expect("item should have a path"); + let abs_path = workspace + .project() + .read(cx) + .absolute_path(&project_path, cx); + assert_eq!( + abs_path.as_deref(), + Some(Path::new(path!("/outside/notes.md"))) + ); + item.downcast::().expect("should be an editor") + }); + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!(editor.selections.newest::(&snapshot).head().row, 1); + }); + } } const FAST_MODE_WARNING_NAMESPACE: &str = "fast-mode-warning-dismissed"; diff --git a/crates/agent_ui/src/diagnostics.rs b/crates/agent_ui/src/diagnostics.rs index 5a2423cae65aad..75c21d68b751cc 100644 --- a/crates/agent_ui/src/diagnostics.rs +++ b/crates/agent_ui/src/diagnostics.rs @@ -1,6 +1,7 @@ use anyhow::Result; use gpui::{App, AppContext as _, Entity, Task}; use language::{Anchor, BufferSnapshot, DiagnosticEntryRef, DiagnosticSeverity, ToOffset}; +use multi_buffer::MultiBuffer; use project::{DiagnosticSummary, Project}; use rope::Point; use std::{fmt::Write, ops::RangeInclusive, path::Path}; @@ -22,7 +23,7 @@ pub fn codeblock_fence_for_path( write!(text, "{path}").unwrap(); } else { - write!(text, "untitled").unwrap(); + write!(text, "{}", MultiBuffer::DEFAULT_TITLE).unwrap(); } if let Some(row_range) = row_range { diff --git a/crates/agent_ui/src/draft_prompt_store.rs b/crates/agent_ui/src/draft_prompt_store.rs index 1b485ce5c888a2..ad053af534e07f 100644 --- a/crates/agent_ui/src/draft_prompt_store.rs +++ b/crates/agent_ui/src/draft_prompt_store.rs @@ -9,7 +9,7 @@ //! the format we persist. use agent::ZED_AGENT_ID; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Context as _; use db::kvp::KeyValueStore; use gpui::{App, AppContext as _, Entity, Task}; diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index f77baba6470183..092450b0fb96ca 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -1,11 +1,14 @@ -use std::ops::Range; +use std::{ops::Range, sync::Arc}; use acp_thread::{AcpThread, AgentThreadEntry, AssistantMessageChunk}; use agent::ThreadStore; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; -use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; +use editor::{ + Editor, EditorEvent, EditorMode, MinimapVisibility, RestoreOnlyUnstagedDiffHunkDelegate, + SizingBehavior, +}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ScrollHandle, TextStyleRefinement, WeakEntity, Window, @@ -235,7 +238,7 @@ impl EntryViewState { match thread_entry { AgentThreadEntry::UserMessage(message) => { let can_rewind = thread.read(cx).supports_truncate(cx); - let has_id = message.id.is_some(); + let has_client_id = message.client_id.is_some(); let is_subagent = thread.read(cx).parent_session_id().is_some(); let chunks = message.chunks.clone(); if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) { @@ -262,7 +265,7 @@ impl EntryViewState { window, cx, ); - if !can_rewind || !has_id || is_subagent { + if !can_rewind || !has_client_id || is_subagent { editor.set_read_only(true, cx); } editor.set_message(chunks, window, cx); @@ -377,6 +380,16 @@ impl EntryViewState { }); } } + AgentThreadEntry::Elicitation(_) => { + if !matches!(self.entries.get(index), Some(Entry::Elicitation { .. })) { + self.set_entry( + index, + Entry::Elicitation { + focus_handle: cx.focus_handle(), + }, + ); + } + } AgentThreadEntry::AssistantMessage(message) => { let entry = if let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) @@ -452,6 +465,7 @@ impl EntryViewState { match entry { Entry::UserMessage { .. } | Entry::AssistantMessage { .. } + | Entry::Elicitation { .. } | Entry::CompletedPlan | Entry::ContextCompaction => {} Entry::ToolCall(ToolCallEntry { content, .. }) => { @@ -521,6 +535,7 @@ pub enum Entry { UserMessage(Entity), AssistantMessage(AssistantMessageEntry), ToolCall(ToolCallEntry), + Elicitation { focus_handle: FocusHandle }, CompletedPlan, ContextCompaction, } @@ -531,6 +546,7 @@ impl Entry { Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), Self::AssistantMessage(message) => Some(message.focus_handle.clone()), Self::ToolCall(tool_call) => Some(tool_call.focus_handle.clone()), + Self::Elicitation { focus_handle } => Some(focus_handle.clone()), Self::CompletedPlan | Self::ContextCompaction => None, } } @@ -540,6 +556,7 @@ impl Entry { Self::UserMessage(editor) => Some(editor), Self::AssistantMessage(_) | Self::ToolCall(_) + | Self::Elicitation { .. } | Self::CompletedPlan | Self::ContextCompaction => None, } @@ -549,7 +566,7 @@ impl Entry { self.content_map()? .get(&diff.entity_id()) .cloned() - .map(|entity| entity.downcast::().unwrap()) + .and_then(|entity| entity.downcast::().ok()) } pub fn terminal( @@ -559,7 +576,7 @@ impl Entry { self.content_map()? .get(&terminal.entity_id()) .cloned() - .map(|entity| entity.downcast::().unwrap()) + .and_then(|entity| entity.downcast::().ok()) } pub fn scroll_handle_for_assistant_message_chunk( @@ -570,6 +587,7 @@ impl Entry { Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), Self::UserMessage(_) | Self::ToolCall(_) + | Self::Elicitation { .. } | Self::CompletedPlan | Self::ContextCompaction => None, } @@ -588,6 +606,7 @@ impl Entry { Self::ToolCall(ToolCallEntry { content, .. }) => !content.is_empty(), Self::UserMessage(_) | Self::AssistantMessage(_) + | Self::Elicitation { .. } | Self::CompletedPlan | Self::ContextCompaction => false, } @@ -606,6 +625,7 @@ impl Focusable for Entry { Self::UserMessage(editor) => editor.read(cx).focus_handle(cx), Self::AssistantMessage(message) => message.focus_handle.clone(), Self::ToolCall(tool_call) => tool_call.focus_handle.clone(), + Self::Elicitation { focus_handle } => focus_handle.clone(), Self::CompletedPlan | Self::ContextCompaction => cx.focus_handle(), } } @@ -665,7 +685,7 @@ fn create_editor_diff( editor.set_show_code_actions(false, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_expand_all_diff_hunks(cx); - editor.set_render_diff_hunks_as_unstaged(true, cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); editor.set_text_style_refinement(diff_editor_text_style_refinement(cx)); editor }) @@ -690,14 +710,14 @@ mod tests { use std::sync::Arc; use acp_thread::{AgentConnection, StubAgentConnection}; - use agent_client_protocol::schema as acp; + use agent_client_protocol::schema::v1 as acp; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::RowInfo; use fs::FakeFs; use gpui::{AppContext as _, TestAppContext}; use parking_lot::RwLock; - use crate::entry_view_state::EntryViewState; + use crate::entry_view_state::{Entry, EntryViewState}; use crate::message_editor::SessionCapabilities; use multi_buffer::MultiBufferRow; use pretty_assertions::assert_matches; @@ -829,9 +849,86 @@ mod tests { ); } + #[gpui::test] + async fn test_elicitation_preserves_entry_index(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let connection = Rc::new(StubAgentConnection::new()); + let thread = cx + .update(|_, cx| { + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/project"))]), + cx, + ) + }) + .await + .unwrap(); + let session_id = thread.update(cx, |thread, _| thread.session_id().clone()); + + let _response_task = thread.update(cx, |thread, cx| { + thread + .request_elicitation( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(session_id.clone()), + acp::ElicitationSchema::new().string("name", true), + ), + "Provide a name", + ), + cx, + ) + .unwrap() + }); + cx.update(|_, cx| { + connection.send_update( + session_id, + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + acp::ContentBlock::Text(acp::TextContent::new("hello")), + )), + cx, + ); + }); + + let view_state = cx.new(|_cx| { + EntryViewState::new( + workspace.downgrade(), + project.downgrade(), + None, + Arc::new(RwLock::new(SessionCapabilities::default())), + "Test Agent".into(), + ) + }); + + view_state.update_in(cx, |view_state, window, cx| { + view_state.sync_entry(0, &thread, window, cx); + view_state.sync_entry(1, &thread, window, cx); + }); + + view_state.read_with(cx, |view_state, _cx| { + assert!(matches!( + view_state.entry(0), + Some(Entry::Elicitation { .. }) + )); + assert!(matches!( + view_state.entry(1), + Some(Entry::AssistantMessage(_)) + )); + }); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { - let settings_store = SettingsStore::test(cx); + let mut settings_store = SettingsStore::test(cx); + settings_store.register_setting::(); cx.set_global(settings_store); theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 4790b72fb92aa6..bec734527b816c 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -1476,6 +1476,13 @@ impl InlineAssistant { return Some(InlineAssistTarget::Terminal(terminal_view)); } + if let Some(agent_panel) = workspace.panel::(cx) + && let Some(terminal_view) = agent_panel.read(cx).visible_terminal_view().cloned() + && terminal_view.focus_handle(cx).contains_focused(window, cx) + { + return Some(InlineAssistTarget::Terminal(terminal_view)); + } + if let Some(workspace_editor) = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index b5fca12fc58c53..a541baa7cba5fa 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -1615,6 +1615,8 @@ fn insert_message_creases( crease.label.clone(), crease.icon_path.clone(), None, + None, + None, start..end, cx.weak_entity(), ) diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 4f870b7cfbd480..7f9ff087b38973 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -47,10 +47,12 @@ pub fn language_model_selector( if popover_styles { Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + .initial_width(rems(20.)) + .popover() } else { - Picker::list(delegate, window, cx).show_scrollbar(true) + Picker::list(delegate, window, cx) + .show_scrollbar(true) + .embedded() } } @@ -390,6 +392,10 @@ impl ModelMatcher { impl PickerDelegate for LanguageModelPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "language model selector" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 6fddacc3279458..3ab2f10060b1da 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -1,7 +1,7 @@ use crate::diagnostics::{DiagnosticsOptions, codeblock_fence_for_path, collect_diagnostics}; use acp_thread::{MentionUri, selection_name}; use agent::{ThreadStore, outline}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; @@ -263,7 +263,7 @@ impl MentionSet { .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - log::error!("project path not found"); + log::error!("project path not found for image mention {abs_path:?}"); return Task::ready(()); }; let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx)); @@ -395,7 +395,9 @@ impl MentionSet { .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - return Task::ready(Err(anyhow!("project path not found"))); + return Task::ready(Err(anyhow!( + "project path not found for file mention {abs_path:?}" + ))); }; if is_raster_image_path(&abs_path) { @@ -422,7 +424,12 @@ impl MentionSet { let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx)); cx.spawn(async move |_, cx| { - let buffer = buffer.await?; + let buffer = match buffer.await { + Ok(buffer) => buffer, + Err(_error) => { + return Ok(Mention::Link); + } + }; let buffer_content = outline::get_buffer_content_or_outline( buffer.clone(), Some(&abs_path.to_string_lossy()), @@ -465,7 +472,9 @@ impl MentionSet { .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - return Task::ready(Err(anyhow!("project path not found"))); + return Task::ready(Err(anyhow!( + "project path not found for symbol mention {abs_path:?}" + ))); }; let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx)); cx.spawn(async move |_, cx| { @@ -516,6 +525,7 @@ impl MentionSet { source_range: Range, selections: Vec<(Entity, Range, Range)>, editor: Entity, + workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) { @@ -555,6 +565,8 @@ impl MentionSet { selection_name(abs_path.as_deref(), &line_range).into(), uri.icon_path(cx), uri.tooltip_text(), + Some(uri.clone()), + Some(workspace.clone()), range, editor.downgrade(), ); @@ -697,23 +709,41 @@ impl MentionSet { } } -/// Computes disambiguated labels for a set of mentions. When multiple mentions -/// share the same base name, their labels include extra context (additional -/// parent path components for files/directories, source for skills) so the user -/// can tell them apart. Driven by [`util::disambiguate::compute_disambiguation_details`], -/// which is the same utility used for buffer tab titles and the sidebar. +/// Computes disambiguated labels for a set of mentions, so that mentions sharing +/// a base name get extra context (parent path components, skill source) to tell +/// them apart. Same approach as buffer tab titles and the sidebar. fn compute_disambiguated_labels<'a>( mentions: impl Iterator, ) -> HashMap { - let mentions: Vec<_> = mentions.collect(); + let (ids, uris): (Vec, Vec<&MentionUri>) = mentions.unzip(); + ids.into_iter() + .zip(disambiguated_labels_for_uris(&uris)) + .collect() +} + +/// Labels for each URI, in input order. Duplicate URIs are collapsed first, so a +/// mention added twice keeps its base name instead of being escalated to its +/// full path by the collision-resolution loop. +fn disambiguated_labels_for_uris(uris: &[&MentionUri]) -> Vec { + let mut seen: HashSet<&MentionUri> = HashSet::default(); + let unique_uris: Vec<&MentionUri> = uris + .iter() + .copied() + .filter(|&uri| seen.insert(uri)) + .collect(); + let details = - util::disambiguate::compute_disambiguation_details(&mentions, |(_, uri), detail| { + util::disambiguate::compute_disambiguation_details(&unique_uris, |uri, detail| { uri.disambiguated_name(detail) }); - mentions - .into_iter() - .zip(details) - .map(|((id, uri), detail)| (id, uri.disambiguated_name(detail).into())) + + let uri_to_detail: HashMap<&MentionUri, usize> = unique_uris.into_iter().zip(details).collect(); + + uris.iter() + .map(|uri| { + let detail = uri_to_detail.get(uri).copied().unwrap_or(0); + uri.disambiguated_name(detail).into() + }) .collect() } @@ -824,6 +854,27 @@ mod tests { assert!(!is_raster_image_path(Path::new("/tmp/notes.txt"))); assert!(!is_raster_image_path(Path::new("/tmp/README"))); } + + #[test] + fn test_disambiguated_labels_dedupe_identical_uris() { + // Mentioning the same file twice must not escalate the duplicates to + // their full path. Distinct files sharing a base name still disambiguate. + let foo_a = MentionUri::File { + abs_path: path!("/project/a/foo.rs").into(), + }; + + let foo_b = MentionUri::File { + abs_path: path!("/project/b/foo.rs").into(), + }; + + let uris = vec![&foo_a, &foo_a, &foo_b]; + let labels = disambiguated_labels_for_uris(&uris); + + assert_eq!(labels[0].as_ref(), "a/foo.rs"); + assert_eq!(labels[2].as_ref(), "b/foo.rs"); + // The duplicate keeps the same label rather than escalating to full path. + assert_eq!(labels[1].as_ref(), "a/foo.rs"); + } } /// Inserts a list of images into the editor as context mentions. @@ -1100,11 +1151,20 @@ pub(crate) fn crease_for_mention( label: SharedString, icon_path: SharedString, tooltip: Option, + mention_uri: Option, + workspace: Option>, range: Range, editor_entity: WeakEntity, ) -> Crease { let placeholder = FoldPlaceholder { - render: render_fold_icon_button(icon_path.clone(), label.clone(), tooltip, editor_entity), + render: render_fold_icon_button( + icon_path.clone(), + label.clone(), + tooltip, + mention_uri, + workspace, + editor_entity, + ), merge_adjacent: false, ..Default::default() }; @@ -1119,6 +1179,8 @@ fn render_fold_icon_button( icon_path: SharedString, label: SharedString, tooltip: Option, + mention_uri: Option, + workspace: Option>, editor: WeakEntity, ) -> Arc, &mut App) -> AnyElement> { Arc::new({ @@ -1128,6 +1190,8 @@ fn render_fold_icon_button( .unwrap_or_default(); MentionCrease::new(fold_id, icon_path.clone(), label.clone()) + .mention_uri(mention_uri.clone()) + .workspace(workspace.clone()) .is_toggled(is_in_text_selection) .when_some(tooltip.clone(), |this, tooltip_text| { this.tooltip(tooltip_text) @@ -1183,7 +1247,9 @@ fn full_mention_for_directory( .read(cx) .project_path_for_absolute_path(&abs_path, cx) else { - return Task::ready(Err(anyhow!("project path not found"))); + return Task::ready(Err(anyhow!( + "project path not found for directory mention {abs_path:?}" + ))); }; let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else { return Task::ready(Err(anyhow!("project entry not found"))); @@ -1203,8 +1269,7 @@ fn full_mention_for_directory( |(worktree_path, full_path): (Arc, String)| { let rel_path = worktree_path .strip_prefix(&directory_path) - .log_err() - .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into()); + .map_or_else(|_| worktree_path.clone(), |rel_path| rel_path.into()); let open_task = project.update(cx, |project, cx| { project.buffer_store().update(cx, |buffer_store, cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 0b38a944d522fa..fe10a36cb953b5 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -5,13 +5,13 @@ use crate::{ completion_provider::{ AgentContextSelection, AvailableCommand, AvailableSkill, PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, PromptContextType, - SlashCommandCompletion, + PromptLocalCommand, SlashCommandCompletion, }, mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention}, }; use acp_thread::MentionUri; use agent::ThreadStore; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use base64::Engine as _; use editor::{ @@ -140,10 +140,13 @@ impl SessionCapabilities { pub type SharedSessionCapabilities = Arc>; +pub type SharedLocalCommands = Arc>>; + struct MessageEditorCompletionDelegate { session_capabilities: SharedSessionCapabilities, has_thread_store: bool, message_editor: WeakEntity, + local_commands: SharedLocalCommands, } impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate { @@ -165,6 +168,18 @@ impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate { self.session_capabilities.read().completion_skills() } + fn available_local_commands(&self, _cx: &App) -> Vec { + self.local_commands.read().clone() + } + + fn run_local_command(&self, command: PromptLocalCommand, cx: &mut App) { + self.message_editor + .update(cx, |_this, cx| { + cx.emit(MessageEditorEvent::LocalCommandInvoked(command)); + }) + .ok(); + } + fn slash_autocomplete_invoked(&self, cx: &mut App) { // This may be called synchronously from inside a `MessageEditor` // update (e.g. when pasting a slash command triggers completions), @@ -189,6 +204,7 @@ pub struct MessageEditor { editor: Entity, workspace: WeakEntity, session_capabilities: SharedSessionCapabilities, + local_commands: SharedLocalCommands, agent_id: AgentId, thread_store: Option>, _subscriptions: Vec, @@ -213,6 +229,11 @@ pub enum MessageEditorEvent { /// editor. Used by `ThreadView` to fire the global-skills scan /// trigger; see `NativeAgent::ensure_skills_scan_started`. SlashAutocompleteOpened, + /// Emitted when the user confirms a local slash command (scrolling, + /// exporting, feedback) in this editor's completion popup. `ThreadView` + /// handles it by running the corresponding action; see + /// `handle_message_editor_event`. + LocalCommandInvoked(PromptLocalCommand), InputAttempted { attempt: InputAttempt, cursor_offset: usize, @@ -484,11 +505,13 @@ impl MessageEditor { editor }); let mention_set = cx.new(|_cx| MentionSet::new(project, thread_store.clone())); + let local_commands: SharedLocalCommands = Arc::new(RwLock::new(Vec::new())); let completion_provider = Rc::new(PromptCompletionProvider::new( MessageEditorCompletionDelegate { session_capabilities: session_capabilities.clone(), has_thread_store: thread_store.is_some(), message_editor: cx.weak_entity(), + local_commands: local_commands.clone(), }, editor.downgrade(), mention_set.clone(), @@ -583,6 +606,7 @@ impl MessageEditor { mention_set, workspace, session_capabilities, + local_commands, agent_id, thread_store, _subscriptions: subscriptions, @@ -590,6 +614,10 @@ impl MessageEditor { } } + pub fn set_local_commands(&self, commands: Vec) { + *self.local_commands.write() = commands; + } + pub fn set_session_capabilities( &mut self, session_capabilities: SharedSessionCapabilities, @@ -1136,7 +1164,11 @@ impl MessageEditor { .update(cx, |project, cx| { project.project_path_for_absolute_path(&file_path, cx) }) - .ok_or_else(|| "project path not found".to_string())?; + .ok_or_else(|| { + format!( + "project path not found for pasted selection {file_path:?}" + ) + })?; let buffer = project .update(cx, |project, cx| project.open_buffer(project_path, cx)) @@ -1567,6 +1599,7 @@ impl MessageEditor { anchor..anchor, self.editor.downgrade(), self.mention_set.downgrade(), + self.workspace.clone(), Some(selection), ) else { @@ -1986,7 +2019,7 @@ impl Render for MessageEditor { let text_style = TextStyle { color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), + font_family: settings.agent_buffer_font_family().clone(), font_fallbacks: settings.buffer_font.fallbacks.clone(), font_features: settings.buffer_font.features.clone(), font_size: settings.agent_buffer_font_size(cx).into(), @@ -2211,11 +2244,12 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option { #[cfg(test)] mod tests { - use std::{ops::Range, path::Path, path::PathBuf, sync::Arc}; + use std::{ops::Range, path::Path, path::PathBuf, rc::Rc, sync::Arc}; + use super::PromptLocalCommand; use acp_thread::MentionUri; use agent::{ThreadStore, outline}; - use agent_client_protocol::schema as acp; + use agent_client_protocol::schema::v1 as acp; use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, @@ -2909,6 +2943,118 @@ mod tests { }); } + /// Local commands set via `set_local_commands` must surface in the + /// slash-command popup, and confirming one must emit + /// `MessageEditorEvent::LocalCommandInvoked` (so `ThreadView` can run the + /// corresponding action) without leaving the `/keyword` in the editor. + #[gpui::test] + async fn test_local_commands_complete_and_emit_event(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(app_state.clone(), cx); + }); + + let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window.into(), cx); + + let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands( + acp::PromptCapabilities::default(), + Vec::new(), + ))); + + let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + None, + session_capabilities.clone(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + let editor = message_editor.read(cx).editor().clone(); + (message_editor, editor) + }); + + message_editor.read_with(&cx, |message_editor, _| { + message_editor.set_local_commands(vec![ + PromptLocalCommand::ThumbsUp, + PromptLocalCommand::ThumbsDown, + ]); + }); + + let invoked = Rc::new(std::cell::RefCell::new(Vec::new())); + let _subscription = cx.update(|_, cx| { + cx.subscribe(&message_editor, { + let invoked = invoked.clone(); + move |_editor, event, _cx| { + if let MessageEditorEvent::LocalCommandInvoked(command) = event { + invoked.borrow_mut().push(*command); + } + } + }) + }); + + // `/helpful` would fuzzy-match both commands ("helpful" is a + // subsequence of "not-helpful"), so drive the unambiguous keyword. + cx.simulate_input("/not-helpful"); + cx.run_until_parked(); + + editor.read_with(&cx, |editor, _| { + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + &[PromptLocalCommand::ThumbsDown.label().to_string()], + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.read_with(&cx, |editor, cx| { + // The `/keyword` text is removed when a local command is confirmed. + assert_eq!(editor.text(cx), ""); + assert!(!editor.has_visible_completions_menu()); + }); + + assert_eq!( + invoked.borrow().as_slice(), + &[PromptLocalCommand::ThumbsDown], + ); + } + /// Opening slash-command autocomplete must emit /// [`MessageEditorEvent::SlashAutocompleteOpened`]. `ThreadView` /// subscribes to that event to fire the global-skills scan trigger @@ -3323,10 +3469,11 @@ mod tests { let plain_text_language = Arc::new(language::Language::new( language::LanguageConfig { name: "Plain Text".into(), - matcher: language::LanguageMatcher { + matcher: (language::LanguageMatcher { path_suffixes: vec!["txt".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, None, diff --git a/crates/agent_ui/src/mode_selector.rs b/crates/agent_ui/src/mode_selector.rs index cea60af7aa78c5..46c1543bd25e0e 100644 --- a/crates/agent_ui/src/mode_selector.rs +++ b/crates/agent_ui/src/mode_selector.rs @@ -1,5 +1,5 @@ use acp_thread::AgentSessionModes; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use fs::Fs; diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index 015d1b3a1246dc..d177f0adc43366 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -35,8 +35,7 @@ pub fn acp_model_selector( let delegate = ModelPickerDelegate::new(selector, focus_handle, window, cx); Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + .initial_width(rems(20.)) } enum ModelPickerEntry { @@ -192,6 +191,10 @@ impl ModelPickerDelegate { impl PickerDelegate for ModelPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "model selector" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } @@ -266,6 +269,7 @@ impl PickerDelegate for ModelPickerDelegate { fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { if let Some(ModelPickerEntry::Model(model_info, _)) = self.filtered_entries.get(self.selected_index) + && model_info.disabled.is_none() { self.selector .select_model(model_info.id.clone(), cx) @@ -331,6 +335,7 @@ impl PickerDelegate for ModelPickerDelegate { Some(AgentModelIcon::Named(icon)) => this.icon(*icon), None => this, }) + .disabled(model_info.disabled.clone()) .is_selected(is_selected) .is_focused(selected) .is_latest(model_info.is_latest) @@ -506,6 +511,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }) .collect::>(), @@ -613,6 +619,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, AgentModelInfo { @@ -621,6 +628,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, ]; @@ -805,6 +813,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, acp_thread::AgentModelInfo { @@ -813,6 +822,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, ]); @@ -855,6 +865,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, acp_thread::AgentModelInfo { @@ -863,6 +874,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, ]); diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 4ca3241161df78..3007b5fa3eb336 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -18,8 +18,9 @@ use std::{ }; use ui::{ DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing, - PopoverMenuHandle, Tooltip, prelude::*, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; +use workspace::ToggleWorktreeSecurity; /// Trait for types that can provide and manage agent profiles pub trait ProfileProvider { @@ -34,6 +35,22 @@ pub trait ProfileProvider { /// Check if there is a model selected in the current context. fn model_selected(&self, cx: &App) -> bool; + + /// Whether the current workspace is restricted (has untrusted worktrees). + /// + /// In a restricted workspace, profiles that enable tools forbidden in + /// restricted mode are flagged, and the active built-in `write`/`ask` + /// profiles are downgraded to `minimal`. + fn is_restricted(&self, _cx: &App) -> bool { + false + } + + /// Whether the active profile has been downgraded to `minimal` because the + /// workspace is restricted (i.e. the user selected `write`/`ask`, but those + /// profiles aren't honored while restricted). + fn profile_downgraded(&self, _cx: &App) -> bool { + false + } } pub struct ProfileSelector { @@ -123,8 +140,7 @@ impl ProfileSelector { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(18.)) - .max_height(Some(rems(20.).into())) + .initial_width(rems(18.)) }); self.picker = Some(picker); @@ -189,9 +205,23 @@ impl Render for ProfileSelector { IconName::ChevronDown }; + // Warn when the active profile is affected by a restricted workspace: + // either it was downgraded to `minimal`, or it still enables tools that + // are forbidden while restricted. + let show_warning = self.provider.is_restricted(cx) + && (self.provider.profile_downgraded(cx) + || !ProfilePickerDelegate::restricted_forbidden_tools(&profile_id, cx).is_empty()); + let trigger_button = Button::new("profile-selector", selected_profile) .label_size(LabelSize::Small) .color(Color::Muted) + .when(show_warning, |this| { + this.start_icon( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + }) .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); let tooltip: Box AnyView> = Box::new(Tooltip::element({ @@ -340,6 +370,22 @@ impl ProfilePickerDelegate { .collect() } + /// Tools enabled by a profile that are forbidden while the workspace is + /// restricted. Returns an empty list for profiles that are safe to use. + fn restricted_forbidden_tools(profile_id: &AgentProfileId, cx: &App) -> Vec { + let Some(profile) = AgentSettings::get_global(cx).profiles.get(profile_id) else { + return Vec::new(); + }; + profile + .tools + .iter() + .filter(|(name, enabled)| { + **enabled && !agent::tool_allowed_in_restricted_mode(name.as_ref()) + }) + .map(|(name, _)| SharedString::from(name.to_string())) + .collect() + } + fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> { match candidate.id.as_str() { builtin_profiles::WRITE => Some("Get help to write anything."), @@ -430,6 +476,10 @@ impl ProfilePickerDelegate { impl PickerDelegate for ProfilePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "profile selector" + } + fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc { "Search profiles…".into() } @@ -591,10 +641,17 @@ impl PickerDelegate for ProfilePickerDelegate { let is_active = active_id == candidate.id; let has_documentation = Self::documentation(candidate).is_some(); + let has_warning = self.provider.is_restricted(cx) + && !Self::restricted_forbidden_tools(&candidate.id, cx).is_empty(); + // The warning details are merged into the documentation aside, + // so hovering either the row or the icon shows a single popup. + let track_hover = has_documentation || has_warning; + let has_end_slot = is_active || has_warning; + Some( div() .id(("profile-picker-item", ix)) - .when(has_documentation, |this| { + .when(track_hover, |this| { this.on_hover(cx.listener(move |picker, hovered, _, cx| { if *hovered { picker.delegate.hovered_index = Some(ix); @@ -613,11 +670,23 @@ impl PickerDelegate for ProfilePickerDelegate { candidate.name.clone(), entry.positions.clone(), )) - .when(is_active, |this| { + .when(has_end_slot, |this| { this.end_slot( - div() + h_flex() + .gap_1() .pr_2() - .child(Icon::new(IconName::Check).color(Color::Accent)), + .when(has_warning, |this| { + this.child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + }) + .when(is_active, |this| { + this.child( + Icon::new(IconName::Check).color(Color::Accent), + ) + }), ) }), ) @@ -641,13 +710,61 @@ impl PickerDelegate for ProfilePickerDelegate { }; let candidate = self.candidates.get(entry.candidate_index)?; - let docs_aside = Self::documentation(candidate)?.to_string(); + let description = Self::documentation(candidate).map(|docs| docs.to_string()); + let forbidden_tools = if self.provider.is_restricted(cx) { + Self::restricted_forbidden_tools(&candidate.id, cx) + } else { + Vec::new() + }; + + // Nothing to show: no description and no restricted-tool warning. + if description.is_none() && forbidden_tools.is_empty() { + return None; + } let side = documentation_aside_side(cx); Some(DocumentationAside { side, - render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()), + render: Rc::new(move |cx| { + v_flex() + .gap_1p5() + .when_some(description.clone(), |this, description| { + this.child(Label::new(description)) + }) + .when(!forbidden_tools.is_empty(), |this| { + this.when(description.is_some(), |this| { + this.child( + div() + .border_t_1() + .border_color(cx.theme().colors().border_variant), + ) + }) + .child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new("Disabled in Restricted Mode") + .size(LabelSize::Small), + ), + ) + .children(forbidden_tools.iter().map(|tool| { + Label::new(format!("• {tool}")) + .size(LabelSize::Small) + .color(Color::Muted) + })), + ) + }) + .into_any_element() + }), }) } @@ -661,29 +778,66 @@ impl PickerDelegate for ProfilePickerDelegate { cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); + let is_restricted = self.provider.is_restricted(cx); Some( - h_flex() + v_flex() .w_full() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .p_1p5() .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in( - &ManageProfiles::default(), - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); - }), + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1p5() + .child( + Button::new("configure", "Configure") + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in( + &ManageProfiles::default(), + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action( + ManageProfiles::default().boxed_clone(), + cx, + ); + }), + ), ) + .when(is_restricted, |this| { + this.child( + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1p5() + .child( + Button::new("restricted-mode", "Restricted Mode") + .full_width() + .style(ButtonStyle::Tinted(TintColor::Warning)) + .color(Color::Warning) + .start_icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .tooltip(Tooltip::text( + "Some tools are disabled. Click to review trust settings.", + )) + .on_click(|_, window, cx| { + window.dispatch_action( + ToggleWorktreeSecurity.boxed_clone(), + cx, + ); + }), + ), + ) + }) .into_any(), ) } diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index 2390784ce40cfa..914662bdd26cba 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -1,5 +1,5 @@ use acp_thread::{AgentConnection, StubAgentConnection}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::{AgentServer, AgentServerDelegate}; use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index c14ddd651de81c..9a3603ce309f56 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -2,7 +2,7 @@ use std::time::Duration; use acp_thread::AgentSessionListRequest; use agent::ThreadStore; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use chrono::Utc; use collections::{HashMap, HashSet}; use db::kvp::Dismissable; diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 49bfbec9c0eade..eb840eb5791c6b 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -4,7 +4,7 @@ use std::{ }; use agent::{ThreadStore, ZED_AGENT_ID}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Context as _; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet}; @@ -1824,7 +1824,7 @@ mod tests { use acp_thread::StubAgentConnection; use action_log::ActionLog; use agent::DbThread; - use agent_client_protocol::schema as acp; + use agent_client_protocol::schema::v1 as acp; use gpui::{TestAppContext, VisualTestContext}; use project::FakeFs; use project::Project; @@ -1844,7 +1844,6 @@ mod tests { request_token_usage: Default::default(), model: None, profile: None, - imported: false, subagent_context: None, speed: None, thinking_enabled: false, @@ -1852,6 +1851,7 @@ mod tests { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: Default::default(), } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index f2c325a6bda0e0..a9c232c7fafb2d 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -10,7 +10,7 @@ use crate::thread_metadata_store::{ use crate::{Agent, ArchiveSelectedThread, DEFAULT_THREAD_TITLE, RemoveSelectedThread}; use agent::ThreadStore; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use collections::HashMap; @@ -36,7 +36,6 @@ use ui::{ Scrollbars, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, }; -use ui_input::ErasedEditor; use util::ResultExt; use util::paths::PathExt; use workspace::{ @@ -1134,7 +1133,7 @@ impl ProjectPickerModal { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() - .modal(false) + .embedded() }); let picker_focus_handle = picker.focus_handle(cx); @@ -1188,7 +1187,6 @@ impl Render for ProjectPickerModal { v_flex() .key_context("ProjectPickerModal") .elevation_3(cx) - .w(rems(34.)) .on_action(cx.listener(|this, _: &workspace::Open, window, cx| { this.picker.update(cx, |picker, cx| { picker.delegate.open_local_folder(window, cx) @@ -1287,6 +1285,10 @@ impl EventEmitter for ProjectPickerDelegate {} impl PickerDelegate for ProjectPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "thread archive project picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { format!( "Associate the \"{}\" thread with...", @@ -1299,22 +1301,6 @@ impl PickerDelegate for ProjectPickerDelegate { .into() } - fn render_editor( - &self, - editor: &Arc, - window: &mut Window, - cx: &mut Context>, - ) -> Div { - h_flex() - .flex_none() - .h_9() - .px_2p5() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child(editor.render(window, cx)) - } - fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 4d355de28e44cf..d2fbdf7d4c1a28 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -2,12 +2,16 @@ mod agent_notification; mod end_trial_upsell; mod mention_crease; mod model_selector_components; +mod sandbox_status_tooltip; +mod terminal_tool_header; mod undo_reject_toast; pub use agent_notification::*; pub use end_trial_upsell::*; pub use mention_crease::*; pub use model_selector_components::*; +pub use sandbox_status_tooltip::*; +pub use terminal_tool_header::*; pub use undo_reject_toast::*; /// Returns the appropriate [`DocumentationSide`] for documentation asides diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 8b2fa0cbab6ec5..6959b5bc4c06c8 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -1,7 +1,7 @@ use std::{path::PathBuf, time::Duration}; use acp_thread::MentionUri; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use editor::Editor; use gpui::{ Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window, @@ -160,14 +160,14 @@ fn open_mention_uri( workspace.update(cx, |workspace, cx| match mention_uri { MentionUri::File { abs_path } => { - open_file(workspace, abs_path, None, window, cx); + open_abs_path_at_point(workspace, abs_path, None, window, cx); } MentionUri::Symbol { abs_path, line_range, .. } => { - open_file( + open_abs_path_at_point( workspace, abs_path, Some(Point::new(*line_range.start(), 0)), @@ -180,7 +180,7 @@ fn open_mention_uri( line_range, column, } => { - open_file( + open_abs_path_at_point( workspace, abs_path, Some(Point::new(*line_range.start(), column.unwrap_or(0))), @@ -339,41 +339,6 @@ fn open_skill_content_buffer( workspace.add_item(pane, Box::new(editor), None, true, true, window, cx); } -fn open_file( - workspace: &mut Workspace, - abs_path: PathBuf, - point: Option, - window: &mut Window, - cx: &mut Context, -) { - if let Some(point) = point { - if open_abs_path_at_point(workspace, abs_path.clone(), point, window, cx) { - return; - } - } - - let project = workspace.project(); - if let Some(project_path) = - project.update(cx, |project, cx| project.find_project_path(&abs_path, cx)) - { - workspace - .open_path(project_path, None, true, window, cx) - .detach_and_log_err(cx); - } else if abs_path.exists() { - workspace - .open_abs_path( - abs_path, - OpenOptions { - focus: Some(true), - ..Default::default() - }, - window, - cx, - ) - .detach_and_log_err(cx); - } -} - fn reveal_in_project_panel( workspace: &mut Workspace, abs_path: PathBuf, diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs index 37e303ff1e1fe7..53ca58743d31d0 100644 --- a/crates/agent_ui/src/ui/model_selector_components.rs +++ b/crates/agent_ui/src/ui/model_selector_components.rs @@ -1,4 +1,5 @@ use gpui::{Action, ClickEvent, FocusHandle, prelude::*}; +use language_model::DisabledReason; use ui::{Chip, ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; use zed_actions::agent::ToggleModelSelector; @@ -52,6 +53,7 @@ pub struct ModelSelectorListItem { is_focused: bool, is_latest: bool, is_favorite: bool, + disabled: Option, on_toggle_favorite: Option>, cost_info: Option, } @@ -66,6 +68,7 @@ impl ModelSelectorListItem { is_focused: false, is_latest: false, is_favorite: false, + disabled: None, on_toggle_favorite: None, cost_info: None, } @@ -86,6 +89,11 @@ impl ModelSelectorListItem { self } + pub fn disabled(mut self, disabled: Option) -> Self { + self.disabled = disabled; + self + } + pub fn is_focused(mut self, is_focused: bool) -> Self { self.is_focused = is_focused; self @@ -117,8 +125,12 @@ impl ModelSelectorListItem { impl RenderOnce for ModelSelectorListItem { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let is_disabled = self.disabled.is_some(); + let model_icon_color = if self.is_selected { Color::Accent + } else if is_disabled { + Color::Disabled } else { Color::Muted }; @@ -129,6 +141,10 @@ impl RenderOnce for ModelSelectorListItem { .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(self.is_focused) + .when_some(self.disabled, |this, disabled_reason| { + this.disabled(true) + .tooltip(Tooltip::text(disabled_reason.0)) + }) .child( h_flex() .w_full() @@ -143,7 +159,11 @@ impl RenderOnce for ModelSelectorListItem { .size(IconSize::Small), ) }) - .child(Label::new(self.title).truncate()) + .child( + Label::new(self.title) + .when(is_disabled, |this| this.color(Color::Disabled)) + .truncate(), + ) .when(self.is_latest, |parent| parent.child(Chip::new("Latest"))) .when_some(self.cost_info, |this, cost_info| { let tooltip_text = if cost_info.ends_with('×') { @@ -157,26 +177,38 @@ impl RenderOnce for ModelSelectorListItem { this.child(Chip::new(cost_info).tooltip(Tooltip::text(tooltip_text))) }), ) - .end_slot(div().pr_2().when(self.is_selected, |this| { - this.child(Icon::new(IconName::Check).color(Color::Accent)) - })) - .end_slot_on_hover(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 |event, window, cx| (handle_click)(event, window, cx)), - ) - } - })) + .end_slot( + h_flex() + .pr_2() + .gap_1p5() + .when(self.is_selected, |this| { + this.child(Icon::new(IconName::Check).color(Color::Accent)) + }) + .when(is_disabled, |this| { + this.child(Icon::new(IconName::Info).color(Color::Muted)) + }), + ) + .when(!is_disabled, |this| { + this.end_slot_on_hover(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 |event, window, cx| { + (handle_click)(event, window, cx) + }), + ) + } + })) + }) } } diff --git a/crates/agent_ui/src/ui/sandbox_status_tooltip.rs b/crates/agent_ui/src/ui/sandbox_status_tooltip.rs new file mode 100644 index 00000000000000..059c9dd0837e9a --- /dev/null +++ b/crates/agent_ui/src/ui/sandbox_status_tooltip.rs @@ -0,0 +1,277 @@ +use std::path::PathBuf; + +use file_icons::FileIcons; +use gpui::{AnyElement, App}; +use ui::{Divider, prelude::*}; + +#[derive(Clone)] +pub enum SandboxRow { + Message(SharedString), + Path(PathBuf), + Domain(SharedString), +} + +impl SandboxRow { + pub fn message(message: impl Into) -> Self { + Self::Message(message.into()) + } + + pub fn path(path: impl Into) -> Self { + Self::Path(path.into()) + } + + pub fn domain(domain: impl Into) -> Self { + Self::Domain(domain.into()) + } + + fn render(self, cx: &App) -> AnyElement { + let icon_basic = |icon_name: IconName| { + Icon::new(icon_name) + .color(Color::Muted) + .size(IconSize::Small) + }; + + let (icon, label) = match self { + SandboxRow::Message(message) => { + return Label::new(message) + .size(LabelSize::XSmall) + .color(Color::Muted) + .into_any_element(); + } + SandboxRow::Path(path) => { + let icon = FileIcons::get_icon(&path, cx) + .map(|icon| { + Icon::from_path(icon) + .color(Color::Muted) + .size(IconSize::Small) + }) + .unwrap_or_else(|| icon_basic(IconName::Folder)); + (icon, path.display().to_string()) + } + SandboxRow::Domain(domain) => (icon_basic(IconName::Public), domain.to_string()), + }; + + h_flex() + .items_start() + .min_w_0() + .gap_1p5() + .child(icon) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .child(Label::new(label).size(LabelSize::XSmall).buffer_font(cx)), + ) + .into_any_element() + } +} + +#[derive(Clone)] +pub struct SandboxGroup { + heading: SharedString, + rows: Vec, +} + +impl SandboxGroup { + pub fn new(heading: impl Into) -> Self { + Self { + heading: heading.into(), + rows: Vec::new(), + } + } + + pub fn row(mut self, row: SandboxRow) -> Self { + self.rows.push(row); + self + } + + pub fn rows(mut self, rows: impl IntoIterator) -> Self { + self.rows.extend(rows); + self + } + + fn render(self, cx: &App) -> impl IntoElement { + v_flex() + .gap_1p5() + .child( + Label::new(self.heading) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .children(self.rows.into_iter().map(|row| row.render(cx))) + } +} + +#[derive(Clone)] +pub struct SandboxSection { + title: SharedString, + groups: Vec, +} + +impl SandboxSection { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + groups: Vec::new(), + } + } + + pub fn group(mut self, group: SandboxGroup) -> Self { + self.groups.push(group); + self + } + + fn render(self, cx: &App) -> AnyElement { + v_flex() + .gap_2() + .child(Label::new(self.title).size(LabelSize::Small)) + .children(self.groups.into_iter().map(|group| { + v_flex() + .gap_2() + .child(Divider::horizontal()) + .child(group.render(cx)) + })) + .into_any_element() + } +} + +#[derive(Clone, IntoElement, RegisterComponent)] +pub enum SandboxStatusTooltip { + Enabled { + settings: SandboxSection, + thread: Option, + }, + DisabledForThread { + settings: SandboxSection, + }, + DisabledInSettings, +} + +impl SandboxStatusTooltip { + pub fn enabled(settings: SandboxSection, thread: Option) -> Self { + Self::Enabled { settings, thread } + } + + pub fn disabled_for_thread(settings: SandboxSection) -> Self { + Self::DisabledForThread { settings } + } + + pub fn disabled_in_settings() -> Self { + Self::DisabledInSettings + } +} + +impl RenderOnce for SandboxStatusTooltip { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let content = match self { + SandboxStatusTooltip::DisabledInSettings => v_flex() + .child( + Label::new("You have sandboxing disabled in settings.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element(), + SandboxStatusTooltip::DisabledForThread { settings } => v_flex() + .gap_1() + .child(div().opacity(0.5).child(settings.render(cx))) + .child(Divider::horizontal()) + .child(Label::new("Sandboxing is disabled for this thread").size(LabelSize::Small)) + .into_any_element(), + SandboxStatusTooltip::Enabled { settings, thread } => v_flex() + .gap_2() + .child(settings.render(cx)) + .children(thread.map(|thread| { + v_flex() + .gap_2() + .child(Divider::horizontal()) + .child(thread.render(cx)) + })) + .into_any_element(), + }; + + v_flex() + .w(rems_from_px(280.)) + .gap_1() + .child(Label::new("Sandboxing")) + .child(content) + } +} + +impl Component for SandboxStatusTooltip { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn name() -> &'static str { + "Sandbox Status Tooltip" + } + + fn description() -> &'static str { + "The tooltip shown on the sandboxing lock icon in the agent panel, \ + describing the filesystem, network, and Git access granted to the \ + agent for each of the possible sandbox states." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + let settings_section = SandboxSection::new("Defined in your settings:") + .group(SandboxGroup::new("Write Access").rows([ + SandboxRow::path("/Users/you/project"), + SandboxRow::path("/tmp (isolated)"), + ])) + .group(SandboxGroup::new("Network Access").rows([ + SandboxRow::domain("github.com"), + SandboxRow::domain("*.npmjs.org"), + ])); + + let thread_section = SandboxSection::new("Allowed for this thread:") + .group( + SandboxGroup::new("Write Access").row(SandboxRow::path("/Users/you/project/build")), + ) + .group(SandboxGroup::new("Network Access").row(SandboxRow::message("None"))); + + let unrestricted_section = SandboxSection::new("Defined in your settings:") + .group(SandboxGroup::new("Write Access").row(SandboxRow::message( + "All paths except protected Git metadata", + ))) + .group( + SandboxGroup::new("Network Access") + .row(SandboxRow::message("All domains (unrestricted)")), + ); + + let container = || div().p_2().elevation_2(cx).max_w_112(); + + v_flex() + .gap_4() + .child(example_group(vec![ + single_example( + "Enabled", + container() + .child(SandboxStatusTooltip::enabled( + settings_section.clone(), + Some(thread_section), + )) + .into_any_element(), + ), + single_example( + "Enabled (unrestricted, no overrides)", + container() + .child(SandboxStatusTooltip::enabled(unrestricted_section, None)) + .into_any_element(), + ), + single_example( + "Disabled for thread", + container() + .child(SandboxStatusTooltip::disabled_for_thread(settings_section)) + .into_any_element(), + ), + single_example( + "Disabled in settings", + container() + .child(SandboxStatusTooltip::disabled_in_settings()) + .into_any_element(), + ), + ])) + .into_any_element() + } +} diff --git a/crates/agent_ui/src/ui/terminal_tool_header.rs b/crates/agent_ui/src/ui/terminal_tool_header.rs new file mode 100644 index 00000000000000..f22fce6797f087 --- /dev/null +++ b/crates/agent_ui/src/ui/terminal_tool_header.rs @@ -0,0 +1,396 @@ +use std::time::Duration; + +use gpui::{AnyElement, ClickEvent, CursorStyle, Window}; +use ui::{CommonAnimationExt, Disclosure, Divider, DividerColor, Tooltip, prelude::*}; +use util::time::duration_alt_display; + +const ELAPSED_DISPLAY_THRESHOLD: Duration = Duration::from_secs(10); + +type ClickHandler = Box; + +pub struct TerminalSandboxWarning { + pub title: SharedString, + pub detail: SharedString, + pub docs_url: SharedString, +} + +#[derive(IntoElement, RegisterComponent)] +pub struct TerminalToolHeader { + id: SharedString, + hover_group: SharedString, + working_dir: SharedString, + is_expanded: bool, + elapsed: Option, + running: bool, + truncated_tooltip: Option, + failed: bool, + exit_code: Option, + sandbox_warning: Option, + on_toggle_expand: Option, + on_stop: Option, + command_slot: Option, +} + +impl TerminalToolHeader { + pub fn new( + id: impl Into, + hover_group: impl Into, + working_dir: impl Into, + is_expanded: bool, + ) -> Self { + Self { + id: id.into(), + hover_group: hover_group.into(), + working_dir: working_dir.into(), + is_expanded, + elapsed: None, + running: false, + truncated_tooltip: None, + failed: false, + exit_code: None, + sandbox_warning: None, + on_toggle_expand: None, + on_stop: None, + command_slot: None, + } + } + + pub fn elapsed(mut self, elapsed: Duration) -> Self { + self.elapsed = Some(elapsed); + self + } + + pub fn running(mut self, running: bool) -> Self { + self.running = running; + self + } + + pub fn truncated(mut self, tooltip: impl Into) -> Self { + self.truncated_tooltip = Some(tooltip.into()); + self + } + + pub fn failed(mut self, exit_code: Option) -> Self { + self.failed = true; + self.exit_code = exit_code; + self + } + + pub fn sandbox_warning(mut self, warning: TerminalSandboxWarning) -> Self { + self.sandbox_warning = Some(warning); + self + } + + pub fn on_toggle_expand( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_toggle_expand = Some(Box::new(handler)); + self + } + + pub fn on_stop( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_stop = Some(Box::new(handler)); + self + } + + pub fn command_slot(mut self, element: impl IntoElement) -> Self { + self.command_slot = Some(element.into_any_element()); + self + } +} + +impl RenderOnce for TerminalToolHeader { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let show_elapsed = self + .elapsed + .is_some_and(|elapsed| elapsed > ELAPSED_DISPLAY_THRESHOLD); + + let Self { + id, + hover_group, + working_dir, + is_expanded, + elapsed, + running, + truncated_tooltip, + failed, + exit_code, + sandbox_warning, + on_toggle_expand, + on_stop, + command_slot, + } = self; + + let child_id = |name: &str| format!("terminal-tool-{name}-{id}"); + + let header_bg = cx + .theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)); + + let header_row = h_flex() + .id(child_id("header")) + .pt_1() + .pl_1p5() + .pr_1() + .flex_none() + .gap_1() + .justify_between() + .rounded_t_md() + .child( + div().w_full().min_w_0().overflow_hidden().child( + Label::new(working_dir) + .buffer_font(cx) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate_start(), + ), + ) + .child( + Disclosure::new(child_id("disclosure"), is_expanded) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&hover_group) + .when_some(on_toggle_expand, |this, handler| this.on_click(handler)), + ) + .when(show_elapsed, |header| { + let elapsed = elapsed.unwrap_or_default(); + header.child( + Label::new(format!("({})", duration_alt_display(elapsed))) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall) + .mr_0p5() + .when(truncated_tooltip.is_some(), |s| s.mr_0()), + ) + }) + .when(running, |header| { + header + .gap_1p5() + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .child(Divider::vertical().color(DividerColor::Border).ml_1()) + .child( + IconButton::new(child_id("stop"), IconName::Stop) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + "Stop This Command", + None, + "Also possible by placing your cursor inside the terminal \ + and using regular terminal bindings.", + cx, + ) + }) + .when_some(on_stop, |this, handler| this.on_click(handler)), + ) + }) + .when_some(truncated_tooltip, |header, tooltip| { + header.child( + IconButton::new(child_id("truncated"), IconName::Info) + .cursor_style(CursorStyle::Arrow) + .style(ButtonStyle::Transparent) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(tooltip)), + ) + }) + .when(failed, |header| { + header.child( + IconButton::new(child_id("failed"), IconName::Close) + .cursor_style(CursorStyle::Arrow) + .style(ButtonStyle::Transparent) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .when_some(exit_code, |this, code| { + this.tooltip(Tooltip::text(format!("Exited with code {code}"))) + }), + ) + }) + .when_some(sandbox_warning, |header, warning| { + let TerminalSandboxWarning { + title, + detail, + docs_url, + } = warning; + header.child( + IconButton::new(child_id("sandbox-not-applied"), IconName::LockOff) + .icon_size(IconSize::Small) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + title.clone(), + None, + format!("{detail} Click to learn more about sandboxing."), + cx, + ) + }) + .on_click(move |_, _, cx| cx.open_url(&docs_url)), + ) + }); + + v_flex() + .group(hover_group) + .text_xs() + .bg(header_bg) + .child(header_row) + .children(command_slot) + } +} + +impl Component for TerminalToolHeader { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn name() -> &'static str { + "Terminal Tool Header" + } + + fn description() -> &'static str { + "The top of a terminal tool call card in the agent panel." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + let working_dir = "/Users/you/projects/zed"; + + let card = |_id: &'static str, header: TerminalToolHeader| { + v_flex() + .w_full() + .border_1() + .border_color(cx.theme().colors().border.opacity(0.6)) + .rounded_md() + .overflow_hidden() + .child( + header.command_slot( + div().px_1p5().pb_1().child( + Label::new("cargo build --release") + .buffer_font(cx) + .size(LabelSize::XSmall), + ), + ), + ) + .into_any_element() + }; + + let sandbox_warning = || TerminalSandboxWarning { + title: "Ran without sandbox".into(), + detail: "Unsandboxed execution is allowed for the rest of this thread.".into(), + docs_url: "https://zed.dev/docs/ai/sandboxing".into(), + }; + + v_flex() + .gap_4() + .child(example_group(vec![ + single_example( + "Running", + card( + "running", + TerminalToolHeader::new( + "running", + "preview-terminal-header-group-running", + working_dir, + false, + ) + .running(true), + ), + ), + single_example( + "Finished (long-running)", + card( + "elapsed", + TerminalToolHeader::new( + "elapsed", + "preview-terminal-header-group-elapsed", + working_dir, + false, + ) + .elapsed(Duration::from_secs(83)), + ), + ), + single_example( + "Truncated output", + card( + "truncated", + TerminalToolHeader::new( + "truncated", + "preview-terminal-header-group-truncated", + working_dir, + true, + ) + .truncated( + "Output is 2.5 MB long, and to avoid unexpected token \ + usage, only 16 KB was sent back to the agent.", + ), + ), + ), + single_example( + "Failed with exit code", + card( + "failed", + TerminalToolHeader::new( + "failed", + "preview-terminal-header-group-failed", + working_dir, + false, + ) + .failed(Some(101)), + ), + ), + single_example( + "Ran without sandbox", + card( + "sandbox", + TerminalToolHeader::new( + "sandbox", + "preview-terminal-header-group-sandbox", + working_dir, + false, + ) + .sandbox_warning(sandbox_warning()), + ), + ), + single_example( + "Long path (truncated from the start)", + div() + .w_80() + .child(card( + "long-path", + TerminalToolHeader::new( + "long-path", + "preview-terminal-header-group-long-path", + "/Users/you/Documents/GitHub/worktrees/some-monorepo/working-tree-three/packages/deeply/nested/service/backend/src", + false, + ), + )) + .into_any_element(), + ), + single_example( + "Everything at once", + card( + "kitchen-sink", + TerminalToolHeader::new( + "kitchen-sink", + "preview-terminal-header-group-kitchen-sink", + working_dir, + true, + ) + .elapsed(Duration::from_secs(3671)) + .truncated("Output was truncated") + .failed(Some(1)) + .sandbox_warning(sandbox_warning()), + ), + ), + ])) + .into_any_element() + } +} diff --git a/crates/agent_ui/src/unicode_confusables.rs b/crates/agent_ui/src/unicode_confusables.rs new file mode 100644 index 00000000000000..3b1ea11eb21b2c --- /dev/null +++ b/crates/agent_ui/src/unicode_confusables.rs @@ -0,0 +1,244 @@ +//! Detection of "surprising" Unicode characters in the domains and paths shown +//! in sandbox privilege-escalation prompts. +//! +//! Homoglyph/confusable attacks (a Cyrillic `а` standing in for a Latin `a`), +//! invisible characters (zero-width spaces), and bidirectional overrides can +//! make a requested domain or path look like something it is not, tricking the +//! user into granting access to the wrong target. Domains reach the prompt in +//! Punycode (`xn--…`) ASCII form, so a lookalike host is decoded back to +//! Unicode before scanning; paths are scanned as they are displayed. + +use unicode_script::UnicodeScript as _; + +/// Why a character in a domain or path is considered surprising. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SuspiciousKind { + /// A bidirectional control that can visually reorder surrounding text (for + /// example U+202E RIGHT-TO-LEFT OVERRIDE) — the classic "Trojan Source" + /// trick. + BidiControl, + /// A zero-width, invisible, or non-ASCII whitespace formatting character. + Invisible, + /// A visible non-ASCII character that can be confused with ASCII (a + /// homoglyph) or that mixes an unexpected script into otherwise-ASCII text. + Confusable, +} + +/// A single surprising character discovered while scanning a domain or path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SuspiciousChar { + pub character: char, + pub kind: SuspiciousKind, +} + +impl SuspiciousChar { + /// A human-readable, one-line description for the approval banner, such as + /// `‘а’ (U+0430 Cyrillic)` or `U+202E right-to-left override`. + pub fn description(&self) -> String { + let codepoint = format!("U+{:04X}", self.character as u32); + match self.kind { + SuspiciousKind::Confusable => { + format!( + "‘{}’ ({codepoint} {})", + self.character, + self.character.script().full_name() + ) + } + // Bidi controls and invisible characters have no meaningful glyph to + // show (and printing them could itself reorder the banner text), so + // we render only the codepoint and a name. + SuspiciousKind::BidiControl | SuspiciousKind::Invisible => { + match well_known_name(self.character) { + Some(name) => format!("{codepoint} {name}"), + None => codepoint, + } + } + } + } +} + +/// Scan a raw string for surprising Unicode characters, returning each distinct +/// offending character once, in order of first appearance. +pub fn scan(text: &str) -> Vec { + let mut result: Vec = Vec::new(); + for character in text.chars() { + if character.is_ascii() { + continue; + } + if result.iter().any(|found| found.character == character) { + continue; + } + result.push(SuspiciousChar { + character, + kind: classify(character), + }); + } + result +} + +/// Scan a host for surprising characters, first decoding any IDN/Punycode +/// (`xn--…`) labels back to Unicode so a lookalike domain that reaches us as +/// ASCII is still caught. Returns the decoded (Unicode) host — which is what the +/// banner shows the user — alongside the findings. When nothing is surprising +/// the returned host equals the input. +pub fn scan_host(host: &str) -> (String, Vec) { + // `domain_to_unicode` never fails destructively: on error it still returns a + // best-effort decoding, which is exactly what we want to scan and show. + let (decoded, _result) = idna::domain_to_unicode(host); + let findings = scan(&decoded); + (decoded, findings) +} + +fn classify(character: char) -> SuspiciousKind { + if is_bidi_control(character) { + SuspiciousKind::BidiControl + } else if is_invisible(character) { + SuspiciousKind::Invisible + } else { + SuspiciousKind::Confusable + } +} + +fn is_bidi_control(character: char) -> bool { + matches!(character, + '\u{061C}' // ARABIC LETTER MARK + | '\u{200E}' // LEFT-TO-RIGHT MARK + | '\u{200F}' // RIGHT-TO-LEFT MARK + | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO + | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI + ) +} + +fn is_invisible(character: char) -> bool { + matches!(character, + '\u{00AD}' // SOFT HYPHEN + | '\u{180E}' // MONGOLIAN VOWEL SEPARATOR + | '\u{200B}' // ZERO WIDTH SPACE + | '\u{200C}' // ZERO WIDTH NON-JOINER + | '\u{200D}' // ZERO WIDTH JOINER + | '\u{2060}' // WORD JOINER + | '\u{2061}'..='\u{2064}' // invisible math operators + | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE (BOM) + ) || is_non_ascii_space(character) + // Any remaining control/format character (categories Cc/Cf) is + // invisible for our purposes. + || character.is_control() +} + +fn is_non_ascii_space(character: char) -> bool { + matches!( + character, + '\u{00A0}' // NO-BREAK SPACE + | '\u{1680}' // OGHAM SPACE MARK + | '\u{2000}' + ..='\u{200A}' // EN QUAD … HAIR SPACE + | '\u{202F}' // NARROW NO-BREAK SPACE + | '\u{205F}' // MEDIUM MATHEMATICAL SPACE + | '\u{3000}' // IDEOGRAPHIC SPACE + ) +} + +/// Friendly names for the invisible/bidi characters most likely to show up in an +/// attack, so the banner reads better than a bare codepoint. +fn well_known_name(character: char) -> Option<&'static str> { + Some(match character { + '\u{00A0}' => "no-break space", + '\u{00AD}' => "soft hyphen", + '\u{061C}' => "arabic letter mark", + '\u{180E}' => "mongolian vowel separator", + '\u{200B}' => "zero-width space", + '\u{200C}' => "zero-width non-joiner", + '\u{200D}' => "zero-width joiner", + '\u{200E}' => "left-to-right mark", + '\u{200F}' => "right-to-left mark", + '\u{202A}' => "left-to-right embedding", + '\u{202B}' => "right-to-left embedding", + '\u{202C}' => "pop directional formatting", + '\u{202D}' => "left-to-right override", + '\u{202E}' => "right-to-left override", + '\u{2060}' => "word joiner", + '\u{2066}' => "left-to-right isolate", + '\u{2067}' => "right-to-left isolate", + '\u{2068}' => "first strong isolate", + '\u{2069}' => "pop directional isolate", + '\u{3000}' => "ideographic space", + '\u{FEFF}' => "zero-width no-break space", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_ascii_is_never_flagged() { + assert!(scan("github.com").is_empty()); + assert!(scan("/home/user/project/src/main.rs").is_empty()); + assert!(scan("*.npmjs.org").is_empty()); + } + + #[test] + fn detects_cyrillic_homoglyph() { + // "gіthub.com" with a Cyrillic "і" (U+0456). + let findings = scan("g\u{0456}thub.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0456}'); + assert_eq!(findings[0].kind, SuspiciousKind::Confusable); + assert!(findings[0].description().contains("U+0456")); + assert!(findings[0].description().contains("Cyrillic")); + } + + #[test] + fn detects_bidi_override() { + let findings = scan("safe\u{202E}txt.exe"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].kind, SuspiciousKind::BidiControl); + assert_eq!(findings[0].description(), "U+202E right-to-left override"); + } + + #[test] + fn detects_zero_width_space() { + let findings = scan("git\u{200B}hub.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].kind, SuspiciousKind::Invisible); + assert_eq!(findings[0].description(), "U+200B zero-width space"); + } + + #[test] + fn deduplicates_repeated_characters() { + // Two Cyrillic "а" (U+0430) should be reported once. + let findings = scan("\u{0430}bc\u{0430}"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn scan_host_decodes_punycode_lookalike() { + // "аpple.com" (leading Cyrillic а, U+0430) encodes to this Punycode. + let (decoded, findings) = scan_host("xn--pple-43d.com"); + assert_eq!(decoded, "\u{0430}pple.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0430}'); + assert_eq!(findings[0].kind, SuspiciousKind::Confusable); + } + + #[test] + fn scan_host_leaves_plain_domains_alone() { + let (decoded, findings) = scan_host("github.com"); + assert_eq!(decoded, "github.com"); + assert!(findings.is_empty()); + } + + #[test] + fn scan_host_handles_wildcard_subdomain_patterns() { + // Host patterns can carry a leading `*.` wildcard; decoding must not + // choke on it, and a lookalike label behind it is still caught. + let (_decoded, findings) = scan_host("*.xn--pple-43d.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0430}'); + + let (decoded, findings) = scan_host("*.github.com"); + assert_eq!(decoded, "*.github.com"); + assert!(findings.is_empty()); + } +} diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 30b022843c34da..0d31c11b26ba61 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -115,6 +115,13 @@ impl ZedAiOnboarding { ) } + fn vip_stamp(cx: &App) -> impl IntoElement { + div().absolute().bottom_1().right_1().child( + Vector::new(VectorName::VipStamp, rems_from_px(156.), rems_from_px(60.)) + .color(Color::Custom(cx.theme().colors().text.alpha(0.8))), + ) + } + fn student_stamp(cx: &App) -> impl IntoElement { div().absolute().bottom_1().right_1().child( Vector::new( @@ -332,6 +339,23 @@ impl ZedAiOnboarding { .into_any_element() } + fn render_vip_plan_state(&self, cx: &mut App) -> AnyElement { + v_flex() + .w_full() + .relative() + .gap_1() + .child(Self::vip_stamp(cx)) + .child(Headline::new("Welcome to Zed VIP")) + .child( + Label::new("Here's what you get:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.vip_plan()) + .children(self.render_dismiss_button()) + .into_any_element() + } + fn render_student_plan_state(&self, cx: &mut App) -> AnyElement { v_flex() .w_full() @@ -359,6 +383,7 @@ impl RenderOnce for ZedAiOnboarding { Some(Plan::ZedProTrial) => self.render_trial_state(cx), Some(Plan::ZedPro) => self.render_pro_plan_state(cx), Some(Plan::ZedBusiness) => self.render_business_plan_state(cx), + Some(Plan::ZedVip) => self.render_vip_plan_state(cx), Some(Plan::ZedStudent) => self.render_student_plan_state(cx), } } else { @@ -436,6 +461,10 @@ impl Component for ZedAiOnboarding { "Business Plan", onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false), ), + single_example( + "VIP Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedVip), false), + ), single_example( "Student Plan", onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false), diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index 2ac7aeab56678c..674c74ebb87540 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -45,6 +45,12 @@ impl PlanDefinitions { .child(ListBulletItem::new("Usage-based billing")) } + pub fn vip_plan(&self) -> impl IntoElement { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("Tokens in the Zed agent")) + } + pub fn student_plan(&self) -> impl IntoElement { List::new() .child(ListBulletItem::new("Unlimited edit predictions")) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 676b43b02b7c2c..cc33e4f06a130c 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -19,7 +19,26 @@ pub mod batches; pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; -const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; +pub const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; + +pub fn supports_fast_mode(model_id: &str) -> bool { + matches!(model_id, "claude-opus-5" | "claude-opus-4-8") +} + +/// Model IDs where adaptive thinking runs by default when a request omits the +/// `thinking` field, and where thinking must instead be turned off with an +/// explicit `thinking: {"type": "disabled"}`. +/// +/// On earlier Opus models omitting `thinking` means thinking is off; Claude +/// Opus 5 flipped that default. Claude Fable 5 and Claude Mythos 5 also think +/// by default, but they reject `{"type": "disabled"}` with a 400 error, so +/// they are deliberately excluded here (thinking cannot be turned off for +/// them at all). +/// +/// +pub fn requires_explicit_thinking_opt_out(model_id: &str) -> bool { + matches!(model_id, "claude-opus-5") +} pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; @@ -156,10 +175,7 @@ impl Model { AnthropicModelMode::Default }; - let supports_speed = matches!( - entry.id.as_str(), - "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" - ); + let supports_speed = supports_fast_mode(&entry.id); // let supports_compaction = matches!( @@ -167,6 +183,7 @@ impl Model { "claude-fable-5" | "claude-mythos-5" | "claude-mythos-preview" + | "claude-opus-5" | "claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" @@ -543,6 +560,15 @@ pub async fn stream_completion_with_rate_limit_info( .strip_prefix("data: ") .or_else(|| line.strip_prefix("data:"))?; + // Some proxies and gateways append `data: [DONE]` as a + // stream-termination sentinel (an OpenAI convention). + // It is not part of the Anthropic streaming spec and is + // not valid JSON, so skip it to avoid a spurious + // deserialization error. + if line.trim() == "[DONE]" { + return None; + } + match serde_json::from_str(line) { Ok(response) => Some(Ok(response)), Err(error) => Some(Err(AnthropicError::DeserializeResponse(error))), @@ -643,6 +669,10 @@ pub enum RequestContent { #[serde(rename = "compaction")] Compaction { content: Option>, + /// Opaque metadata from a prior compaction that must be round-tripped + /// verbatim for Anthropic to recognize the block. + #[serde(default, skip_serializing_if = "Option::is_none")] + encrypted_content: Option>, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, @@ -678,7 +708,11 @@ pub enum ResponseContent { input: serde_json::Value, }, #[serde(rename = "compaction")] - Compaction { content: Option> }, + Compaction { + content: Option>, + #[serde(default)] + encrypted_content: Option>, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -723,6 +757,10 @@ pub enum Thinking { #[serde(default, skip_serializing_if = "Option::is_none")] display: Option, }, + /// Explicitly turns thinking off. Required by models where thinking runs + /// by default (see [`requires_explicit_thinking_opt_out`]); only accepted + /// at effort `high` or below. + Disabled, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -931,7 +969,11 @@ pub enum ContentDelta { #[serde(rename = "input_json_delta")] InputJsonDelta { partial_json: String }, #[serde(rename = "compaction_delta")] - CompactionDelta { content: Option> }, + CompactionDelta { + content: Option>, + #[serde(default)] + encrypted_content: Option>, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -1196,18 +1238,17 @@ mod tests { } #[test] - fn from_listed_enables_fast_mode_for_opus_4_8() { - let model = Model::from_listed(listed_entry( - "claude-opus-4-8", - ModelCapabilities::default(), - )); - - assert!(model.supports_speed); - let beta_headers = model - .beta_headers() - .expect("model should have beta headers"); - assert!(beta_headers.contains(FAST_MODE_BETA_HEADER)); - assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); + fn from_listed_enables_fast_mode_and_compaction_for_supported_opus_models() { + for model_id in ["claude-opus-5", "claude-opus-4-8"] { + let model = Model::from_listed(listed_entry(model_id, ModelCapabilities::default())); + + assert!(model.supports_speed); + let beta_headers = model + .beta_headers() + .expect("model should have beta headers"); + assert!(beta_headers.contains(FAST_MODE_BETA_HEADER)); + assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); + } } #[test] diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 7c9a8c53695ca3..6c0b65114b0fc3 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -1,15 +1,17 @@ -use anyhow::Result; +use anyhow::{Result, anyhow}; use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ - CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, Role, StopReason, - TokenUsage, + CompactedContext, CompactionUpdate, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, + LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, LanguageModelToolUseInput, MessageContent, ProviderCompactionState, Role, + SharedString, StopReason, TokenUsage, util::{fix_streamed_json, parse_tool_arguments}, }; use std::pin::Pin; use std::str::FromStr; +use std::sync::Arc; use crate::{ AdaptiveThinkingDisplay, AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, @@ -19,6 +21,46 @@ use crate::{ completion_error_from_anthropic_api, }; +pub const COMPACTION_STATE_FORMAT: &str = "anthropic.messages.encrypted-content.v1"; + +/// Packages a compaction block's opaque `encrypted_content` into provider +/// state owned by `owner`. +/// +/// Anthropic requires the metadata to be round-tripped verbatim, and only the +/// backend whose infrastructure produced it can make sense of it. The owner +/// recorded here is what [`provider_compaction_encrypted_content`] later +/// compares against, so it must identify that backend, not merely the wire +/// protocol. +pub fn provider_compaction_state_from_encrypted_content( + owner: LanguageModelProviderId, + encrypted_content: impl Into>, +) -> ProviderCompactionState { + ProviderCompactionState::new( + owner, + SharedString::new_static(COMPACTION_STATE_FORMAT), + encrypted_content, + ) +} + +/// Recovers the `encrypted_content` to round-trip from `state` if it is owned +/// by `owner`, or `None` when the state belongs to a different backend and the +/// summary should be replayed without it. +pub fn provider_compaction_encrypted_content( + state: &ProviderCompactionState, + owner: &LanguageModelProviderId, +) -> Result>> { + if state.provider_id() != owner { + return Ok(None); + } + if state.format() != COMPACTION_STATE_FORMAT { + return Err(anyhow!( + "unsupported Anthropic compaction state format: {}", + state.format() + )); + } + Ok(Some(state.payload().into())) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AnthropicPromptCacheMode { Disabled, @@ -68,7 +110,10 @@ fn mark_last_cacheable_content(content: &mut [RequestContent], cache_control: Ca } } -fn to_anthropic_content(content: MessageContent) -> Option { +fn to_anthropic_content( + content: MessageContent, + compaction_state_owner: &LanguageModelProviderId, +) -> Result> { match content { MessageContent::Text(text) => { let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) { @@ -77,12 +122,12 @@ fn to_anthropic_content(content: MessageContent) -> Option { text }; if !text.is_empty() { - Some(RequestContent::Text { + Ok(Some(RequestContent::Text { text, cache_control: None, - }) + })) } else { - None + Ok(None) } } MessageContent::Thinking { @@ -92,36 +137,41 @@ fn to_anthropic_content(content: MessageContent) -> Option { if let Some(signature) = signature && !thinking.is_empty() { - Some(RequestContent::Thinking { + Ok(Some(RequestContent::Thinking { thinking, signature, cache_control: None, - }) + })) } else { - None + Ok(None) } } MessageContent::RedactedThinking(data) => { if !data.is_empty() { - Some(RequestContent::RedactedThinking { data }) + Ok(Some(RequestContent::RedactedThinking { data })) } else { - None + Ok(None) } } - MessageContent::Image(image) => Some(RequestContent::Image { + MessageContent::Image(image) => Ok(Some(RequestContent::Image { source: ImageSource { source_type: "base64".to_string(), media_type: "image/png".to_string(), data: image.source.to_string(), }, cache_control: None, - }), - MessageContent::ToolUse(tool_use) => Some(RequestContent::ToolUse { - id: tool_use.id.to_string(), - name: tool_use.name.to_string(), - input: tool_use.input, - cache_control: None, - }), + })), + MessageContent::ToolUse(tool_use) => match tool_use.input { + LanguageModelToolUseInput::Json(input) => Ok(Some(RequestContent::ToolUse { + id: tool_use.id.to_string(), + name: tool_use.name.to_string(), + input, + cache_control: None, + })), + LanguageModelToolUseInput::Text(_) => Err(anyhow::anyhow!( + "Anthropic does not support custom tool calls" + )), + }, MessageContent::ToolResult(tool_result) => { let content = match tool_result.content.as_slice() { [LanguageModelToolResultContent::Text(text)] => { @@ -147,24 +197,30 @@ fn to_anthropic_content(content: MessageContent) -> Option { ToolResultContent::Multipart(parts) } }; - Some(RequestContent::ToolResult { + Ok(Some(RequestContent::ToolResult { tool_use_id: tool_result.tool_use_id.to_string(), is_error: tool_result.is_error, content, cache_control: None, - }) + })) } - MessageContent::Compaction(CompactionContent::Summary { content }) => { - Some(RequestContent::Compaction { - content, + MessageContent::Compaction(CompactedContext::Summary { + content, + provider_state, + }) => { + let encrypted_content = match &provider_state { + Some(state) => { + provider_compaction_encrypted_content(state, compaction_state_owner)? + } + None => None, + }; + Ok(Some(RequestContent::Compaction { + content: Some(content), + encrypted_content, cache_control: None, - }) + })) } - // Encrypted compaction blocks come from other providers, and a - // Pending block is a streaming-only UI signal; neither is replayed. - MessageContent::Compaction( - CompactionContent::Encrypted { .. } | CompactionContent::Pending, - ) => None, + MessageContent::Compaction(CompactedContext::ProviderState(_)) => Ok(None), } } @@ -175,7 +231,8 @@ pub fn into_anthropic( max_output_tokens: u64, mode: AnthropicModelMode, cache_mode: AnthropicPromptCacheMode, -) -> crate::Request { + compaction_state_owner: &LanguageModelProviderId, +) -> Result { let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); let mut any_message_wants_cache = false; @@ -189,11 +246,12 @@ pub fn into_anthropic( match message.role { Role::User | Role::Assistant => { - let mut anthropic_message_content: Vec = message - .content - .into_iter() - .filter_map(to_anthropic_content) - .collect(); + let mut anthropic_message_content = Vec::new(); + for content in message.content { + if let Some(content) = to_anthropic_content(content, compaction_state_owner)? { + anthropic_message_content.push(content); + } + } let anthropic_role = match message.role { Role::User => crate::Role::User, Role::Assistant => crate::Role::Assistant, @@ -261,21 +319,52 @@ pub fn into_anthropic( let mut tools: Vec = request .tools .into_iter() - .map(|tool| Tool { - name: tool.name, - description: tool.description, - input_schema: tool.input_schema, - eager_input_streaming: tool.use_input_streaming, - cache_control: None, + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { + input_schema, + use_input_streaming, + } => Ok(Tool { + name: tool.name, + description: tool.description, + input_schema, + eager_input_streaming: use_input_streaming, + cache_control: None, + }), + LanguageModelRequestToolInput::Custom { .. } => { + Err(anyhow::anyhow!("Anthropic does not support custom tools")) + } }) - .collect(); + .collect::>()?; if let Some(cache_control) = long_lived_cache && let Some(last_tool) = tools.last_mut() { last_tool.cache_control = Some(cache_control); } - crate::Request { + let thinking = if request.thinking_allowed { + match mode { + AnthropicModelMode::Thinking { budget_tokens } => { + Some(Thinking::Enabled { budget_tokens }) + } + AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive { + display: Some(AdaptiveThinkingDisplay::Summarized), + }), + AnthropicModelMode::Default => None, + } + } else if crate::requires_explicit_thinking_opt_out(&model) { + // On Claude Opus 5, omitting the `thinking` field no longer means + // "off": the model runs adaptive thinking by default, so features + // that suppress thinking (e.g. inline assist) must opt out + // explicitly. `disabled` is only accepted at effort `high` or below; + // that holds here because `output_config` is never sent when thinking + // is disallowed, and the server-side default effort is `high`. + // + Some(Thinking::Disabled) + } else { + None + }; + + Ok(crate::Request { model, messages: new_messages, max_tokens: max_output_tokens, @@ -290,19 +379,7 @@ pub fn into_anthropic( cache_type: CacheControlType::Ephemeral, ttl: None, }), - thinking: if request.thinking_allowed { - match mode { - AnthropicModelMode::Thinking { budget_tokens } => { - Some(Thinking::Enabled { budget_tokens }) - } - AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive { - display: Some(AdaptiveThinkingDisplay::Summarized), - }), - AnthropicModelMode::Default => None, - } - } else { - None - }, + thinking, tools, tool_choice: request.tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => ToolChoice::Auto, @@ -339,23 +416,33 @@ pub fn into_anthropic( trigger: Some(CompactionTrigger::InputTokens { value }), }], }), - } + }) } pub struct AnthropicEventMapper { tool_uses_by_index: HashMap, + compactions_by_index: HashMap, usage: Usage, stop_reason: StopReason, provider_name: LanguageModelProviderName, + compaction_state_owner: LanguageModelProviderId, } impl AnthropicEventMapper { - pub fn new(provider_name: LanguageModelProviderName) -> Self { + /// `compaction_state_owner` identifies the backend whose infrastructure + /// produced this stream, so that any `encrypted_content` it emits is only + /// ever round-tripped back to that same backend. + pub fn new( + provider_name: LanguageModelProviderName, + compaction_state_owner: LanguageModelProviderId, + ) -> Self { Self { tool_uses_by_index: HashMap::default(), + compactions_by_index: HashMap::default(), usage: Usage::default(), stop_reason: StopReason::EndTurn, provider_name, + compaction_state_owner, } } @@ -407,10 +494,28 @@ impl AnthropicEventMapper { ); Vec::new() } - ResponseContent::Compaction { content } => { - vec![Ok(LanguageModelCompletionEvent::Compaction( - CompactionContent::Summary { content }, - ))] + ResponseContent::Compaction { + content, + encrypted_content, + } => { + let mut events = vec![Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Started, + ))]; + let compaction = self.compactions_by_index.entry(index).or_default(); + if let Some(encrypted_content) = + encrypted_content.filter(|encrypted| !encrypted.is_empty()) + { + compaction.encrypted_content = Some(encrypted_content); + } + if let Some(content) = content + && !content.is_empty() + { + compaction.summary.push_str(&content); + events.push(Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::SummaryDelta(content), + ))); + } + events } }, Event::ContentBlockDelta { index, delta } => match delta { @@ -429,9 +534,30 @@ impl AnthropicEventMapper { signature: Some(signature), })] } - ContentDelta::CompactionDelta { content } => { + ContentDelta::CompactionDelta { + content, + encrypted_content, + } => { + let Some(compaction) = self.compactions_by_index.get_mut(&index) else { + return vec![Err(LanguageModelCompletionError::Other(anyhow::anyhow!( + "Anthropic streamed a compaction delta before starting its content block" + )))]; + }; + // Unlike summary text, `encrypted_content` arrives whole: + // a later delta carries a complete replacement value, not + // a chunk to append (Anthropic's own SDKs assign it, the + // way they do thinking signatures). + if let Some(encrypted_content) = + encrypted_content.filter(|encrypted| !encrypted.is_empty()) + { + compaction.encrypted_content = Some(encrypted_content); + } + let Some(content) = content.filter(|content| !content.is_empty()) else { + return Vec::new(); + }; + compaction.summary.push_str(&content); vec![Ok(LanguageModelCompletionEvent::Compaction( - CompactionContent::Summary { content }, + CompactionUpdate::SummaryDelta(content), ))] } ContentDelta::InputJsonDelta { partial_json } => { @@ -451,7 +577,7 @@ impl AnthropicEventMapper { name: tool_use.name.clone().into(), is_input_complete: false, raw_input: tool_use.input_json.clone(), - input, + input: LanguageModelToolUseInput::Json(input), thought_signature: None, }, ))]; @@ -461,7 +587,29 @@ impl AnthropicEventMapper { } }, Event::ContentBlockStop { index } => { - if let Some(tool_use) = self.tool_uses_by_index.remove(&index) { + if let Some(compaction) = self.compactions_by_index.remove(&index) { + // A compaction block that closes without content is a + // documented failed compaction, which the server treats + // as a no-op: there is nothing to persist, and the + // conversation continues on the uncompacted transcript. + if compaction.summary.is_empty() { + return vec![Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Failed, + ))]; + } + let provider_state = compaction.encrypted_content.map(|encrypted_content| { + provider_compaction_state_from_encrypted_content( + self.compaction_state_owner.clone(), + encrypted_content, + ) + }); + vec![Ok(LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Finished(CompactedContext::Summary { + content: compaction.summary.into(), + provider_state, + }), + ))] + } else if let Some(tool_use) = self.tool_uses_by_index.remove(&index) { let input_json = tool_use.input_json.trim(); let event_result = match parse_tool_arguments(input_json) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( @@ -469,7 +617,7 @@ impl AnthropicEventMapper { id: tool_use.id.into(), name: tool_use.name.into(), is_input_complete: true, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: tool_use.input_json.clone(), thought_signature: None, }, @@ -519,6 +667,17 @@ impl AnthropicEventMapper { ))] } Event::MessageStop => { + // Anthropic closes every content block before ending the + // message, so an unclosed compaction block means the stream + // was malformed and its finalized summary never arrived. + // Consumers would otherwise see `Started` with no terminal + // event and treat the compaction as still in progress. + if !self.compactions_by_index.is_empty() { + self.compactions_by_index.clear(); + return vec![Err(LanguageModelCompletionError::Other(anyhow::anyhow!( + "Anthropic ended the stream without finishing its compaction summary" + )))]; + } vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))] } Event::Error { error } => { @@ -538,6 +697,12 @@ struct RawToolUse { input_json: String, } +#[derive(Default)] +struct RawCompaction { + summary: String, + encrypted_content: Option>, +} + /// Updates usage data by preferring counts from `new`. fn update_usage(usage: &mut Usage, new: &Usage) { if let Some(input_tokens) = new.input_tokens { @@ -568,7 +733,8 @@ mod tests { use super::*; use crate::{AnthropicModelMode, UsageIteration, UsageIterationType}; use language_model_core::{ - ANTHROPIC_PROVIDER_NAME, LanguageModelImage, LanguageModelRequestMessage, MessageContent, + ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, LanguageModelImage, + LanguageModelRequestMessage, MessageContent, }; #[test] @@ -597,12 +763,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -617,7 +783,9 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, - ); + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap(); // No message content block should carry cache_control anymore; the // conversation breakpoint is set via top-level automatic caching. @@ -703,12 +871,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -723,7 +891,9 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Legacy, - ); + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap(); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( @@ -781,7 +951,9 @@ mod tests { 128_000, AnthropicModelMode::AdaptiveThinking, AnthropicPromptCacheMode::Automatic, - ); + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap(); assert_eq!( anthropic_request @@ -791,6 +963,69 @@ mod tests { ); } + #[test] + fn test_thinking_disallowed_sends_explicit_opt_out_only_where_required() { + // (model, expects_explicit_opt_out): Claude Opus 5 thinks by default + // when the `thinking` field is omitted, so suppressing thinking + // requires sending `{"type": "disabled"}`. Earlier Opus models treat + // omission as "off", and Fable rejects `disabled` outright, so both + // must keep omitting the field. + for (model, expects_explicit_opt_out) in [ + ("claude-opus-5", true), + ("claude-opus-4-8", false), + ("claude-fable-5", false), + ] { + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hi".to_string())], + cache: false, + reasoning_details: None, + }], + thread_id: None, + prompt_id: None, + intent: None, + stop: vec![], + temperature: None, + tools: vec![], + tool_choice: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + compact_at_tokens: None, + }; + + let anthropic_request = into_anthropic( + request, + model.to_string(), + 1.0, + 128_000, + AnthropicModelMode::AdaptiveThinking, + AnthropicPromptCacheMode::Automatic, + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap(); + + if expects_explicit_opt_out { + assert!( + matches!(anthropic_request.thinking, Some(Thinking::Disabled)), + "{model} should send an explicit thinking opt-out" + ); + // `disabled` combined with effort `xhigh`/`max` is a 400, so + // no effort may accompany the opt-out. + assert!( + anthropic_request.output_config.is_none(), + "{model} must not send output_config with thinking disabled" + ); + } else { + assert!( + anthropic_request.thinking.is_none(), + "{model} should omit the thinking field entirely" + ); + } + } + } + #[test] fn test_no_cache_control_when_caching_disabled() { let request = LanguageModelRequest { @@ -813,12 +1048,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -833,7 +1068,9 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, - ); + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap(); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( @@ -878,7 +1115,9 @@ mod tests { budget_tokens: Some(10000), }, AnthropicPromptCacheMode::Automatic, + &ANTHROPIC_PROVIDER_ID, ) + .unwrap() } #[test] @@ -977,7 +1216,9 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Disabled, - ); + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap(); assert_eq!( serde_json::to_value(&anthropic_request.context_management).unwrap(), @@ -1001,8 +1242,9 @@ mod tests { #[test] fn test_compaction_content_replayed_as_compaction_block() { let result = request_with_assistant_content(vec![ - MessageContent::Compaction(CompactionContent::Summary { - content: Some("Summary of the conversation so far.".into()), + MessageContent::Compaction(CompactedContext::Summary { + content: "Summary of the conversation so far.".into(), + provider_state: None, }), MessageContent::Text("Response".to_string()), ]); @@ -1022,26 +1264,84 @@ mod tests { ); } + #[test] + fn test_compaction_encrypted_content_replayed_only_for_owning_backend() { + let summary_owned_by = |owner: LanguageModelProviderId| { + MessageContent::Compaction(CompactedContext::Summary { + content: "Summary of the conversation so far.".into(), + provider_state: Some(provider_compaction_state_from_encrypted_content( + owner, + "opaque-compaction-payload", + )), + }) + }; + + let owned = to_anthropic_content( + summary_owned_by(ANTHROPIC_PROVIDER_ID), + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap() + .expect("compaction block should be produced"); + assert_eq!( + serde_json::to_value(&owned).unwrap(), + serde_json::json!({ + "type": "compaction", + "content": "Summary of the conversation so far.", + "encrypted_content": "opaque-compaction-payload" + }) + ); + + // State produced by a different Anthropic-protocol backend must not + // be round-tripped: the summary is still replayed, but without the + // foreign encrypted payload. + let foreign = to_anthropic_content( + summary_owned_by(LanguageModelProviderId::new("other-anthropic-backend")), + &ANTHROPIC_PROVIDER_ID, + ) + .unwrap() + .expect("compaction block should be produced"); + assert_eq!( + serde_json::to_value(&foreign).unwrap(), + serde_json::json!({ + "type": "compaction", + "content": "Summary of the conversation so far." + }) + ); + } + #[test] fn test_event_mapper_maps_compaction_block_and_deltas() { - let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME); + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); let start_event: Event = serde_json::from_value(serde_json::json!({ "type": "content_block_start", "index": 0, - "content_block": { "type": "compaction", "content": null } + "content_block": { "type": "compaction", "content": "Summary " } })) .unwrap(); let delta_event: Event = serde_json::from_value(serde_json::json!({ "type": "content_block_delta", "index": 0, - "delta": { "type": "compaction_delta", "content": "Summary chunk" } + "delta": { "type": "compaction_delta", "content": "in " } + })) + .unwrap(); + let second_delta_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "compaction_delta", "content": "chunks" } + })) + .unwrap(); + let stop_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_stop", + "index": 0 })) .unwrap(); let mut events = Vec::new(); events.extend(mapper.map_event(start_event)); events.extend(mapper.map_event(delta_event)); + events.extend(mapper.map_event(second_delta_event)); + events.extend(mapper.map_event(stop_event)); let events = events .into_iter() .collect::, _>>() @@ -1050,14 +1350,186 @@ mod tests { assert_eq!( events, vec![ - LanguageModelCompletionEvent::Compaction(CompactionContent::Summary { - content: None - }), - LanguageModelCompletionEvent::Compaction(CompactionContent::Summary { - content: Some("Summary chunk".into()) - }), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::Started), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::SummaryDelta( + "Summary ".into() + )), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::SummaryDelta( + "in ".into() + )), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::SummaryDelta( + "chunks".into() + )), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished( + CompactedContext::Summary { + content: "Summary in chunks".into(), + provider_state: None, + } + )), + ] + ); + } + + /// Mirrors the stream shape in Anthropic's SDK fixtures: the block starts + /// with both fields null, then a single delta carries the summary text + /// alongside the opaque `encrypted_content` that must be round-tripped. + #[test] + fn test_event_mapper_captures_encrypted_content_as_provider_state() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); + + let start_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { "type": "compaction", "content": null, "encrypted_content": null } + })) + .unwrap(); + let delta_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "compaction_delta", + "content": "Earlier conversation summarized.", + "encrypted_content": "opaque-compaction-payload" + } + })) + .unwrap(); + let stop_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_stop", + "index": 0 + })) + .unwrap(); + + let mut events = Vec::new(); + events.extend(mapper.map_event(start_event)); + events.extend(mapper.map_event(delta_event)); + events.extend(mapper.map_event(stop_event)); + let mut events = events + .into_iter() + .collect::, _>>() + .expect("all events should map successfully"); + + let Some(LanguageModelCompletionEvent::Compaction(CompactionUpdate::Finished( + CompactedContext::Summary { + content, + provider_state: Some(state), + }, + ))) = events.pop() + else { + panic!("expected a finished summary carrying provider state"); + }; + assert_eq!(content.as_ref(), "Earlier conversation summarized."); + assert_eq!( + provider_compaction_encrypted_content(&state, &ANTHROPIC_PROVIDER_ID) + .unwrap() + .as_deref(), + Some("opaque-compaction-payload") + ); + assert_eq!( + provider_compaction_encrypted_content( + &state, + &LanguageModelProviderId::new("other-anthropic-backend") + ) + .unwrap(), + None + ); + } + + /// A compaction block that closes without any content is Anthropic's + /// documented representation of a failed compaction, which the server + /// treats as a no-op. It must surface as `Failed` -- not as an error that + /// would kill the rest of the response. + #[test] + fn test_event_mapper_maps_null_content_compaction_to_failed() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); + let start_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { "type": "compaction", "content": null, "encrypted_content": null } + })) + .unwrap(); + let stop_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_stop", + "index": 0 + })) + .unwrap(); + + assert_eq!( + mapper + .map_event(start_event) + .into_iter() + .collect::, _>>() + .unwrap(), + vec![LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Started + )] + ); + assert_eq!( + mapper + .map_event(stop_event) + .into_iter() + .collect::, _>>() + .unwrap(), + vec![LanguageModelCompletionEvent::Compaction( + CompactionUpdate::Failed + )] + ); + } + + #[test] + fn test_event_mapper_rejects_compaction_delta_before_start() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); + let delta_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "compaction_delta", "content": "Summary chunk" } + })) + .unwrap(); + + let error = mapper.map_event(delta_event).pop().unwrap().unwrap_err(); + + assert!( + error + .to_string() + .contains("compaction delta before starting") + ); + } + + #[test] + fn test_event_mapper_rejects_stream_end_with_unfinished_compaction() { + let mut mapper = AnthropicEventMapper::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_ID); + let start_event: Event = serde_json::from_value(serde_json::json!({ + "type": "content_block_start", + "index": 0, + "content_block": { "type": "compaction", "content": "Summary " } + })) + .unwrap(); + let stop_event: Event = serde_json::from_value(serde_json::json!({ + "type": "message_stop" + })) + .unwrap(); + + let started = mapper + .map_event(start_event) + .into_iter() + .collect::, _>>() + .unwrap(); + assert_eq!( + started, + vec![ + LanguageModelCompletionEvent::Compaction(CompactionUpdate::Started), + LanguageModelCompletionEvent::Compaction(CompactionUpdate::SummaryDelta( + "Summary ".into() + )), ] ); + + let error = mapper.map_event(stop_event).pop().unwrap().unwrap_err(); + + assert!( + error + .to_string() + .contains("without finishing its compaction summary") + ); } #[test] diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 298d1a736959d1..671ab34df25080 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -22,6 +22,9 @@ tempfile.workspace = true util.workspace = true zeroize.workspace = true +[target.'cfg(not(target_os = "windows"))'.dependencies] +which.workspace = true + [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index e887841b584914..8092e36f3fa5f5 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -91,6 +91,9 @@ pub struct AskPassSession { #[cfg(not(target_os = "windows"))] const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; +#[cfg(not(target_os = "windows"))] +const GPG_WRAPPER_SCRIPT_NAME: &str = "gpg-wrapper.sh"; + impl AskPassSession { /// This will create a new AskPassSession. /// You must retain this session until the master process exits. @@ -153,15 +156,25 @@ impl AskPassSession { // The caller is responsible for examining the result of their own commands and cancelling this // future when this is no longer needed. Note that this can only be called once, but due to the // drop order this takes an &mut, so you can `drop()` it after you're done with the master process. - pub async fn run(&mut self) -> AskPassResult { - // This is the default timeout setting used by VSCode. - let connection_timeout = Duration::from_secs(17); + // + // When `timeout` is provided, this resolves with `AskPassResult::Timedout` if no askpass prompt + // has been opened within that duration. This is intended for connection establishment (e.g. + // SSH), where "no prompt and no connection" indicates an unreachable host. Callers wrapping + // commands that may legitimately run for a long time without prompting (e.g. git) should pass + // `None` and rely on the command's own completion instead. + pub async fn run(&mut self, timeout: Option) -> AskPassResult { let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once"); let askpass_kill_master_rx = self .askpass_kill_master_rx .take() .expect("Only call run once"); let executor = self.executor.clone(); + let timer = async move { + match timeout { + Some(timeout) => executor.timer(timeout).await, + None => std::future::pending().await, + } + }; select_biased! { _ = askpass_opened_rx.fuse() => { @@ -170,7 +183,7 @@ impl AskPassSession { AskPassResult::CancelledByUser } - _ = futures::FutureExt::fuse(executor.timer(connection_timeout)) => { + _ = futures::FutureExt::fuse(timer) => { AskPassResult::Timedout } } @@ -189,6 +202,15 @@ impl AskPassSession { self.askpass_task.script_path() } + /// Path to a script suitable for git's `gpg.program`, routing GnuPG + /// passphrase prompts through Zed's askpass UI. `None` if unavailable. + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + #[cfg(not(target_os = "windows"))] + return self.askpass_task.gpg_wrapper_path(); + #[cfg(target_os = "windows")] + return None; + } + /// Returns the socket path to set as ZED_ASKPASS_SOCKET. /// /// On Windows, SSH_ASKPASS points directly to cli.exe. SSH passes only @@ -206,6 +228,8 @@ pub struct PasswordProxy { /// On Unix: path to the generated .sh askpass script (set as SSH_ASKPASS). /// On Windows: path to cli.exe (set as SSH_ASKPASS directly — no script needed). askpass_script_path: std::path::PathBuf, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path: Option, /// On Windows only: path to the Unix socket, passed as ZED_ASKPASS_SOCKET /// so cli.exe can find it without --askpass argument parsing. #[cfg(target_os = "windows")] @@ -238,6 +262,24 @@ impl PasswordProxy { let askpass_socket_path = askpass_socket.clone(); + // Create a gpg wrapper script that routes GnuPG passphrase prompts through + // the same socket (and thus through Zed's askpass UI). This only works on + // Unix where we control the pinentry via loopback mode. We compute the path + // before the socket task takes ownership of `temp_dir`, and write the file + // afterwards. + #[cfg(not(target_os = "windows"))] + let (gpg_wrapper_script_path, gpg_wrapper_script) = + match generate_gpg_wrapper_script(askpass_program, &askpass_socket_path) { + Ok(script) => ( + Some(temp_dir.path().join(GPG_WRAPPER_SCRIPT_NAME)), + Some(script), + ), + Err(err) => { + log::warn!("could not create gpg askpass wrapper: {err:#}"); + (None, None) + } + }; + let _task = executor.spawn(async move { maybe!(async move { let listener = @@ -291,9 +333,36 @@ impl PasswordProxy { })?; } + // Write the gpg wrapper script (computed above) and mark it executable. + #[cfg(not(target_os = "windows"))] + let gpg_wrapper_script_path = + if let Some((path, script)) = gpg_wrapper_script_path.zip(gpg_wrapper_script) { + match async { + fs::write(&path, script) + .await + .with_context(|| format!("creating gpg wrapper script at {path:?}"))?; + make_file_executable(&path).await.with_context(|| { + format!("marking gpg wrapper script executable at {path:?}") + })?; + anyhow::Ok(()) + } + .await + { + Ok(()) => Some(path), + Err(err) => { + log::warn!("could not write gpg askpass wrapper: {err:#}"); + None + } + } + } else { + None + }; + Ok(Self { _task, askpass_script_path, + #[cfg(not(target_os = "windows"))] + gpg_wrapper_script_path, #[cfg(target_os = "windows")] askpass_socket_path, }) @@ -307,6 +376,11 @@ impl PasswordProxy { pub fn socket_path(&self) -> impl AsRef { &self.askpass_socket_path } + + #[cfg(not(target_os = "windows"))] + pub fn gpg_wrapper_path(&self) -> Option<&std::path::Path> { + self.gpg_wrapper_script_path.as_deref() + } } /// Runs Zed in netcat mode for use in askpass. @@ -398,3 +472,99 @@ fn generate_askpass_script( "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n", )) } + +#[inline] +#[cfg(not(target_os = "windows"))] +fn generate_gpg_wrapper_script( + askpass_program: &std::path::Path, + askpass_socket: &std::path::Path, +) -> Result { + let shell_kind = ShellKind::Posix; + let gpg_program = find_gpg_program().context("could not find a gpg binary on PATH")?; + let gpg_program = gpg_program + .to_str() + .context("gpg program is on a non-utf8 path")?; + let gpg_program = shell_kind + .try_quote_prefix_aware(gpg_program) + .context("Failed to shell-escape gpg program path")?; + + let askpass_program = shell_kind.prepend_command_prefix( + askpass_program + .to_str() + .context("Askpass program is on a non-utf8 path")?, + ); + let askpass_program = shell_kind + .try_quote_prefix_aware(&askpass_program) + .context("Failed to shell-escape Askpass program path")?; + let askpass_socket = askpass_socket + .try_shell_safe(shell_kind) + .context("Failed to shell-escape Askpass socket path")?; + + let prompt = shell_kind + .try_quote_prefix_aware("Enter passphrase for your Git signing key:") + .context("Failed to shell-escape gpg passphrase prompt")?; + + // The wrapper only intervenes when git asks gpg to *sign* (e.g. `gpg -bsau + // `); other invocations like `--verify` run unchanged. For signing we + // first try plain gpg so gpg-agent/keychain can supply a cached or empty + // passphrase silently, and only fall back to asking Zed (loopback mode, fd + // 3) when that fails, e.g. the "Inappropriate ioctl for device" case with no + // TTY for pinentry. + // + // git streams the payload on stdin (readable once) and reads the signature + // from stdout, so we buffer stdin to replay it into both attempts and buffer + // the first attempt's output, forwarding it only if it succeeds. The + // passphrase goes to fd 3 via a pipe. + Ok(format!( + r#"#!/bin/sh +for arg in "$@"; do + case "$arg" in + # Long-form signing options. + --sign|--detach-sign|--clearsign|--clear-sign) is_signing=1 ;; + # Skip other long options so flags like `--status-fd` don't match below. + --*) ;; + # Short-flag clusters containing `s`, e.g. git's `-bsau`. + -*s*) is_signing=1 ;; + esac +done + +# Not a signing request: run gpg as-is, leaving its prompting untouched. +if [ -z "${{is_signing}}" ]; then + exec {gpg_program} "$@" +fi + +# Signing. Buffer stdin (the payload) and the first attempt's output +# so we can retry cleanly on failure without git seeing partial output. +tmpdir=$(mktemp -d) || exit 1 +trap 'rm -rf "$tmpdir"' EXIT +payload="$tmpdir/payload" +signature="$tmpdir/signature" +status="$tmpdir/status" +cat > "$payload" || exit 1 + +# First try letting gpg-agent/keychain supply the passphrase without any +# interactive pinentry. If that succeeds (cached passphrase) +# forward its output and we're done, so Zed never shows a modal. +if {gpg_program} --pinentry-mode error "$@" < "$payload" > "$signature" 2> "$status"; then + cat "$status" >&2 + cat "$signature" + exit 0 +fi + +# The silent attempt failed: ask Zed for the passphrase, then hand it to gpg on +# fd 3 using loopback mode so no pinentry/terminal is required. +passphrase=$(printf '%s\0' {prompt} | {askpass_program} --askpass={askpass_socket} 2>/dev/null) +printf '%s\n' "$passphrase" | +{gpg_program} --pinentry-mode loopback --passphrase-fd 3 "$@" 3<&0 < "$payload" +"#, + )) +} + +/// Finds the real `gpg` (or `gpg2`) executable on `PATH`. +#[inline] +#[cfg(not(target_os = "windows"))] +fn find_gpg_program() -> Option { + ["gpg", "gpg2"] + .into_iter() + .find_map(|candidate| which::which(candidate).ok()) +} diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 9786aa84d1622f..4193e8185cda43 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -13,7 +13,10 @@ use semver::Version; use serde::{Deserialize, Serialize}; use settings::{RegisterSetting, Settings, SettingsStore}; use smol::fs::File; -use smol::{fs, io::AsyncReadExt}; +use smol::{ + fs, + io::{AsyncReadExt, AsyncWriteExt}, +}; use std::mem; use std::{ env::{ @@ -106,12 +109,6 @@ actions!( ] ); -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum VersionCheckType { - Sha(AppCommitSha), - Semantic(Version), -} - #[derive(Serialize, Debug)] pub struct AssetQuery<'a> { asset: &'a str, @@ -126,20 +123,33 @@ pub struct AssetQuery<'a> { pub enum AutoUpdateStatus { Idle, Checking, - Downloading { version: VersionCheckType }, - Installing { version: VersionCheckType }, - Updated { version: VersionCheckType }, - Errored { error: Arc }, + Downloading { + version: Version, + /// Download progress as a fraction in the range `0.0..=1.0`, or `None` + /// when the total download size is not yet known. + progress: Option, + }, + Installing { + version: Version, + }, + Updated { + version: Version, + }, + Errored { + error: Arc, + }, } impl PartialEq for AutoUpdateStatus { + // `progress` is deliberately not compared: two `Downloading` statuses for + // the same version are equal regardless of how far the download is. fn eq(&self, other: &Self) -> bool { match (self, other) { (AutoUpdateStatus::Idle, AutoUpdateStatus::Idle) => true, (AutoUpdateStatus::Checking, AutoUpdateStatus::Checking) => true, ( - AutoUpdateStatus::Downloading { version: v1 }, - AutoUpdateStatus::Downloading { version: v2 }, + AutoUpdateStatus::Downloading { version: v1, .. }, + AutoUpdateStatus::Downloading { version: v2, .. }, ) => v1 == v2, ( AutoUpdateStatus::Installing { version: v1 }, @@ -170,6 +180,7 @@ pub struct AutoUpdater { pending_poll: Option>>, quit_subscription: Option, update_check_type: UpdateCheckType, + dismissed_status: Option, } #[derive(Deserialize, Serialize, Clone, Debug)] @@ -183,35 +194,53 @@ struct MacOsUnmounter<'a> { background_executor: &'a BackgroundExecutor, } +impl MacOsUnmounter<'_> { + /// Unmounts the disk image and waits for completion. This must happen + /// before the `InstallerDir` is dropped: deleting the temp dir while the + /// image is still mounted inside it fails silently and leaks the + /// directory (and the downloaded DMG) in the system temp dir. + async fn unmount(mut self) { + let mount_path = mem::take(&mut self.mount_path); + unmount_disk_image(&mount_path).await; + } +} + impl Drop for MacOsUnmounter<'_> { fn drop(&mut self) { let mount_path = mem::take(&mut self.mount_path); + // Safety net for early exits and cancellation; the happy path calls + // `unmount`, which leaves the path empty. + if mount_path.as_os_str().is_empty() { + return; + } self.background_executor - .spawn(async move { - let unmount_output = new_command("hdiutil") - .args(["detach", "-force"]) - .arg(&mount_path) - .output() - .await; - match unmount_output { - Ok(output) if output.status.success() => { - log::info!("Successfully unmounted the disk image"); - } - Ok(output) => { - log::error!( - "Failed to unmount disk image: {:?}", - String::from_utf8_lossy(&output.stderr) - ); - } - Err(error) => { - log::error!("Error while trying to unmount disk image: {:?}", error); - } - } - }) + .spawn(async move { unmount_disk_image(&mount_path).await }) .detach(); } } +async fn unmount_disk_image(mount_path: &Path) { + let unmount_output = new_command("hdiutil") + .args(["detach", "-force"]) + .arg(mount_path) + .output() + .await; + match unmount_output { + Ok(output) if output.status.success() => { + log::info!("Successfully unmounted the disk image"); + } + Ok(output) => { + log::error!( + "Failed to unmount disk image: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + } + Err(error) => { + log::error!("Error while trying to unmount disk image: {:?}", error); + } + } +} + #[derive(Clone, Copy, Debug, RegisterSetting)] struct AutoUpdateSetting(bool); @@ -334,6 +363,9 @@ pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut App) -> Option<()> { None } +#[cfg(not(target_os = "windows"))] +const INSTALLER_DIR_PREFIX: &str = "zed-auto-update"; + #[cfg(not(target_os = "windows"))] struct InstallerDir(tempfile::TempDir); @@ -342,7 +374,7 @@ impl InstallerDir { async fn new() -> Result { Ok(Self( tempfile::Builder::new() - .prefix("zed-auto-update") + .prefix(INSTALLER_DIR_PREFIX) .tempdir()?, )) } @@ -416,6 +448,7 @@ impl AutoUpdater { pending_poll: None, quit_subscription, update_check_type: UpdateCheckType::Automatic, + dismissed_status: None, } } @@ -436,6 +469,9 @@ impl AutoUpdater { .log_err(); } + #[cfg(all(not(target_os = "windows"), not(test)))] + cx.background_spawn(cleanup_stale_installer_dirs()).detach(); + loop { this.update(cx, |this, cx| this.poll(UpdateCheckType::Automatic, cx))?; cx.background_executor().timer(poll_interval).await; @@ -448,6 +484,9 @@ impl AutoUpdater { } pub fn poll(&mut self, check_type: UpdateCheckType, cx: &mut Context) { + if check_type.is_manual() { + self.dismissed_status = None; + } if self.pending_poll.is_some() { if self.update_check_type == UpdateCheckType::Automatic { self.update_check_type = check_type; @@ -501,6 +540,15 @@ impl AutoUpdater { self.status.clone() } + pub fn dismissed_status(&self) -> Option { + self.dismissed_status.clone() + } + + pub fn dismiss_status(&mut self, status: AutoUpdateStatus, cx: &mut Context) { + self.dismissed_status = Some(status); + cx.notify(); + } + pub fn dismiss(&mut self, cx: &mut Context) -> bool { if let AutoUpdateStatus::Idle = self.status { return false; @@ -700,6 +748,7 @@ impl AutoUpdater { this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Downloading { version: newer_version.clone(), + progress: None, }; cx.notify(); }); @@ -708,9 +757,27 @@ impl AutoUpdater { .await .context("Failed to create installer dir")?; let target_path = Self::target_path(&installer_dir).await?; - download_release(&target_path, fetched_release_data, client) - .await - .with_context(|| format!("Failed to download update to {}", target_path.display()))?; + let progress_entity = this.clone(); + let mut progress_cx = cx.clone(); + download_release( + &target_path, + fetched_release_data, + client, + move |progress| { + progress_entity.update(&mut progress_cx, |this, cx| { + if let AutoUpdateStatus::Downloading { + progress: current_progress, + .. + } = &mut this.status + { + *current_progress = progress; + cx.notify(); + } + }); + }, + ) + .await + .with_context(|| format!("Failed to download update to {}", target_path.display()))?; this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Installing { @@ -765,49 +832,33 @@ impl AutoUpdater { installed_version: Version, fetched_version: String, status: AutoUpdateStatus, - ) -> Result> { - let parsed_fetched_version = fetched_version.parse::(); - - 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().rsplit('.').next() - != Some(&cached_version.full()) - }); - let newer_version = should_download - .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); - return Ok(newer_version); - } - VersionCheckType::Semantic(cached_version) => { - return Self::check_if_fetched_version_is_newer_non_nightly( - cached_version, - parsed_fetched_version?, - ); - } - } - } + ) -> Result> { + let fetched_version = fetched_version.parse::()?; match release_channel { ReleaseChannel::Nightly => { - let should_download = app_commit_sha - .ok() - .flatten() - .map(|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 - .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); - Ok(newer_version) + let should_download = if let AutoUpdateStatus::Updated { version } = status { + fetched_version != version + } else { + let fetched_sha = fetched_version.build.as_str().rsplit('.').next(); + app_commit_sha + .ok() + .flatten() + .is_none_or(|sha| fetched_sha != Some(sha.as_str())) + }; + Ok(should_download.then_some(fetched_version)) + } + _ => { + let current_version = if let AutoUpdateStatus::Updated { version } = status { + version + } else { + installed_version + }; + Ok(Self::check_if_fetched_version_is_newer_non_nightly( + current_version, + fetched_version, + )) } - _ => Self::check_if_fetched_version_is_newer_non_nightly( - installed_version, - parsed_fetched_version?, - ), } } @@ -870,13 +921,11 @@ impl AutoUpdater { fn check_if_fetched_version_is_newer_non_nightly( mut installed_version: Version, fetched_version: Version, - ) -> Result> { + ) -> Option { // For non-nightly releases, ignore build and pre-release fields as they're not provided by our endpoints right now. installed_version.pre = semver::Prerelease::EMPTY; installed_version.build = semver::BuildMetadata::EMPTY; - let should_download = fetched_version > installed_version; - let newer_version = should_download.then(|| VersionCheckType::Semantic(fetched_version)); - Ok(newer_version) + (fetched_version > installed_version).then_some(fetched_version) } pub fn set_should_show_update_notification( @@ -989,6 +1038,7 @@ async fn download_release( target_path: &Path, release: ReleaseAsset, client: Arc, + mut on_progress: impl FnMut(Option), ) -> Result<()> { let mut target_file = File::create(&target_path).await?; @@ -998,7 +1048,40 @@ async fn download_release( "failed to download update: {:?}", response.status() ); - smol::io::copy(response.body_mut(), &mut target_file).await?; + + let total_bytes = response + .headers() + .get(http_client::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|total_bytes| *total_bytes > 0); + + let mut downloaded_bytes: u64 = 0; + let mut last_reported_percent: Option = None; + let mut buffer = [0u8; 8192]; + let body = response.body_mut(); + loop { + let bytes_read = body.read(&mut buffer).await?; + if bytes_read == 0 { + break; + } + target_file.write_all(&buffer[..bytes_read]).await?; + downloaded_bytes += bytes_read as u64; + + if let Some(total_bytes) = total_bytes { + let fraction = (downloaded_bytes as f32 / total_bytes as f32).clamp(0.0, 1.0); + // Only report when the whole-number percentage changes to avoid notifying the UI on every chunk. + let percent = (fraction * 100.0) as u8; + if last_reported_percent != Some(percent) { + last_reported_percent = Some(percent); + on_progress(Some(fraction)); + } + } + } + target_file.flush().await?; + if total_bytes.is_some() && last_reported_percent != Some(100) { + on_progress(Some(1.0)); + } log::info!("downloaded update. path:{:?}", target_path); Ok(()) @@ -1102,8 +1185,7 @@ async fn install_release_macos( String::from_utf8_lossy(&output.stderr) ); - // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits - let _unmounter = MacOsUnmounter { + let unmounter = MacOsUnmounter { mount_path: mount_path.clone(), background_executor, }; @@ -1112,10 +1194,13 @@ async fn install_release_macos( cmd.args(["-av", "--delete", "--exclude", "Icon?"]) .arg(&mounted_app_path) .arg(&running_app_path); - let output = cmd - .output() - .await - .with_context(|| "failed to rsync: {cmd}")?; + let rsync_output = cmd.output().await; + + // Await the unmount (even if rsync failed) so that the installer temp dir + // can be deleted once this function returns. + unmounter.unmount().await; + + let output = rsync_output.with_context(|| "failed to rsync: {cmd}")?; anyhow::ensure!( output.status.success(), @@ -1126,6 +1211,52 @@ async fn install_release_macos( Ok(None) } +/// Removes stale installer dirs from the system temp dir. Older Zed versions +/// leaked one per update by deleting the dir while the downloaded disk image +/// was still mounted inside it, which made the deletion fail silently. +#[cfg(any(rust_analyzer, all(not(target_os = "windows"), not(test))))] +async fn cleanup_stale_installer_dirs() { + const STALE_INSTALLER_DIR_AGE: Duration = Duration::from_secs(24 * 60 * 60); + + let temp_dir = std::env::temp_dir(); + let Ok(mut entries) = fs::read_dir(&temp_dir).await else { + log::warn!("failed to read temp dir {temp_dir:?} while cleaning up installer dirs"); + return; + }; + while let Some(entry) = entries.next().await { + let Ok(entry) = entry else { + continue; + }; + if !entry + .file_name() + .to_string_lossy() + .starts_with(INSTALLER_DIR_PREFIX) + { + continue; + } + // Leave recent dirs alone, as they may belong to an update currently + // in progress in another Zed instance. + let is_stale = entry.metadata().await.ok().is_some_and(|metadata| { + metadata.is_dir() + && metadata.modified().ok().is_some_and(|modified| { + SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age > STALE_INSTALLER_DIR_AGE) + }) + }); + if is_stale { + if let Err(error) = fs::remove_dir_all(entry.path()).await { + log::warn!( + "failed to remove stale installer dir {:?}: {error}", + entry.path() + ); + } else { + log::info!("removed stale installer dir {:?}", entry.path()); + } + } + } +} + async fn cleanup_windows() -> Result<()> { let parent = std::env::current_exe()? .parent() @@ -1297,7 +1428,8 @@ mod tests { assert_eq!( status, AutoUpdateStatus::Downloading { - version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)) + version: semver::Version::new(0, 100, 1), + progress: None, } ); @@ -1327,7 +1459,7 @@ mod tests { assert_eq!( status, AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)) + version: semver::Version::new(0, 100, 1) } ); let will_restart = cx.expect_restart(); @@ -1337,6 +1469,114 @@ mod tests { assert_eq!(std::fs::read_to_string(path).unwrap(), ""); } + #[gpui::test] + async fn test_download_release_reports_progress(cx: &mut TestAppContext) { + cx.background_executor.allow_parking(); + + let body = vec![0u8; 20_000]; + let content_length = body.len(); + + let client = FakeHttpClient::create(move |_req| { + let body = body.clone(); + async move { + Ok(Response::builder() + .status(200) + .header( + http_client::http::header::CONTENT_LENGTH, + body.len().to_string(), + ) + .body(body.into()) + .unwrap()) + } + }); + + let temp_dir = tempdir().unwrap(); + let target_path = temp_dir.path().join("zed-download"); + let release = ReleaseAsset { + version: "1.0.0".to_string(), + url: "https://test.example/download".to_string(), + }; + + let reported = Rc::new(std::cell::RefCell::new(Vec::::new())); + download_release(&target_path, release, client, { + let reported = reported.clone(); + move |fraction| { + if let Some(fraction) = fraction { + reported.borrow_mut().push(fraction); + } + } + }) + .await + .unwrap(); + + let reported = reported.borrow(); + assert!( + reported.len() >= 2, + "expected progress to be reported across multiple reads, got {reported:?}" + ); + assert_eq!( + reported.last().copied(), + Some(1.0), + "download should finish at 100%" + ); + for fraction in reported.iter() { + assert!( + (0.0..=1.0).contains(fraction), + "progress {fraction} out of range" + ); + } + for pair in reported.windows(2) { + assert!( + pair[0] <= pair[1], + "progress must not decrease: {reported:?}" + ); + } + + let downloaded_len = std::fs::metadata(&target_path).unwrap().len(); + assert_eq!(downloaded_len, content_length as u64); + } + + #[gpui::test] + async fn test_download_release_without_content_length_reports_no_progress( + cx: &mut TestAppContext, + ) { + cx.background_executor.allow_parking(); + + let body = vec![0u8; 20_000]; + let content_length = body.len(); + + let client = FakeHttpClient::create(move |_req| { + let body = body.clone(); + async move { Ok(Response::builder().status(200).body(body.into()).unwrap()) } + }); + + let temp_dir = tempdir().unwrap(); + let target_path = temp_dir.path().join("zed-download"); + let release = ReleaseAsset { + version: "1.0.0".to_string(), + url: "https://test.example/download".to_string(), + }; + + let reported = Rc::new(std::cell::RefCell::new(Vec::>::new())); + download_release(&target_path, release, client, { + let reported = reported.clone(); + move |fraction| { + reported.borrow_mut().push(fraction); + } + }) + .await + .unwrap(); + + assert!( + reported.borrow().is_empty(), + "progress should not be reported when the total size is unknown, got {:?}", + reported.borrow() + ); + + let downloaded_len = std::fs::metadata(&target_path).unwrap().len(); + assert_eq!(downloaded_len, content_length as u64); + } + #[test] fn test_stable_does_not_update_when_fetched_version_is_not_higher() { let release_channel = ReleaseChannel::Stable; @@ -1372,10 +1612,7 @@ mod tests { status, ); - assert_eq!( - newer_version.unwrap(), - Some(VersionCheckType::Semantic(fetched_version)) - ); + assert_eq!(newer_version.unwrap(), Some(fetched_version)); } #[test] @@ -1384,7 +1621,7 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(1, 0, 1)), + version: semver::Version::new(1, 0, 1), }; let fetched_version = semver::Version::new(1, 0, 1); @@ -1405,7 +1642,7 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(1, 0, 1)), + version: semver::Version::new(1, 0, 1), }; let fetched_version = semver::Version::new(1, 0, 2); @@ -1417,10 +1654,7 @@ mod tests { status, ); - assert_eq!( - newer_version.unwrap(), - Some(VersionCheckType::Semantic(fetched_version)) - ); + assert_eq!(newer_version.unwrap(), Some(fetched_version)); } #[test] @@ -1430,13 +1664,13 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Idle; - let fetched_sha = "1.0.0+a".to_string(); + let fetched_version = "1.0.0+a".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1449,19 +1683,19 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; - let fetched_sha = "b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } @@ -1472,15 +1706,15 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1494,22 +1728,51 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+c".to_string(); + let fetched_version = "1.0.0+c".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) + ); + } + + #[test] + fn test_nightly_does_not_redownload_after_updating_to_fetched_version() { + let release_channel = ReleaseChannel::Nightly; + let installed_version = semver::Version::new(1, 0, 0); + let fetched_version = "1.0.0+nightly.b".to_string(); + + let newer_version = AutoUpdater::check_if_fetched_version_is_newer( + release_channel, + Ok(Some("a".to_string())), + installed_version.clone(), + fetched_version.clone(), + AutoUpdateStatus::Idle, + ) + .unwrap() + .expect("a newer nightly version should be available"); + + let next_check = AutoUpdater::check_if_fetched_version_is_newer( + release_channel, + Ok(Some("a".to_string())), + installed_version, + fetched_version, + AutoUpdateStatus::Updated { + version: newer_version, + }, ); + + assert_eq!(next_check.unwrap(), None); } #[test] @@ -1518,19 +1781,19 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; - let fetched_sha = "a".to_string(); + let fetched_version = "1.0.0+a".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } @@ -1541,15 +1804,15 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1563,21 +1826,21 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "c".to_string(); + let fetched_version = "1.0.0+c".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } } diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index e8ce37e8c8f423..9df36df46f02e6 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -43,30 +43,8 @@ pub async fn stream_completion( let mut additional_fields: HashMap = HashMap::new(); - match request.thinking { - Some(Thinking::Enabled { - budget_tokens: Some(budget_tokens), - }) => { - let thinking_config = HashMap::from([ - ("type".to_string(), Document::String("enabled".to_string())), - ( - "budget_tokens".to_string(), - Document::Number(AwsNumber::PosInt(budget_tokens)), - ), - ]); - additional_fields.insert("thinking".to_string(), Document::from(thinking_config)); - } - Some(Thinking::Adaptive { effort: _ }) => { - let thinking_config = HashMap::from([ - ("type".to_string(), Document::String("adaptive".to_string())), - ( - "display".to_string(), - Document::String("summarized".to_string()), - ), - ]); - additional_fields.insert("thinking".to_string(), Document::from(thinking_config)); - } - _ => {} + if let Some(thinking) = &request.thinking { + additional_fields.extend(thinking_request_fields(thinking)); } if !additional_fields.is_empty() { @@ -212,6 +190,71 @@ pub enum Thinking { Adaptive { effort: BedrockAdaptiveThinkingEffort, }, + /// Explicitly turns thinking off. Required by Claude Opus 5, where + /// adaptive thinking runs by default when the `thinking` field is + /// omitted; only accepted at effort `high` or below. + /// + /// + Disabled, +} + +/// Converts the request's thinking configuration into the +/// `additionalModelRequestFields` entries understood by Anthropic models on +/// the Converse API. +fn thinking_request_fields(thinking: &Thinking) -> HashMap { + let mut fields = HashMap::new(); + match thinking { + Thinking::Enabled { + budget_tokens: Some(budget_tokens), + } => { + fields.insert( + "thinking".to_string(), + Document::from(HashMap::from([ + ("type".to_string(), Document::String("enabled".to_string())), + ( + "budget_tokens".to_string(), + Document::Number(AwsNumber::PosInt(*budget_tokens)), + ), + ])), + ); + } + Thinking::Enabled { + budget_tokens: None, + } => {} + Thinking::Adaptive { effort } => { + fields.insert( + "thinking".to_string(), + Document::from(HashMap::from([ + ("type".to_string(), Document::String("adaptive".to_string())), + ( + "display".to_string(), + Document::String("summarized".to_string()), + ), + ])), + ); + fields.insert( + "output_config".to_string(), + Document::from(HashMap::from([( + "effort".to_string(), + Document::String(effort.as_str().to_string()), + )])), + ); + } + Thinking::Disabled => { + // On Claude Opus 5 omitting the `thinking` field means adaptive + // thinking runs by default, so turning it off requires this + // explicit opt-out. No effort is attached: `disabled` combined + // with effort `xhigh`/`max` is rejected with a 400. + fields.insert( + "thinking".to_string(), + Document::from(HashMap::from([( + "type".to_string(), + Document::String("disabled".to_string()), + )])), + ); + } + } + fields } #[derive(Debug)] @@ -255,3 +298,65 @@ pub enum BedrockError { #[error(transparent)] Other(#[from] anyhow::Error), } + +#[cfg(test)] +mod tests { + use super::*; + + fn string_field<'a>(document: &'a Document, key: &str) -> Option<&'a str> { + match document { + Document::Object(map) => match map.get(key) { + Some(Document::String(value)) => Some(value.as_str()), + _ => None, + }, + _ => None, + } + } + + #[test] + fn test_adaptive_thinking_serializes_effort_in_output_config() { + let fields = thinking_request_fields(&Thinking::Adaptive { + effort: BedrockAdaptiveThinkingEffort::XHigh, + }); + + let thinking = fields.get("thinking").expect("thinking field"); + assert_eq!(string_field(thinking, "type"), Some("adaptive")); + assert_eq!(string_field(thinking, "display"), Some("summarized")); + + let output_config = fields.get("output_config").expect("output_config field"); + assert_eq!(string_field(output_config, "effort"), Some("xhigh")); + } + + #[test] + fn test_disabled_thinking_serializes_opt_out_without_effort() { + let fields = thinking_request_fields(&Thinking::Disabled); + + let thinking = fields.get("thinking").expect("thinking field"); + assert_eq!(string_field(thinking, "type"), Some("disabled")); + // `disabled` combined with an effort of `xhigh`/`max` is a 400, so no + // output_config may accompany the opt-out. + assert!(!fields.contains_key("output_config")); + } + + #[test] + fn test_enabled_thinking_serializes_budget_tokens() { + let fields = thinking_request_fields(&Thinking::Enabled { + budget_tokens: Some(4_096), + }); + + let thinking = fields.get("thinking").expect("thinking field"); + assert_eq!(string_field(thinking, "type"), Some("enabled")); + match thinking { + Document::Object(map) => assert_eq!( + map.get("budget_tokens"), + Some(&Document::Number(AwsNumber::PosInt(4_096))) + ), + _ => panic!("thinking field should be an object"), + } + + let fields = thinking_request_fields(&Thinking::Enabled { + budget_tokens: None, + }); + assert!(fields.is_empty()); + } +} diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index a2f299a3c9ed10..75a3096986b2f6 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -46,39 +46,36 @@ pub struct BedrockModelCacheConfiguration { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] -pub enum Model { +pub enum ConverseModel { // Anthropic Claude 4+ models - #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] - ClaudeHaiku4_5, #[serde( - rename = "claude-sonnet-4", - alias = "claude-sonnet-4-latest", - alias = "claude-sonnet-4-thinking", - alias = "claude-sonnet-4-thinking-latest" + rename = "claude-fable-5", + alias = "claude-fable-5-latest", + alias = "claude-fable-5-thinking", + alias = "claude-fable-5-thinking-latest" )] - ClaudeSonnet4, - #[default] + ClaudeFable5, #[serde( - rename = "claude-sonnet-4-5", - alias = "claude-sonnet-4-5-latest", - alias = "claude-sonnet-4-5-thinking", - alias = "claude-sonnet-4-5-thinking-latest" + rename = "claude-opus-5", + alias = "claude-opus-5-latest", + alias = "claude-opus-5-thinking", + alias = "claude-opus-5-thinking-latest" )] - ClaudeSonnet4_5, + ClaudeOpus5, #[serde( - rename = "claude-opus-4-1", - alias = "claude-opus-4-1-latest", - alias = "claude-opus-4-1-thinking", - alias = "claude-opus-4-1-thinking-latest" + rename = "claude-opus-4-8", + alias = "claude-opus-4-8-latest", + alias = "claude-opus-4-8-thinking", + alias = "claude-opus-4-8-thinking-latest" )] - ClaudeOpus4_1, + ClaudeOpus4_8, #[serde( - rename = "claude-opus-4-5", - alias = "claude-opus-4-5-latest", - alias = "claude-opus-4-5-thinking", - alias = "claude-opus-4-5-thinking-latest" + rename = "claude-opus-4-7", + alias = "claude-opus-4-7-latest", + alias = "claude-opus-4-7-thinking", + alias = "claude-opus-4-7-thinking-latest" )] - ClaudeOpus4_5, + ClaudeOpus4_7, #[serde( rename = "claude-opus-4-6", alias = "claude-opus-4-6-latest", @@ -87,19 +84,26 @@ pub enum Model { )] ClaudeOpus4_6, #[serde( - rename = "claude-opus-4-7", - alias = "claude-opus-4-7-latest", - alias = "claude-opus-4-7-thinking", - alias = "claude-opus-4-7-thinking-latest" + rename = "claude-opus-4-5", + alias = "claude-opus-4-5-latest", + alias = "claude-opus-4-5-thinking", + alias = "claude-opus-4-5-thinking-latest" )] - ClaudeOpus4_7, + ClaudeOpus4_5, #[serde( - rename = "claude-opus-4-8", - alias = "claude-opus-4-8-latest", - alias = "claude-opus-4-8-thinking", - alias = "claude-opus-4-8-thinking-latest" + rename = "claude-opus-4-1", + alias = "claude-opus-4-1-latest", + alias = "claude-opus-4-1-thinking", + alias = "claude-opus-4-1-thinking-latest" )] - ClaudeOpus4_8, + ClaudeOpus4_1, + #[serde( + rename = "claude-sonnet-5", + alias = "claude-sonnet-5-latest", + alias = "claude-sonnet-5-thinking", + alias = "claude-sonnet-5-thinking-latest" + )] + ClaudeSonnet5, #[serde( rename = "claude-sonnet-4-6", alias = "claude-sonnet-4-6-latest", @@ -107,6 +111,23 @@ pub enum Model { alias = "claude-sonnet-4-6-thinking-latest" )] ClaudeSonnet4_6, + #[default] + #[serde( + rename = "claude-sonnet-4-5", + alias = "claude-sonnet-4-5-latest", + alias = "claude-sonnet-4-5-thinking", + alias = "claude-sonnet-4-5-thinking-latest" + )] + ClaudeSonnet4_5, + #[serde( + rename = "claude-sonnet-4", + alias = "claude-sonnet-4-latest", + alias = "claude-sonnet-4-thinking", + alias = "claude-sonnet-4-thinking-latest" + )] + ClaudeSonnet4, + #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] + ClaudeHaiku4_5, // Meta Llama 4 models #[serde(rename = "llama-4-scout-17b")] @@ -213,13 +234,17 @@ pub enum Model { }, } -impl Model { +impl ConverseModel { pub fn default_fast(_region: &str) -> Self { Self::ClaudeHaiku4_5 } pub fn from_id(id: &str) -> anyhow::Result { - if id.starts_with("claude-opus-4-8") { + if id.starts_with("claude-fable-5") { + Ok(Self::ClaudeFable5) + } else if id.starts_with("claude-opus-5") { + Ok(Self::ClaudeOpus5) + } else if id.starts_with("claude-opus-4-8") { Ok(Self::ClaudeOpus4_8) } else if id.starts_with("claude-opus-4-7") { Ok(Self::ClaudeOpus4_7) @@ -229,6 +254,8 @@ impl Model { Ok(Self::ClaudeOpus4_5) } else if id.starts_with("claude-opus-4-1") { Ok(Self::ClaudeOpus4_1) + } else if id.starts_with("claude-sonnet-5") { + Ok(Self::ClaudeSonnet5) } else if id.starts_with("claude-sonnet-4-6") { Ok(Self::ClaudeSonnet4_6) } else if id.starts_with("claude-sonnet-4-5") { @@ -244,15 +271,18 @@ impl Model { pub fn id(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "claude-haiku-4-5", - Self::ClaudeSonnet4 => "claude-sonnet-4", - Self::ClaudeSonnet4_5 => "claude-sonnet-4-5", - Self::ClaudeOpus4_1 => "claude-opus-4-1", - Self::ClaudeOpus4_5 => "claude-opus-4-5", - Self::ClaudeOpus4_6 => "claude-opus-4-6", - Self::ClaudeOpus4_7 => "claude-opus-4-7", + Self::ClaudeFable5 => "claude-fable-5", + Self::ClaudeOpus5 => "claude-opus-5", Self::ClaudeOpus4_8 => "claude-opus-4-8", + Self::ClaudeOpus4_7 => "claude-opus-4-7", + Self::ClaudeOpus4_6 => "claude-opus-4-6", + Self::ClaudeOpus4_5 => "claude-opus-4-5", + Self::ClaudeOpus4_1 => "claude-opus-4-1", + Self::ClaudeSonnet5 => "claude-sonnet-5", Self::ClaudeSonnet4_6 => "claude-sonnet-4-6", + Self::ClaudeSonnet4_5 => "claude-sonnet-4-5", + Self::ClaudeSonnet4 => "claude-sonnet-4", + Self::ClaudeHaiku4_5 => "claude-haiku-4-5", Self::Llama4Scout17B => "llama-4-scout-17b", Self::Llama4Maverick17B => "llama-4-maverick-17b", Self::Gemma3_4B => "gemma-3-4b", @@ -295,15 +325,18 @@ impl Model { pub fn request_id(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "anthropic.claude-haiku-4-5-20251001-v1:0", - Self::ClaudeSonnet4 => "anthropic.claude-sonnet-4-20250514-v1:0", - Self::ClaudeSonnet4_5 => "anthropic.claude-sonnet-4-5-20250929-v1:0", - Self::ClaudeOpus4_1 => "anthropic.claude-opus-4-1-20250805-v1:0", - Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0", - Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1", - Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", + Self::ClaudeFable5 => "anthropic.claude-fable-5", + Self::ClaudeOpus5 => "anthropic.claude-opus-5", Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8", + Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", + Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1", + Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0", + Self::ClaudeOpus4_1 => "anthropic.claude-opus-4-1-20250805-v1:0", + Self::ClaudeSonnet5 => "anthropic.claude-sonnet-5", Self::ClaudeSonnet4_6 => "anthropic.claude-sonnet-4-6", + Self::ClaudeSonnet4_5 => "anthropic.claude-sonnet-4-5-20250929-v1:0", + Self::ClaudeSonnet4 => "anthropic.claude-sonnet-4-20250514-v1:0", + Self::ClaudeHaiku4_5 => "anthropic.claude-haiku-4-5-20251001-v1:0", Self::Llama4Scout17B => "meta.llama4-scout-17b-instruct-v1:0", Self::Llama4Maverick17B => "meta.llama4-maverick-17b-instruct-v1:0", Self::Gemma3_4B => "google.gemma-3-4b-it", @@ -346,15 +379,18 @@ impl Model { pub fn display_name(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", - Self::ClaudeSonnet4 => "Claude Sonnet 4", - Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", - Self::ClaudeOpus4_1 => "Claude Opus 4.1", - Self::ClaudeOpus4_5 => "Claude Opus 4.5", - Self::ClaudeOpus4_6 => "Claude Opus 4.6", - Self::ClaudeOpus4_7 => "Claude Opus 4.7", + Self::ClaudeFable5 => "Claude Fable 5", + Self::ClaudeOpus5 => "Claude Opus 5", Self::ClaudeOpus4_8 => "Claude Opus 4.8", + Self::ClaudeOpus4_7 => "Claude Opus 4.7", + Self::ClaudeOpus4_6 => "Claude Opus 4.6", + Self::ClaudeOpus4_5 => "Claude Opus 4.5", + Self::ClaudeOpus4_1 => "Claude Opus 4.1", + Self::ClaudeSonnet5 => "Claude Sonnet 5", Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", + Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", + Self::ClaudeSonnet4 => "Claude Sonnet 4", + Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", Self::Llama4Scout17B => "Llama 4 Scout 17B", Self::Llama4Maverick17B => "Llama 4 Maverick 17B", Self::Gemma3_4B => "Gemma 3 4B", @@ -399,14 +435,17 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => 1_000_000, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 1_000_000, Self::ClaudeOpus4_1 => 200_000, Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000, Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000, @@ -434,13 +473,18 @@ impl Model { pub fn max_output_tokens(&self) -> u64 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 + Self::ClaudeFable5 + | Self::ClaudeOpus5 + | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet5 => 128_000, + Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeSonnet4_6 => 64_000, + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 64_000, Self::ClaudeOpus4_1 => 32_000, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 => 128_000, Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::Gemma3_4B @@ -472,15 +516,18 @@ impl Model { pub fn default_temperature(&self) -> f32 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => 1.0, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 1.0, Self::Custom { default_temperature, .. @@ -491,15 +538,18 @@ impl Model { pub fn supports_tool_use(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true, Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true, Self::Devstral2_123B | Self::Ministral14B => true, @@ -523,15 +573,18 @@ impl Model { pub fn supports_images(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::NovaLite | Self::NovaPro => true, Self::PixtralLarge => true, Self::Qwen3VL235B => true, @@ -542,15 +595,18 @@ impl Model { pub fn supports_caching(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::Custom { cache_configuration, .. @@ -562,27 +618,39 @@ impl Model { pub fn supports_thinking(&self) -> bool { matches!( self, - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 ) } pub fn supports_adaptive_thinking(&self) -> bool { matches!( self, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 + Self::ClaudeFable5 + | Self::ClaudeOpus5 + | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet5 + | Self::ClaudeSonnet4_6 ) } pub fn supports_xhigh_adaptive_thinking(&self) -> bool { - matches!(self, Self::ClaudeOpus4_8) + matches!( + self, + Self::ClaudeFable5 | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet5 + ) } pub fn thinking_mode(&self) -> BedrockModelMode { @@ -608,14 +676,17 @@ impl Model { let supports_global = matches!( self, - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite ); @@ -669,14 +740,17 @@ impl Model { // Global inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite, "global", ) => Ok(format!("{}.{}", region_group, model_id)), @@ -686,15 +760,18 @@ impl Model { // US region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 + | Self::ClaudeOpus5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::NovaLite @@ -709,15 +786,27 @@ impl Model { // Canada region inference profiles (Self::NovaLite, "ca") => Ok(format!("{}.{}", region_group, model_id)), + // Canada has no Claude-specific `ca.` profiles. AWS instead lists + // ca-central-1 and ca-west-1 as source regions of the US geo + // profiles for these models, which keep data within US and Canada + // regions: + // + // + // + (Self::ClaudeOpus5 | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7, "ca") => { + Ok(format!("us.{}", model_id)) + } + // EU region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeOpus5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::NovaLite | Self::NovaPro | Self::Nova2Lite, @@ -726,61 +815,325 @@ impl Model { // Australia region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeOpus5 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeHaiku4_5, "au", ) => Ok(format!("{}.{}", region_group, model_id)), - // Japan region inference profiles + // Japan region inference profiles. Claude Opus 5 is deliberately + // absent: its model card lists only `us.`/`eu.`/`au.` geo profiles + // (plus `global.`): + // ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4_5 + Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite, "jp", ) => Ok(format!("{}.{}", region_group, model_id)), // APAC region inference profiles (other than AU/JP) ( - Self::ClaudeHaiku4_5 + Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 + | Self::ClaudeHaiku4_5 | Self::NovaLite | Self::NovaPro | Self::Nova2Lite, "apac", ) => Ok(format!("{}.{}", region_group, model_id)), + (Self::ClaudeFable5 | Self::ClaudeSonnet5, _) => Ok(format!("global.{}", model_id)), + // Default: use model ID directly _ => Ok(model_id.into()), } } } +/// The wire protocol used to talk to a [`MantleModel`] on the `bedrock-mantle` endpoint. +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] +pub enum MantleProtocol { + /// The OpenAI Chat Completions API (`/chat/completions`). + #[default] + ChatCompletions, + /// The OpenAI Responses API (`/responses`). + Responses, +} + +/// Models only reachable through the `bedrock-mantle` endpoint's +/// OpenAI-compatible APIs, i.e. with no `Converse`/`Invoke` support on +/// `bedrock-runtime`. +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, EnumIter)] +pub enum MantleModel { + #[serde(rename = "gpt-5.6-sol")] + Gpt5_6Sol, + #[serde(rename = "gpt-5.6-terra")] + Gpt5_6Terra, + #[serde(rename = "gpt-5.6-luna")] + Gpt5_6Luna, + #[serde(rename = "gpt-5.5")] + Gpt5_5, + #[serde(rename = "gpt-5.4")] + Gpt5_4, + #[serde(rename = "grok-4.3")] + Grok4_3, + #[serde(rename = "custom")] + Custom { + name: String, + display_name: Option, + max_tokens: u64, + max_output_tokens: Option, + protocol: MantleProtocol, + supports_tools: bool, + supports_images: bool, + supports_thinking: bool, + }, +} + +impl MantleModel { + /// The model id Zed uses internally (also used as the `name` in settings). + pub fn id(&self) -> &str { + match self { + Self::Gpt5_6Sol => "gpt-5.6-sol", + Self::Gpt5_6Terra => "gpt-5.6-terra", + Self::Gpt5_6Luna => "gpt-5.6-luna", + Self::Gpt5_5 => "gpt-5.5", + Self::Gpt5_4 => "gpt-5.4", + Self::Grok4_3 => "grok-4.3", + Self::Custom { name, .. } => name, + } + } + + /// The model id as expected in Bedrock Mantle request bodies, e.g. `openai.gpt-5.5`. + pub fn request_id(&self) -> &str { + match self { + Self::Gpt5_6Sol => "openai.gpt-5.6-sol", + Self::Gpt5_6Terra => "openai.gpt-5.6-terra", + Self::Gpt5_6Luna => "openai.gpt-5.6-luna", + Self::Gpt5_5 => "openai.gpt-5.5", + Self::Gpt5_4 => "openai.gpt-5.4", + Self::Grok4_3 => "xai.grok-4.3", + Self::Custom { name, .. } => name, + } + } + + pub fn display_name(&self) -> &str { + match self { + Self::Gpt5_6Sol => "GPT-5.6 Sol", + Self::Gpt5_6Terra => "GPT-5.6 Terra", + Self::Gpt5_6Luna => "GPT-5.6 Luna", + Self::Gpt5_5 => "GPT-5.5", + Self::Gpt5_4 => "GPT-5.4", + Self::Grok4_3 => "Grok 4.3", + Self::Custom { + display_name, name, .. + } => display_name.as_deref().unwrap_or(name.as_str()), + } + } + + /// Which OpenAI-compatible API this model must be called through. + pub fn protocol(&self) -> MantleProtocol { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Grok4_3 => MantleProtocol::Responses, + Self::Custom { protocol, .. } => *protocol, + } + } + + pub fn max_token_count(&self) -> u64 { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 => 272_000, + Self::Grok4_3 => 1_000_000, + Self::Custom { max_tokens, .. } => *max_tokens, + } + } + + pub fn max_output_tokens(&self) -> u64 { + match self { + // AWS doesn't document a hard cap for the GPT-5.x models on Mantle. + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 => 128_000, + Self::Grok4_3 => 131_072, + Self::Custom { + max_output_tokens, .. + } => max_output_tokens.unwrap_or(4_096), + } + } + + pub fn supports_tools(&self) -> bool { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Grok4_3 => true, + Self::Custom { supports_tools, .. } => *supports_tools, + } + } + + pub fn supports_images(&self) -> bool { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Grok4_3 => true, + Self::Custom { + supports_images, .. + } => *supports_images, + } + } + + pub fn supports_thinking(&self) -> bool { + match self { + Self::Gpt5_6Sol + | Self::Gpt5_6Terra + | Self::Gpt5_6Luna + | Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Grok4_3 => true, + Self::Custom { + supports_thinking, .. + } => *supports_thinking, + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_builtin_mantle_models_use_responses_protocol() { + assert_eq!(MantleModel::Gpt5_6Sol.protocol(), MantleProtocol::Responses); + assert_eq!( + MantleModel::Gpt5_6Terra.protocol(), + MantleProtocol::Responses + ); + assert_eq!( + MantleModel::Gpt5_6Luna.protocol(), + MantleProtocol::Responses + ); + assert_eq!(MantleModel::Gpt5_5.protocol(), MantleProtocol::Responses); + assert_eq!(MantleModel::Gpt5_4.protocol(), MantleProtocol::Responses); + assert_eq!(MantleModel::Grok4_3.protocol(), MantleProtocol::Responses); + } + + #[test] + fn test_gpt_5_6_mantle_model_metadata() { + // Values mirror the AWS Bedrock model cards for GPT-5.6 Sol/Terra/Luna, + // which are served only through the `bedrock-mantle` Responses API. + for (model, id, request_id, display_name) in [ + ( + MantleModel::Gpt5_6Sol, + "gpt-5.6-sol", + "openai.gpt-5.6-sol", + "GPT-5.6 Sol", + ), + ( + MantleModel::Gpt5_6Terra, + "gpt-5.6-terra", + "openai.gpt-5.6-terra", + "GPT-5.6 Terra", + ), + ( + MantleModel::Gpt5_6Luna, + "gpt-5.6-luna", + "openai.gpt-5.6-luna", + "GPT-5.6 Luna", + ), + ] { + assert_eq!(model.id(), id); + assert_eq!(model.request_id(), request_id); + assert_eq!(model.display_name(), display_name); + assert_eq!(model.max_token_count(), 272_000); + assert!(model.supports_tools()); + assert!(model.supports_images()); + assert!(model.supports_thinking()); + } + } + + #[test] + fn test_builtin_mantle_models_have_unique_ids_and_sane_token_limits() { + use std::collections::HashSet; + use strum::IntoEnumIterator; + + let mut ids = HashSet::new(); + let mut request_ids = HashSet::new(); + for model in + MantleModel::iter().filter(|model| !matches!(model, MantleModel::Custom { .. })) + { + assert!( + ids.insert(model.id().to_string()), + "duplicate MantleModel id: {}", + model.id() + ); + assert!( + request_ids.insert(model.request_id().to_string()), + "duplicate MantleModel request_id: {}", + model.request_id() + ); + assert!( + model.max_output_tokens() <= model.max_token_count(), + "{} has max_output_tokens ({}) greater than max_token_count ({})", + model.id(), + model.max_output_tokens(), + model.max_token_count() + ); + } + } + #[test] fn test_us_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-east-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?, + ConverseModel::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?, "us.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::NovaPro.cross_region_inference_id("us-east-2", false)?, + ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-sonnet-5" + ); + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-opus-5" + ); + assert_eq!( + ConverseModel::NovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" ); assert_eq!( - Model::DeepSeekR1.cross_region_inference_id("us-east-1", false)?, + ConverseModel::DeepSeekR1.cross_region_inference_id("us-east-1", false)?, "us.deepseek.r1-v1:0" ); Ok(()) @@ -789,40 +1142,65 @@ mod tests { #[test] fn test_eu_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::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", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::NovaLite.cross_region_inference_id("eu-north-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("eu-north-1", false)?, "eu.amazon.nova-lite-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-8" ); + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("eu-west-1", false)?, + "eu.anthropic.claude-opus-5" + ); + Ok(()) + } + + #[test] + fn test_inference_profile_only_models_fall_back_to_global() -> anyhow::Result<()> { + assert_eq!( + ConverseModel::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?, + "global.anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.cross_region_inference_id("eu-west-1", false)?, + "global.anthropic.claude-sonnet-5" + ); + assert_eq!( + ConverseModel::ClaudeFable5.cross_region_inference_id("ap-southeast-2", false)?, + "global.anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.cross_region_inference_id("ap-northeast-1", false)?, + "global.anthropic.claude-sonnet-5" + ); Ok(()) } #[test] fn test_apac_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-south-1", false)?, "apac.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::NovaLite.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("ap-south-1", false)?, "apac.amazon.nova-lite-v1:0" ); Ok(()) @@ -831,40 +1209,59 @@ mod tests { #[test] fn test_au_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeHaiku4_5.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-haiku-4-5-20251001-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-southeast-4", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-southeast-4", false)?, "au.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-8" ); + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("ap-southeast-2", false)?, + "au.anthropic.claude-opus-5" + ); Ok(()) } #[test] fn test_jp_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?, "jp.anthropic.claude-haiku-4-5-20251001-v1:0" ); + // Claude Opus 5 has no `jp.` geo profile, so it falls through to the + // bare model id (which fails at request time with a region hint) + // rather than fabricating an undocumented profile. + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("ap-northeast-1", false)?, + "anthropic.claude-opus-5" + ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ap-northeast-1", false)?, + "jp.anthropic.claude-opus-4-8" + ); + assert_eq!( + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("ap-northeast-3", false)?, + "jp.anthropic.claude-opus-4-7" + ); + assert_eq!( + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::Nova2Lite.cross_region_inference_id("ap-northeast-1", false)?, + ConverseModel::Nova2Lite.cross_region_inference_id("ap-northeast-1", false)?, "jp.amazon.nova-2-lite-v1:0" ); Ok(()) @@ -873,20 +1270,34 @@ mod tests { #[test] fn test_ca_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::NovaLite.cross_region_inference_id("ca-central-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("ca-central-1", false)?, "ca.amazon.nova-lite-v1:0" ); + // Canadian regions are source regions of the US geo profile for + // recent Opus models; there are no `ca.` Claude profiles. + assert_eq!( + ConverseModel::ClaudeOpus5.cross_region_inference_id("ca-central-1", false)?, + "us.anthropic.claude-opus-5" + ); + assert_eq!( + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ca-west-1", false)?, + "us.anthropic.claude-opus-4-8" + ); + assert_eq!( + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("ca-central-1", false)?, + "us.anthropic.claude-opus-4-7" + ); Ok(()) } #[test] fn test_gov_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-gov-east-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-east-1", false)?, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" ); Ok(()) @@ -895,37 +1306,49 @@ mod tests { #[test] fn test_global_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?, + ConverseModel::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)?, + ConverseModel::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)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, "global.anthropic.claude-haiku-4-5-20251001-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-8" ); assert_eq!( - Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-opus-5" + ); + assert_eq!( + ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-sonnet-5" + ); + assert_eq!( + ConverseModel::Nova2Lite.cross_region_inference_id("us-east-1", true)?, "global.amazon.nova-2-lite-v1:0" ); // Models without global support fall back to regional assert_eq!( - Model::NovaPro.cross_region_inference_id("us-east-1", true)?, + ConverseModel::NovaPro.cross_region_inference_id("us-east-1", true)?, "us.amazon.nova-pro-v1:0" ); Ok(()) @@ -935,27 +1358,27 @@ mod tests { fn test_models_without_cross_region() -> anyhow::Result<()> { // Models without cross-region support return their request_id directly assert_eq!( - Model::Gemma3_4B.cross_region_inference_id("us-east-1", false)?, + ConverseModel::Gemma3_4B.cross_region_inference_id("us-east-1", false)?, "google.gemma-3-4b-it" ); assert_eq!( - Model::MistralLarge3.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::MistralLarge3.cross_region_inference_id("eu-west-1", false)?, "mistral.mistral-large-3-675b-instruct" ); assert_eq!( - Model::Qwen3VL235B.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::Qwen3VL235B.cross_region_inference_id("ap-south-1", false)?, "qwen.qwen3-vl-235b-a22b" ); assert_eq!( - Model::GptOss120B.cross_region_inference_id("us-east-1", false)?, + ConverseModel::GptOss120B.cross_region_inference_id("us-east-1", false)?, "openai.gpt-oss-120b-1:0" ); assert_eq!( - Model::MiniMaxM2.cross_region_inference_id("us-east-1", false)?, + ConverseModel::MiniMaxM2.cross_region_inference_id("us-east-1", false)?, "minimax.minimax-m2" ); assert_eq!( - Model::KimiK2Thinking.cross_region_inference_id("us-east-1", false)?, + ConverseModel::KimiK2Thinking.cross_region_inference_id("us-east-1", false)?, "moonshot.kimi-k2-thinking" ); Ok(()) @@ -963,7 +1386,7 @@ mod tests { #[test] fn test_custom_model_inference_ids() -> anyhow::Result<()> { - let custom_model = Model::Custom { + let custom_model = ConverseModel::Custom { name: "custom.my-model-v1:0".to_string(), max_tokens: 100000, display_name: Some("My Custom Model".to_string()), @@ -985,58 +1408,104 @@ mod tests { #[test] fn test_friendly_id_vs_request_id() { - assert_eq!(Model::ClaudeSonnet4_5.id(), "claude-sonnet-4-5"); - assert_eq!(Model::NovaLite.id(), "nova-lite"); - assert_eq!(Model::DeepSeekR1.id(), "deepseek-r1"); - assert_eq!(Model::Llama4Scout17B.id(), "llama-4-scout-17b"); + assert_eq!(ConverseModel::ClaudeSonnet4_5.id(), "claude-sonnet-4-5"); + assert_eq!(ConverseModel::NovaLite.id(), "nova-lite"); + assert_eq!(ConverseModel::DeepSeekR1.id(), "deepseek-r1"); + assert_eq!(ConverseModel::Llama4Scout17B.id(), "llama-4-scout-17b"); + assert_eq!(ConverseModel::ClaudeFable5.id(), "claude-fable-5"); + assert_eq!(ConverseModel::ClaudeOpus5.id(), "claude-opus-5"); + assert_eq!(ConverseModel::ClaudeSonnet5.id(), "claude-sonnet-5"); assert_eq!( - Model::ClaudeSonnet4_5.request_id(), + ConverseModel::ClaudeSonnet4_5.request_id(), "anthropic.claude-sonnet-4-5-20250929-v1:0" ); - assert_eq!(Model::NovaLite.request_id(), "amazon.nova-lite-v1:0"); - assert_eq!(Model::DeepSeekR1.request_id(), "deepseek.r1-v1:0"); assert_eq!( - Model::Llama4Scout17B.request_id(), + ConverseModel::NovaLite.request_id(), + "amazon.nova-lite-v1:0" + ); + assert_eq!(ConverseModel::DeepSeekR1.request_id(), "deepseek.r1-v1:0"); + assert_eq!( + ConverseModel::Llama4Scout17B.request_id(), "meta.llama4-scout-17b-instruct-v1:0" ); + assert_eq!( + ConverseModel::ClaudeFable5.request_id(), + "anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeOpus5.request_id(), + "anthropic.claude-opus-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.request_id(), + "anthropic.claude-sonnet-5" + ); // Thinking aliases deserialize to the same model - assert_eq!(Model::ClaudeSonnet4.id(), "claude-sonnet-4"); + assert_eq!(ConverseModel::ClaudeSonnet4.id(), "claude-sonnet-4"); assert_eq!( - Model::from_id("claude-sonnet-4-thinking").unwrap().id(), + ConverseModel::from_id("claude-sonnet-4-thinking") + .unwrap() + .id(), "claude-sonnet-4" ); + assert_eq!( + ConverseModel::from_id("claude-fable-5-thinking") + .unwrap() + .id(), + "claude-fable-5" + ); + assert_eq!( + ConverseModel::from_id("claude-opus-5-thinking") + .unwrap() + .id(), + "claude-opus-5" + ); + assert_eq!( + ConverseModel::from_id("claude-sonnet-5-thinking") + .unwrap() + .id(), + "claude-sonnet-5" + ); } #[test] fn test_thinking_modes() { - assert!(Model::ClaudeHaiku4_5.supports_thinking()); - assert!(Model::ClaudeSonnet4.supports_thinking()); - assert!(Model::ClaudeSonnet4_5.supports_thinking()); - assert!(Model::ClaudeOpus4_6.supports_thinking()); - - assert!(!Model::ClaudeSonnet4.supports_adaptive_thinking()); - assert!(Model::ClaudeOpus4_6.supports_adaptive_thinking()); - assert!(Model::ClaudeSonnet4_6.supports_adaptive_thinking()); - assert!(!Model::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); - assert!(Model::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeHaiku4_5.supports_thinking()); + assert!(ConverseModel::ClaudeSonnet4.supports_thinking()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_thinking()); + assert!(ConverseModel::ClaudeOpus4_6.supports_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_thinking()); + assert!(ConverseModel::ClaudeOpus5.supports_thinking()); + + assert!(!ConverseModel::ClaudeSonnet4.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus4_6.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet4_6.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus5.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet5.supports_adaptive_thinking()); + assert!(!ConverseModel::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh"); assert_eq!( - Model::ClaudeSonnet4.thinking_mode(), + ConverseModel::ClaudeSonnet4.thinking_mode(), BedrockModelMode::Thinking { budget_tokens: Some(4096) } ); assert_eq!( - Model::ClaudeOpus4_6.thinking_mode(), + ConverseModel::ClaudeOpus4_6.thinking_mode(), BedrockModelMode::AdaptiveThinking { effort: BedrockAdaptiveThinkingEffort::High } ); assert_eq!( - Model::ClaudeHaiku4_5.thinking_mode(), + ConverseModel::ClaudeHaiku4_5.thinking_mode(), BedrockModelMode::Thinking { budget_tokens: Some(4096) } @@ -1045,38 +1514,46 @@ mod tests { #[test] fn test_max_token_count() { - assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000); - assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000); - assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000); - assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeSonnet4_5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeOpus4_6.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeFable5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeOpus5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeSonnet5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::Llama4Scout17B.max_token_count(), 128_000); + assert_eq!(ConverseModel::NovaPremier.max_token_count(), 1_000_000); } #[test] fn test_max_output_tokens() { - assert_eq!(Model::ClaudeSonnet4_5.max_output_tokens(), 64_000); - assert_eq!(Model::ClaudeOpus4_6.max_output_tokens(), 128_000); - assert_eq!(Model::ClaudeOpus4_1.max_output_tokens(), 32_000); - assert_eq!(Model::Gemma3_4B.max_output_tokens(), 8_192); + assert_eq!(ConverseModel::ClaudeSonnet4_5.max_output_tokens(), 64_000); + assert_eq!(ConverseModel::ClaudeOpus4_6.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeFable5.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeOpus5.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeSonnet5.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeOpus4_1.max_output_tokens(), 32_000); + assert_eq!(ConverseModel::Gemma3_4B.max_output_tokens(), 8_192); } #[test] fn test_supports_tool_use() { - assert!(Model::ClaudeSonnet4_5.supports_tool_use()); - assert!(Model::NovaPro.supports_tool_use()); - assert!(Model::MistralLarge3.supports_tool_use()); - assert!(!Model::Gemma3_4B.supports_tool_use()); - assert!(Model::Qwen3_32B.supports_tool_use()); - assert!(Model::MiniMaxM2.supports_tool_use()); - assert!(Model::KimiK2_5.supports_tool_use()); - assert!(Model::DeepSeekR1.supports_tool_use()); - assert!(!Model::Llama4Scout17B.supports_tool_use()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_tool_use()); + assert!(ConverseModel::ClaudeFable5.supports_tool_use()); + assert!(ConverseModel::NovaPro.supports_tool_use()); + assert!(ConverseModel::MistralLarge3.supports_tool_use()); + assert!(!ConverseModel::Gemma3_4B.supports_tool_use()); + assert!(ConverseModel::Qwen3_32B.supports_tool_use()); + assert!(ConverseModel::MiniMaxM2.supports_tool_use()); + assert!(ConverseModel::KimiK2_5.supports_tool_use()); + assert!(ConverseModel::DeepSeekR1.supports_tool_use()); + assert!(!ConverseModel::Llama4Scout17B.supports_tool_use()); } #[test] fn test_supports_caching() { - assert!(Model::ClaudeSonnet4_5.supports_caching()); - assert!(Model::ClaudeOpus4_6.supports_caching()); - assert!(!Model::Llama4Scout17B.supports_caching()); - assert!(!Model::NovaPro.supports_caching()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_caching()); + assert!(ConverseModel::ClaudeOpus4_6.supports_caching()); + assert!(ConverseModel::ClaudeFable5.supports_caching()); + assert!(!ConverseModel::Llama4Scout17B.supports_caching()); + assert!(!ConverseModel::NovaPro.supports_caching()); } } diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index c300ace11ae1d9..b73c64ff61eacc 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1,5 +1,5 @@ use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; -use imara_diff::{Algorithm, Sink, intern::InternedInput, sources::lines_with_terminator}; +use imara_diff::{Algorithm, Diff, InternedInput, sources::lines}; use language::{ Capability, DiffOptions, Language, LanguageName, LanguageRegistry, language_settings::LanguageSettings, word_diff_ranges, @@ -122,11 +122,37 @@ struct InternalDiffHunk { } #[derive(Debug, Clone, PartialEq, Eq)] -struct PendingHunk { +pub struct PendingHunk { buffer_range: Range, diff_base_byte_range: Range, buffer_version: clock::Global, - new_status: DiffHunkSecondaryStatus, + sense: PendingSense, +} + +impl PendingHunk { + pub fn new( + buffer_range: Range, + diff_base_byte_range: Range, + buffer_version: clock::Global, + sense: PendingSense, + ) -> Self { + Self { + buffer_range, + diff_base_byte_range, + buffer_version, + sense, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingSense { + /// Override the secondary status of the matched hunk (used by the + /// uncommitted diff to show a hunk as staging/unstaging in place). + SetSecondaryStatus { stage: bool }, + /// Suppress the matched hunk entirely (used by the unstaged/staged diffs so + /// that a hunk disappears the moment it is staged/unstaged). + Suppress, } #[derive(Debug, Clone)] @@ -294,6 +320,56 @@ impl BufferDiffSnapshot { self.hunks_intersecting_range_impl(filter, buffer, unstaged_counterpart) } + /// Like [`hunks_intersecting_range`], but ignores optimistic pending hunks + /// (both secondary-status overrides and suppressions) and does not compute a + /// secondary status. + pub fn raw_hunks_intersecting_range<'a>( + &'a self, + range: Range, + buffer: &'a text::BufferSnapshot, + ) -> impl 'a + Iterator { + let range = range.to_offset(buffer); + let filter = move |summary: &DiffHunkSummary| { + let summary_range = summary.buffer_range.to_offset(buffer); + !(summary_range.end < range.start) && !(summary_range.start > range.end) + }; + self.hunks + .filter::<_, DiffHunkSummary>(buffer, filter) + .map(move |hunk| { + let buffer_range = hunk.buffer_range.clone(); + DiffHunk { + range: buffer_range.to_point(buffer), + diff_base_byte_range: hunk.diff_base_byte_range.clone(), + buffer_range, + secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, + base_word_diffs: hunk.base_word_diffs.clone(), + buffer_word_diffs: hunk.buffer_word_diffs.clone(), + } + }) + } + + /// Maps a range in this diff's main buffer to the range it covers in the + /// base text, expanding to whole hunks wherever the range endpoints fall + /// inside or touch a hunk (`edit_for_old_position` is inclusive on both + /// boundaries, matching `raw_hunks_intersecting_range`). Used by the + /// index-write path to compute the index-coordinate footprint of a staging + /// operation; like the raw hunks, the mapping ignores optimistic pending + /// hunks. + pub fn base_text_range_for_buffer_range( + &self, + range: Range, + buffer: &text::BufferSnapshot, + ) -> Range { + let point_range = range.to_point(buffer); + let patch = self.patch_for_buffer_range(point_range.start..=point_range.end, buffer); + let start_point = patch.edit_for_old_position(point_range.start).new.start; + let end_point = patch.edit_for_old_position(point_range.end).new.end; + let base_text = self.base_text(); + let start = base_text.point_to_offset(start_point.min(base_text.max_point())); + let end = base_text.point_to_offset(end_point.min(base_text.max_point())); + start.min(end)..end + } + pub fn hunks_intersecting_range_rev<'a>( &'a self, range: Range, @@ -705,114 +781,80 @@ impl BufferDiffSnapshot { } impl BufferDiffSnapshot { - fn stage_or_unstage_hunks_impl( - &mut self, + // Compute the edits to apply to the index, and the resulting pending hunks, + // for a stage or unstage operation on the uncommitted diff. + pub fn compute_uncommitted_index_edits( + &self, unstaged_diff: &Self, stage: bool, hunks: &[DiffHunk], buffer: &text::BufferSnapshot, file_exists: bool, - ) -> Option { + ) -> (Option, Arc)>>, Vec) { let head_text = self .base_text_exists .then(|| self.base_text.as_rope().clone()); let index_text = unstaged_diff .base_text_exists .then(|| unstaged_diff.base_text.as_rope().clone()); + let sense = PendingSense::SetSecondaryStatus { stage }; + let version = buffer.version().clone(); // If the file doesn't exist in either HEAD or the index, then the // entire file must be either created or deleted in the index. let (index_text, head_text) = match (index_text, head_text) { (Some(index_text), Some(head_text)) if file_exists || !stage => (index_text, head_text), (index_text, head_text) => { - let (new_index_text, new_status) = if stage { + let index_len = index_text.as_ref().map_or(0, |rope| rope.len()); + let new_index_text: Option = if stage { log::debug!("stage all"); - ( - file_exists.then(|| buffer.as_rope().clone()), - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending, - ) + file_exists.then(|| buffer.as_rope().clone()) } else { log::debug!("unstage all"); - ( - head_text, - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending, - ) + head_text }; - let hunk = PendingHunk { - 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, - }; - self.pending_hunks = SumTree::from_item(hunk, buffer); - return new_index_text; + let pending = vec![PendingHunk::new( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + 0..index_len, + version, + sense, + )]; + let edits = + new_index_text.map(|rope| vec![(0..index_len, Arc::from(rope.to_string()))]); + return (edits, pending); } }; - let mut pending_hunks = SumTree::new(buffer); - let mut old_pending_hunks = self.pending_hunks.cursor::(buffer); - - // first, merge new hunks into pending_hunks - for DiffHunk { - buffer_range, - diff_base_byte_range, - secondary_status, - .. - } in hunks.iter().cloned() - { - let preceding_pending_hunks = old_pending_hunks.slice(&buffer_range.start, Bias::Left); - pending_hunks.append(preceding_pending_hunks, buffer); - - // Skip all overlapping or adjacent old pending hunks - while old_pending_hunks.item().is_some_and(|old_hunk| { - old_hunk - .buffer_range - .start - .cmp(&buffer_range.end, buffer) - .is_le() - }) { - old_pending_hunks.next(); - } - - if (stage && secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) - || (!stage && secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk) - { - continue; - } - - pending_hunks.push( - PendingHunk { - buffer_range, - diff_base_byte_range, - buffer_version: buffer.version().clone(), - new_status: if stage { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending - } else { - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending - }, - }, - buffer, - ); - } - // append the remainder - pending_hunks.append(old_pending_hunks.suffix(), buffer); - let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::(buffer); unstaged_hunk_cursor.next(); - // then, iterate over all pending hunks (both new ones and the existing ones) and compute the edits let mut prev_unstaged_hunk_buffer_end = 0; let mut prev_unstaged_hunk_base_text_end = 0; - let mut edits = Vec::<(Range, String)>::new(); - let mut pending_hunks_iter = pending_hunks.iter().cloned().peekable(); - while let Some(PendingHunk { - buffer_range, - diff_base_byte_range, - new_status, - .. - }) = pending_hunks_iter.next() - { + let mut edits = Vec::<(Range, Arc)>::new(); + let mut pending = Vec::::new(); + + // Process only the hunks the user acted on, skipping any already in the + // desired state. + let mut hunks_iter = hunks + .iter() + .filter(|hunk| { + !((stage && hunk.secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) + || (!stage + && hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)) + }) + .peekable(); + + while let Some(hunk) = hunks_iter.next() { + let buffer_range = hunk.buffer_range.clone(); + let diff_base_byte_range = hunk.diff_base_byte_range.clone(); + pending.push(PendingHunk::new( + buffer_range.clone(), + diff_base_byte_range.clone(), + version.clone(), + sense, + )); + // Advance unstaged_hunk_cursor to skip unstaged hunks before current hunk let skipped_unstaged = unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left); @@ -846,16 +888,20 @@ impl BufferDiffSnapshot { } } - // If any unstaged hunks were merged, then subsequent pending hunks may - // now overlap this hunk. Merge them. - if let Some(next_pending_hunk) = pending_hunks_iter.peek() { - let next_pending_hunk_offset_range = - next_pending_hunk.buffer_range.to_offset(buffer); - if next_pending_hunk_offset_range.start <= buffer_offset_range.end { - buffer_offset_range.end = buffer_offset_range - .end - .max(next_pending_hunk_offset_range.end); - pending_hunks_iter.next(); + // If any unstaged hunks were merged, then subsequent acted-on hunks + // may now overlap this hunk. Merge them. + if let Some(next_hunk) = hunks_iter.peek() { + let next_hunk_offset_range = next_hunk.buffer_range.to_offset(buffer); + if next_hunk_offset_range.start <= buffer_offset_range.end { + buffer_offset_range.end = + buffer_offset_range.end.max(next_hunk_offset_range.end); + let merged_hunk = hunks_iter.next().expect("peeked hunk exists"); + pending.push(PendingHunk::new( + merged_hunk.buffer_range.clone(), + merged_hunk.diff_base_byte_range.clone(), + version.clone(), + sense, + )); continue; } } @@ -879,49 +925,59 @@ impl BufferDiffSnapshot { let index_start = index_start.min(index_end); let index_byte_range = index_start..index_end; - let replacement_text = match new_status { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => { - log::debug!("staging hunk {:?}", buffer_offset_range); + let replacement_text: Arc = if stage { + log::debug!("staging hunk {:?}", buffer_offset_range); + Arc::from( buffer .text_for_range(buffer_offset_range) - .collect::() - } - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => { - log::debug!("unstaging hunk {:?}", buffer_offset_range); + .collect::(), + ) + } else { + log::debug!("unstaging hunk {:?}", buffer_offset_range); + Arc::from( head_text .chunks_in_range(diff_base_byte_range.clone()) - .collect::() - } - _ => { - debug_assert!(false); - continue; - } + .collect::(), + ) }; - edits.push((index_byte_range, replacement_text)); + // Distinct worktree hunks can project to touching index ranges + // (e.g. a staged deletion ending exactly where the next hunk's + // index position starts). Merge them so the edit list stays + // strictly disjoint, which the pending-edit eviction logic relies + // on to not evict one of these edits when the other is inserted. + if let Some((last_range, last_text)) = edits.last_mut() + && index_byte_range.start <= last_range.end + { + debug_assert!(index_byte_range.start == last_range.end); + debug_assert!(index_byte_range.end >= last_range.end); + last_range.end = index_byte_range.end; + let mut merged_text = + String::with_capacity(last_text.len() + replacement_text.len()); + merged_text.push_str(last_text); + merged_text.push_str(&replacement_text); + *last_text = Arc::from(merged_text); + } else { + edits.push((index_byte_range, replacement_text)); + } } - drop(pending_hunks_iter); - drop(old_pending_hunks); - self.pending_hunks = pending_hunks; #[cfg(debug_assertions)] // invariants: non-overlapping and sorted { for window in edits.windows(2) { let (range_a, range_b) = (&window[0].0, &window[1].0); - debug_assert!(range_a.end < range_b.start); + debug_assert!( + range_a.end < range_b.start, + "index edits out of order or overlapping: {:?}", + edits + .iter() + .map(|(range, text)| (range.clone(), text.len())) + .collect::>() + ); } } - let mut new_index_text = Rope::new(); - let mut index_cursor = index_text.cursor(0); - - for (old_range, replacement_text) in edits { - new_index_text.append(index_cursor.slice(old_range.start)); - index_cursor.seek_forward(old_range.end); - new_index_text.push(&replacement_text); - } - new_index_text.append(index_cursor.suffix()); - Some(new_index_text) + (Some(edits), pending) } } @@ -1005,8 +1061,17 @@ impl BufferDiffSnapshot { start_anchor..end_anchor, ) { - has_pending = true; - secondary_status = pending_hunk.new_status; + match pending_hunk.sense { + PendingSense::SetSecondaryStatus { stage } => { + has_pending = true; + secondary_status = if stage { + DiffHunkSecondaryStatus::SecondaryHunkRemovalPending + } else { + DiffHunkSecondaryStatus::SecondaryHunkAdditionPending + }; + } + PendingSense::Suppress => continue, + } } } @@ -1127,13 +1192,20 @@ fn compute_hunks( return tree; } - let input = InternedInput::new( - lines_with_terminator(diff_base.as_ref()), - lines_with_terminator(buffer_text.as_str()), - ); - let sink = HunkSink::new(&diff_base, &diff_base_rope, buffer, diff_options.as_ref()); - let hunks = imara_diff::diff(Algorithm::Histogram, &input, sink); - for hunk in hunks { + let input = InternedInput::new(lines(diff_base.as_ref()), lines(buffer_text.as_str())); + let mut diff = Diff::compute(Algorithm::Histogram, &input); + // Canonicalize the placement of ambiguous hunks (git's slider/indent + // heuristic). Without this, diffs of the same buffer against different + // base texts (e.g. HEAD vs index) can anchor the same logical change at + // different rows, and code that correlates hunks across those diffs + // misbehaves: hunks render as staged when they aren't, and staging or + // unstaging them corrupts the index. + diff.postprocess_lines(&input); + let mut sink = HunkSink::new(&diff_base, &diff_base_rope, buffer, diff_options.as_ref()); + for hunk in diff.hunks() { + sink.process_change(hunk.before, hunk.after); + } + for hunk in sink.finish() { tree.push(hunk, buffer); } } else { @@ -1179,7 +1251,7 @@ impl<'a> HunkSink<'a> { fn compute_line_offsets(text: &str) -> Vec { let mut offsets = vec![0]; let mut offset = 0; - for line in lines_with_terminator(text) { + for line in lines(text) { offset += line.len(); offsets.push(offset); } @@ -1187,9 +1259,7 @@ impl<'a> HunkSink<'a> { } } -impl Sink for HunkSink<'_> { - type Out = Vec; - +impl HunkSink<'_> { fn process_change(&mut self, before: Range, after: Range) { let old_start = before.start as usize; let old_end = before.end as usize; @@ -1256,7 +1326,7 @@ impl Sink for HunkSink<'_> { }); } - fn finish(self) -> Self::Out { + fn finish(self) -> Vec { self.hunks } } @@ -1474,7 +1544,6 @@ pub struct DiffChanged { pub enum BufferDiffEvent { BaseTextChanged, DiffChanged(DiffChanged), - HunksStagedOrUnstaged(Option), } impl EventEmitter for BufferDiff {} @@ -1594,24 +1663,195 @@ impl BufferDiff { let Some(diff_snapshot) = &mut self.diff_snapshot else { return; }; - if self.secondary_diff.is_some() { - diff_snapshot.pending_hunks = SumTree::from_summary(DiffHunkSummary { - buffer_range: Anchor::min_min_range_for_buffer(self.buffer_id), - diff_base_byte_range: 0..0, - added_rows: 0, - removed_rows: 0, - }); - let changed_range = Some(Anchor::min_max_range_for_buffer(self.buffer_id)); - let base_text_range = Some(0..self.base_text(cx).len()); + let Some((first, last)) = diff_snapshot + .pending_hunks + .first() + .zip(diff_snapshot.pending_hunks.last()) + else { + return; + }; + let changed_range = first.buffer_range.start..last.buffer_range.end; + let base_text_changed_range = + first.diff_base_byte_range.start..last.diff_base_byte_range.end; + let buffer = diff_snapshot.buffer_snapshot.clone(); + diff_snapshot.pending_hunks = SumTree::new(&buffer); + cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { + changed_range: Some(changed_range.clone()), + base_text_changed_range: Some(base_text_changed_range), + extended_range: Some(changed_range), + base_text_changed: false, + })); + } + + /// Installs optimistic pending hunks in this diff, merging them with any + /// existing pending hunks (newest wins on overlap) and emitting a + /// `DiffChanged` covering both the new hunks and any existing pending hunks + /// they replace. `hunks` must be sorted by `buffer_range.start` and + /// non-overlapping. + /// + /// `buffer` must be a current snapshot of this diff's main buffer: the + /// incoming hunks carry anchors minted from the current buffer, which this + /// diff's internal snapshot (from the last settled recalculation) may not + /// have observed yet. + pub fn set_pending_hunks( + &mut self, + hunks: &[PendingHunk], + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + if hunks.is_empty() { + return; + } + let Some(diff_snapshot) = self.diff_snapshot.as_mut() else { + return; + }; + + let mut new_pending = SumTree::new(buffer); + let mut old = diff_snapshot + .pending_hunks + .cursor::(buffer); + let mut changed_start: Option = None; + let mut changed_end: Option = None; + let mut base_start = usize::MAX; + let mut base_end = 0usize; + let mut extend_changed_range = |buffer_range: &Range, base_range: &Range| { + changed_start = Some(changed_start.map_or(buffer_range.start, |start| { + *start.min(&buffer_range.start, buffer) + })); + changed_end = Some( + changed_end.map_or(buffer_range.end, |end| *end.max(&buffer_range.end, buffer)), + ); + base_start = base_start.min(base_range.start); + base_end = base_end.max(base_range.end); + }; + for hunk in hunks { + let preceding = old.slice(&hunk.buffer_range.start, Bias::Left); + new_pending.append(preceding, buffer); + + // Drop any overlapping or adjacent existing pending hunks, folding + // them into the changed range so that views repaint their full + // extent (a replaced hunk can be wider than its replacement). + while let Some(old_hunk) = old.item() { + if old_hunk + .buffer_range + .start + .cmp(&hunk.buffer_range.end, buffer) + .is_gt() + { + break; + } + extend_changed_range(&old_hunk.buffer_range, &old_hunk.diff_base_byte_range); + old.next(); + } + + extend_changed_range(&hunk.buffer_range, &hunk.diff_base_byte_range); + new_pending.push(hunk.clone(), buffer); + } + new_pending.append(old.suffix(), buffer); + drop(old); + diff_snapshot.pending_hunks = new_pending; + + if let (Some(start), Some(end)) = (changed_start, changed_end) { + let changed_range = Some(start..end); cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { changed_range: changed_range.clone(), - base_text_changed_range: base_text_range, + base_text_changed_range: Some(base_start..base_end), extended_range: changed_range, base_text_changed: false, })); } } + /// Optimistically marks every stageable (resp. unstageable) hunk in this diff + /// as staging (resp. unstaging). Used by whole-file staging from the git + /// panel, where the actual index change is performed by `git add`/`reset` + /// rather than the optimistic index patch. + pub fn mark_all_hunks_pending( + &mut self, + stage: bool, + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + let sense = PendingSense::SetSecondaryStatus { stage }; + let version = buffer.version().clone(); + let hunks = self + .snapshot(cx) + .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) + .filter(|hunk| { + !((stage && hunk.secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) + || (!stage + && hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)) + }) + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range, + hunk.diff_base_byte_range, + version.clone(), + sense, + ) + }) + .collect::>(); + self.set_pending_hunks(&hunks, buffer, cx); + } + + pub fn suppress_all_hunks_pending( + &mut self, + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + let version = buffer.version().clone(); + let hunks = self + .snapshot(cx) + .raw_hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + buffer, + ) + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range, + hunk.diff_base_byte_range, + version.clone(), + PendingSense::Suppress, + ) + }) + .collect::>(); + self.set_pending_hunks(&hunks, buffer, cx); + } + + /// Computes the index-text edits for unstaging the given staged (HEAD-vs-index) + /// hunks. `index_buffer` is this diff's main buffer (the index text). The + /// returned edits are in index coordinates. + pub fn unstage_staged_hunks( + &self, + hunks: &[DiffHunk], + index_buffer: &text::BufferSnapshot, + ) -> Option, Arc)>> { + let Some(diff_snapshot) = self.diff_snapshot.as_ref() else { + return Some(Vec::new()); + }; + // With no HEAD, the whole file is one staged addition; unstaging it + // removes the file from the index entirely. + if !diff_snapshot.base_text_exists { + return None; + } + let head_text = diff_snapshot.base_text.as_rope(); + let mut edits = hunks + .iter() + .map(|hunk| { + let index_range = hunk.buffer_range.to_offset(index_buffer); + let replacement_text: Arc = Arc::from( + head_text + .chunks_in_range(hunk.diff_base_byte_range.clone()) + .collect::(), + ); + (index_range, replacement_text) + }) + .collect::>(); + edits.sort_by_key(|(range, _)| range.start); + Some(edits) + } + + #[cfg(any(test, feature = "test-support"))] pub fn stage_or_unstage_hunks( &mut self, stage: bool, @@ -1621,74 +1861,40 @@ impl BufferDiff { cx: &mut Context, ) -> Option { let secondary_diff = self.secondary_diff.clone()?; - let diff_snapshot = self.diff_snapshot.as_mut()?; let unstaged_diff_snapshot = secondary_diff.read_with(cx, |secondary_diff, _cx| { secondary_diff.diff_snapshot.clone() })?; - let new_index_text = diff_snapshot.stage_or_unstage_hunks_impl( + let diff_snapshot = self.diff_snapshot.clone()?; + let (edits, pending) = diff_snapshot.compute_uncommitted_index_edits( &unstaged_diff_snapshot, stage, hunks, buffer, file_exists, ); - - cx.emit(BufferDiffEvent::HunksStagedOrUnstaged( - new_index_text.clone(), - )); - if let Some((first, last)) = hunks.first().zip(hunks.last()) { - let changed_range = Some(first.buffer_range.start..last.buffer_range.end); - let base_text_changed_range = - Some(first.diff_base_byte_range.start..last.diff_base_byte_range.end); - cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: changed_range.clone(), - base_text_changed_range, - extended_range: changed_range, - base_text_changed: false, - })); - } - new_index_text + self.set_pending_hunks(&pending, buffer, cx); + edits.map(|edits| { + let mut index_text = unstaged_diff_snapshot.base_text.as_rope().clone(); + for (old_range, replacement_text) in edits.iter().rev() { + index_text.replace(old_range.clone(), replacement_text); + } + index_text + }) } + #[cfg(any(test, feature = "test-support"))] pub fn stage_or_unstage_all_hunks( &mut self, stage: bool, buffer: &text::BufferSnapshot, file_exists: bool, cx: &mut Context, - ) { + ) -> Option { let hunks = self .snapshot(cx) .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) .collect::>(); - let Some(diff_snapshot) = &mut self.diff_snapshot else { - return; - }; - let Some(secondary) = self.secondary_diff.clone() else { - return; - }; - let secondary = secondary.read(cx); - let Some(secondary_snapshot) = &secondary.diff_snapshot else { - return; - }; - diff_snapshot.stage_or_unstage_hunks_impl( - &secondary_snapshot, - stage, - &hunks, - buffer, - file_exists, - ); - if let Some((first, last)) = hunks.first().zip(hunks.last()) { - let changed_range = Some(first.buffer_range.start..last.buffer_range.end); - let base_text_changed_range = - Some(first.diff_base_byte_range.start..last.diff_base_byte_range.end); - cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: changed_range.clone(), - base_text_changed_range, - extended_range: changed_range, - base_text_changed: false, - })); - } + self.stage_or_unstage_hunks(stage, &hunks, buffer, file_exists, cx) } pub fn update_diff( @@ -1916,6 +2122,12 @@ impl BufferDiff { .is_some_and(|diff_snapshot| diff_snapshot.base_text_exists) } + pub fn changed_row_counts(&self) -> (u32, u32) { + self.diff_snapshot + .as_ref() + .map_or((0, 0), |diff_snapshot| diff_snapshot.changed_row_counts()) + } + pub fn snapshot(&self, cx: &App) -> BufferDiffSnapshot { let mut snapshot = self.diff_snapshot.clone().unwrap_or_else(|| { let base_text = self.base_text_buffer.read(cx).snapshot(); @@ -2837,6 +3049,81 @@ mod tests { }); } + #[gpui::test] + async fn test_set_pending_hunks_change_covers_replaced_hunks(cx: &mut TestAppContext) { + let base_text = " + zero + one + two + three + four + five + " + .unindent(); + let buffer_text = " + ZERO + one + two + THREE + four + FIVE + " + .unindent(); + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); + let diff = cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer, cx)); + + // Install a whole-file pending hunk, as the no-HEAD staging paths do. + let version = buffer.version(); + diff.update(cx, |diff, cx| { + diff.set_pending_hunks( + &[PendingHunk::new( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + 0..base_text.len(), + version.clone(), + PendingSense::SetSecondaryStatus { stage: true }, + )], + &buffer, + cx, + ) + }); + + let (tx, rx) = mpsc::channel(); + let subscription = + cx.update(|cx| cx.subscribe(&diff, move |_, event, _| tx.send(event.clone()).unwrap())); + + // Replace it with a narrower hunk; the emitted change must still cover + // the whole extent of the replaced hunk. + diff.update(cx, |diff, cx| { + diff.set_pending_hunks( + &[PendingHunk::new( + buffer.anchor_before(Point::new(3, 0))..buffer.anchor_before(Point::new(4, 0)), + base_text.find("three").unwrap()..base_text.find("four").unwrap(), + version, + PendingSense::Suppress, + )], + &buffer, + cx, + ) + }); + + drop(subscription); + let events = rx.into_iter().collect::>(); + match events.as_slice() { + [ + BufferDiffEvent::DiffChanged(DiffChanged { + changed_range: Some(changed_range), + .. + }), + ] => { + assert_eq!( + changed_range.to_point(&buffer), + Point::zero()..buffer.max_point(), + ); + } + _ => panic!("unexpected events: {:?}", events), + } + } + #[gpui::test] async fn test_buffer_diff_compare(cx: &mut TestAppContext) { let base_text = " diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index eb9e3c8d86b7b9..eafb713d7e80f3 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -51,6 +51,7 @@ fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } +serde_json.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/call/src/call_impl/diagnostics.rs b/crates/call/src/call_impl/diagnostics.rs index 1aa1774dfb0f59..9410151049cbff 100644 --- a/crates/call/src/call_impl/diagnostics.rs +++ b/crates/call/src/call_impl/diagnostics.rs @@ -1,33 +1,91 @@ -use gpui::{Context, Task, WeakEntity}; -use livekit_client::ConnectionQuality; -use std::time::Duration; +use gpui::{Context, Entity, Task, WeakEntity}; +use livekit_client::{ConnectionQuality, RemoteAudioPlaybackStats}; +use serde::{Serialize, Serializer}; +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, + time::{Duration, Instant}, +}; use super::room::Room; -#[derive(Clone, Default)] +const MAX_HISTORY_SAMPLES: usize = 60; + +#[derive(Copy, Clone, Default)] +pub struct DurationDTO(pub Duration); + +#[derive(Copy, Clone)] +pub struct ConnectionQualityDTO(pub ConnectionQuality); + +#[derive(Clone, Default, Serialize)] pub struct CallStats { - pub connection_quality: Option, - pub effective_quality: Option, + pub connection_quality: Option, + pub effective_quality: Option, pub latency_ms: Option, pub jitter_ms: Option, pub packet_loss_pct: Option, - pub input_lag: Option, + #[serde(rename = "input_lag_ms")] + pub input_lag: Option, +} + +#[derive(Clone, Default, Serialize)] +pub struct RemoteAudioDiagnostics { + pub participant_id: String, + #[serde(skip)] + pub participant_name: String, + pub track_id: String, + pub packets_received: u64, + pub packets_lost: i64, + pub packet_loss_pct: Option, + pub jitter_ms: f64, + pub jitter_buffer_delay_ms: Option, + pub concealed_samples: u64, + pub concealment_events: u64, + pub inserted_samples_for_deceleration: u64, + pub removed_samples_for_acceleration: u64, + pub frames_received: u64, + pub frames_dropped: u64, + pub queue_underflows: u64, + pub current_queue_depth: u64, + pub maximum_queue_depth: u64, +} + +#[derive(Clone, Serialize)] +pub struct CallDiagnosticsSnapshot { + #[serde(rename = "elapsed_ms")] + pub elapsed: DurationDTO, + pub stats: CallStats, + pub remote_audio: Vec, +} + +#[derive(Serialize)] +pub struct CallDiagnosticsReport { + schema_version: u32, + samples: Vec>, } pub struct CallDiagnostics { stats: CallStats, + history: VecDeque>, + previous_inbound: HashMap, + participant_ids: HashMap, + track_ids: HashMap, + started_at: Instant, room: WeakEntity, poll_task: Option>, - stats_update_task: Option>, } impl CallDiagnostics { pub fn new(room: WeakEntity, cx: &mut Context) -> Self { let mut this = Self { stats: CallStats::default(), + history: VecDeque::with_capacity(MAX_HISTORY_SAMPLES), + previous_inbound: HashMap::default(), + participant_ids: HashMap::default(), + track_ids: HashMap::default(), + started_at: Instant::now(), room, poll_task: None, - stats_update_task: None, }; this.start_polling(cx); this @@ -37,10 +95,35 @@ impl CallDiagnostics { &self.stats } + pub fn latest(&self) -> Option<&CallDiagnosticsSnapshot> { + self.history.back().map(Arc::as_ref) + } + + pub fn history(&self) -> &VecDeque> { + &self.history + } + + pub fn report(&self) -> CallDiagnosticsReport { + CallDiagnosticsReport { + schema_version: 1, + samples: self.history.iter().cloned().collect(), + } + } + fn start_polling(&mut self, cx: &mut Context) { self.poll_task = Some(cx.spawn(async move |this, cx| { loop { - if this.update(cx, |this, cx| this.poll_stats(cx)).is_err() { + let Ok(Some(room)) = this.update(cx, |this, _| this.room.upgrade()) else { + break; + }; + let Ok(poll_task) = this.update(cx, |this, cx| this.poll_stats(room, cx)) else { + break; + }; + let result = poll_task.await; + if this + .update(cx, |this, cx| this.apply_poll_result(result, cx)) + .is_err() + { break; } cx.background_executor().timer(Duration::from_secs(1)).await; @@ -48,41 +131,136 @@ impl CallDiagnostics { })); } - fn poll_stats(&mut self, cx: &mut Context) { - let Some(room) = self.room.upgrade() else { + fn poll_stats(&mut self, room: Entity, cx: &mut Context) -> Task { + let connection_quality = room.read(cx).connection_quality(); + let input_lag = room.read(cx).input_lag().map(DurationDTO); + let stats_future = room.read(cx).get_stats(cx); + + let mut remote_tracks = Vec::new(); + for (user_id, participant) in room.read(cx).remote_participants() { + let next_participant_number = self.participant_ids.len() + 1; + let participant_id = self + .participant_ids + .entry(*user_id) + .or_insert_with(|| format!("participant-{next_participant_number}")) + .clone(); + for (track_id, (track, stream)) in &participant.audio_tracks { + let track_id = track_id.to_string(); + let next_track_number = self.track_ids.len() + 1; + let track_id = self + .track_ids + .entry(track_id) + .or_insert_with(|| format!("audio-track-{next_track_number}")) + .clone(); + remote_tracks.push(TrackContext { + participant_id: participant_id.clone(), + participant_name: participant.user.username.to_string(), + track_id, + rtc_track_id: track.rtc_track_id(), + playback: stream.remote_playback_stats().unwrap_or_default(), + }); + } + } + + let previous_inbound = std::mem::take(&mut self.previous_inbound); + let started_at = self.started_at; + cx.background_executor().spawn(async move { + let Some(session_stats) = stats_future.await else { + return PollResult { + snapshot: None, + previous_inbound, + }; + }; + let computed = compute_network_stats(&session_stats); + let (remote_audio, previous_inbound) = + compute_remote_audio_stats(&session_stats, &remote_tracks, previous_inbound); + let mut stats = CallStats { + connection_quality: Some(ConnectionQualityDTO(connection_quality)), + effective_quality: None, + latency_ms: computed.latency_ms, + jitter_ms: computed.jitter_ms, + packet_loss_pct: computed.packet_loss_pct, + input_lag, + }; + stats.effective_quality = Some(ConnectionQualityDTO(effective_connection_quality( + connection_quality, + &stats, + ))); + PollResult { + snapshot: Some(CallDiagnosticsSnapshot { + elapsed: DurationDTO(started_at.elapsed()), + stats, + remote_audio, + }), + previous_inbound, + } + }) + } + + fn apply_poll_result(&mut self, result: PollResult, cx: &mut Context) { + self.previous_inbound = result.previous_inbound; + let Some(snapshot) = result.snapshot else { return; }; + self.stats = snapshot.stats.clone(); + if self.history.len() + 1 > MAX_HISTORY_SAMPLES { + self.history.pop_front(); + } + self.history.push_back(Arc::new(snapshot)); + cx.notify(); + } +} - let connection_quality = room.read(cx).connection_quality(); - self.stats.connection_quality = Some(connection_quality); - self.stats.input_lag = room.read(cx).input_lag(); +impl Serialize for DurationDTO { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.as_millis().serialize(serializer) + } +} - let stats_future = room.read(cx).get_stats(cx); +impl Serialize for ConnectionQualityDTO { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self.0 { + ConnectionQuality::Excellent => "excellent", + ConnectionQuality::Good => "good", + ConnectionQuality::Poor => "poor", + ConnectionQuality::Lost => "lost", + } + .serialize(serializer) + } +} - let background_task = cx.background_executor().spawn(async move { - let session_stats = stats_future.await; - session_stats.map(|stats| compute_network_stats(&stats)) - }); +#[cfg_attr(any(test, feature = "test-support"), allow(dead_code))] +struct TrackContext { + participant_id: String, + participant_name: String, + track_id: String, + rtc_track_id: String, + playback: RemoteAudioPlaybackStats, +} - self.stats_update_task = Some(cx.spawn(async move |this, cx| { - let result = background_task.await; - this.update(cx, |this, cx| { - if let Some(computed) = result { - this.stats.latency_ms = computed.latency_ms; - this.stats.jitter_ms = computed.jitter_ms; - this.stats.packet_loss_pct = computed.packet_loss_pct; - } - let quality = this - .stats - .connection_quality - .unwrap_or(ConnectionQuality::Lost); - this.stats.effective_quality = - Some(effective_connection_quality(quality, &this.stats)); - cx.notify(); - }) - .ok(); - })); - } +struct PollResult { + snapshot: Option, + previous_inbound: HashMap, +} + +#[derive(Clone, Copy)] +#[cfg_attr(any(test, feature = "test-support"), allow(dead_code))] +struct InboundCounters { + packets_received: u64, + packets_lost: i64, + jitter_buffer_delay: f64, + jitter_buffer_emitted_count: u64, + concealed_samples: u64, + concealment_events: u64, + inserted_samples_for_deceleration: u64, + removed_samples_for_acceleration: u64, + playback: RemoteAudioPlaybackStats, } struct ComputedNetworkStats { @@ -126,6 +304,159 @@ fn compute_network_stats(stats: &livekit_client::SessionStats) -> ComputedNetwor } } +#[cfg(all( + not(rust_analyzer), + any( + test, + feature = "test-support", + all(target_os = "windows", target_env = "gnu"), + target_os = "freebsd" + ) +))] +fn compute_remote_audio_stats( + _stats: &livekit_client::SessionStats, + _remote_tracks: &[TrackContext], + previous_inbound: HashMap, +) -> ( + Vec, + HashMap, +) { + (Vec::new(), previous_inbound) +} + +#[cfg(any( + rust_analyzer, + not(any( + test, + feature = "test-support", + all(target_os = "windows", target_env = "gnu"), + target_os = "freebsd" + )) +))] +fn compute_remote_audio_stats( + stats: &livekit_client::SessionStats, + remote_tracks: &[TrackContext], + previous_inbound: HashMap, +) -> ( + Vec, + HashMap, +) { + use livekit_client::RtcStats; + + let mut diagnostics = Vec::new(); + let mut next_inbound = HashMap::default(); + for stat in &stats.subscriber_stats { + let RtcStats::InboundRtp(inbound) = stat else { + continue; + }; + if inbound.stream.kind != "audio" { + continue; + } + + let Some(track) = remote_tracks + .iter() + .find(|track| track.rtc_track_id == inbound.inbound.track_identifier) + else { + continue; + }; + + let current = InboundCounters { + packets_received: inbound.received.packets_received, + packets_lost: inbound.received.packets_lost, + jitter_buffer_delay: inbound.inbound.jitter_buffer_delay, + jitter_buffer_emitted_count: inbound.inbound.jitter_buffer_emitted_count, + concealed_samples: inbound.inbound.concealed_samples, + concealment_events: inbound.inbound.concealment_events, + inserted_samples_for_deceleration: inbound.inbound.inserted_samples_for_deceleration, + removed_samples_for_acceleration: inbound.inbound.removed_samples_for_acceleration, + playback: track.playback, + }; + let previous = previous_inbound + .get(&inbound.rtc.id) + .copied() + .unwrap_or(current); + next_inbound.insert(inbound.rtc.id.clone(), current); + + let packets_received = current + .packets_received + .checked_sub(previous.packets_received) + .unwrap_or_default(); + let packets_lost = current + .packets_lost + .checked_sub(previous.packets_lost) + .unwrap_or_default(); + + let packet_loss_pct = { + let packets_lost = packets_lost.max(0); + let expected_packets = packets_received as i128 + i128::from(packets_lost); + if expected_packets > 0 { + Some((packets_lost as f64 / expected_packets as f64) * 100.0) + } else { + None + } + }; + + let emitted_count = current + .jitter_buffer_emitted_count + .checked_sub(previous.jitter_buffer_emitted_count) + .unwrap_or_default(); + let jitter_buffer_delay_ms = { + let delay = current.jitter_buffer_delay - previous.jitter_buffer_delay; + if emitted_count > 0 && delay >= 0.0 { + Some((delay / emitted_count as f64) * 1000.0) + } else { + None + } + }; + + diagnostics.push(RemoteAudioDiagnostics { + participant_id: track.participant_id.clone(), + participant_name: track.participant_name.clone(), + track_id: track.track_id.clone(), + packets_received, + packets_lost, + packet_loss_pct, + jitter_ms: inbound.received.jitter * 1000.0, + jitter_buffer_delay_ms, + concealed_samples: current + .concealed_samples + .checked_sub(previous.concealed_samples) + .unwrap_or_default(), + concealment_events: current + .concealment_events + .checked_sub(previous.concealment_events) + .unwrap_or_default(), + inserted_samples_for_deceleration: current + .inserted_samples_for_deceleration + .checked_sub(previous.inserted_samples_for_deceleration) + .unwrap_or_default(), + removed_samples_for_acceleration: current + .removed_samples_for_acceleration + .checked_sub(previous.removed_samples_for_acceleration) + .unwrap_or_default(), + frames_received: current + .playback + .frames_received + .checked_sub(previous.playback.frames_received) + .unwrap_or_default(), + frames_dropped: current + .playback + .frames_dropped + .checked_sub(previous.playback.frames_dropped) + .unwrap_or_default(), + queue_underflows: current + .playback + .queue_underflows + .checked_sub(previous.playback.queue_underflows) + .unwrap_or_default(), + current_queue_depth: current.playback.current_queue_depth, + maximum_queue_depth: current.playback.maximum_queue_depth, + }); + } + + (diagnostics, next_inbound) +} + #[cfg(all( not(rust_analyzer), any( @@ -224,9 +555,37 @@ fn effective_connection_quality( worst = worst.max(metric_quality(loss, 1.0, 5.0)); } if let Some(lag) = stats.input_lag { - let lag_ms = lag.as_secs_f64() * 1000.0; + let lag_ms = lag.0.as_secs_f64() * 1000.0; worst = worst.max(metric_quality(lag_ms, 20.0, 50.0)); } worst } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn report_redacts_participant_names() -> anyhow::Result<()> { + let report = CallDiagnosticsReport { + schema_version: 1, + samples: vec![Arc::new(CallDiagnosticsSnapshot { + elapsed: DurationDTO(Duration::from_secs(1)), + stats: CallStats::default(), + remote_audio: vec![RemoteAudioDiagnostics { + participant_id: "participant-1".to_string(), + participant_name: "private-username".to_string(), + track_id: "audio-track-1".to_string(), + ..Default::default() + }], + })], + }; + + let serialized = serde_json::to_string(&report)?; + assert!(!serialized.contains("private-username")); + assert!(serialized.contains("participant-1")); + assert!(serialized.contains("audio-track-1")); + Ok(()) + } +} diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index 6b87c4d85552c5..f7fa9d3b7dd35c 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -35,13 +35,37 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { return; }; - let active_call_handle = active_call_handle.clone(); - cx.subscribe_in( - &cx.entity(), - window, + cx.observe_window_activation(window, { + let active_call_handle = active_call_handle.clone(); + move |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.update_active_view_for_followers(window, cx) + }); + + if window.is_window_active() { + let project = workspace.read(cx).project().clone(); + if let Ok(task) = active_call_handle.update(cx, |active_call, cx| { + active_call.set_location(Some(&project), cx) + }) { + task.detach_and_log_err(cx); + } + } else if cx.active_window().is_none() { + if let Ok(task) = active_call_handle + .update(cx, |active_call, cx| active_call.set_location(None, cx)) + { + task.detach_and_log_err(cx); + } + } + } + }) + .detach(); + + cx.subscribe_in(&cx.entity(), window, { + let active_call_handle = active_call_handle.clone(); move |multi_workspace, _, event: &MultiWorkspaceEvent, window, cx| { - if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged { .. }) - && window.is_window_active() + if !(matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged { .. }) + && window.is_window_active()) { return; } @@ -52,8 +76,8 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { }) { task.detach_and_log_err(cx); } - }, - ) + } + }) .detach(); }) .detach(); @@ -367,6 +391,7 @@ pub struct IncomingCall { /// Singleton global maintaining the user's participation in a room across workspaces. pub struct ActiveCall { room: Option<(Entity, Vec)>, + last_call_diagnostics: Option>, pending_room_creation: Option, Arc>>>>, location: Option>, _join_debouncer: OneAtATime, @@ -386,6 +411,7 @@ impl ActiveCall { fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { Self { room: None, + last_call_diagnostics: None, pending_room_creation: None, location: None, pending_invites: Default::default(), @@ -666,6 +692,7 @@ impl ActiveCall { Audio::end_call(cx); let channel_id = self.channel_id(cx); + self.retain_room_diagnostics(cx); if let Some((room, _)) = self.room.take() { cx.emit(Event::RoomLeft { channel_id }); room.update(cx, |room, cx| room.leave(cx)) @@ -720,6 +747,7 @@ impl ActiveCall { Task::ready(Ok(())) } else { cx.notify(); + self.retain_room_diagnostics(cx); if let Some(room) = room { if room.read(cx).status().is_offline() { self.room = None; @@ -755,6 +783,23 @@ impl ActiveCall { self.room.as_ref().map(|(room, _)| room) } + pub fn call_diagnostics(&self, cx: &App) -> Option> { + self.room() + .and_then(|room| room.read(cx).diagnostics()) + .cloned() + .or_else(|| self.last_call_diagnostics.clone()) + } + + fn retain_room_diagnostics(&mut self, cx: &App) { + if let Some(diagnostics) = self + .room() + .and_then(|room| room.read(cx).diagnostics()) + .cloned() + { + self.last_call_diagnostics = Some(diagnostics); + } + } + pub fn client(&self) -> Arc { self.client.clone() } diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index e72f375847e9d4..108c5a46304ffa 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -93,7 +93,6 @@ pub struct Room { room_update_completed_rx: watch::Receiver>, pending_room_update: Option>, maintain_connection: Option>>, - livekit_connection_task: Option>, created: Instant, } @@ -124,7 +123,7 @@ impl Room { user_store: Entity, cx: &mut Context, ) -> Self { - let livekit_connection_task = spawn_room_connection(livekit_connection_info, cx); + spawn_room_connection(livekit_connection_info, cx); let maintain_connection = cx.spawn({ let client = client.clone(); @@ -165,7 +164,6 @@ impl Room { user_store, follows_by_leader_id_project_id: Default::default(), maintain_connection: Some(maintain_connection), - livekit_connection_task, room_update_completed_tx, room_update_completed_rx, created: cx.background_executor().now(), @@ -362,7 +360,6 @@ impl Room { self.diagnostics.take(); self.pending_room_update.take(); self.maintain_connection.take(); - self.livekit_connection_task.take(); } fn emit_video_track_unsubscribed_events(&self, cx: &mut Context) { @@ -403,10 +400,7 @@ impl Room { let Some(this) = this.upgrade() else { break }; let task = this.update(cx, |this, cx| this.rejoin(cx)); - if let Some(live_kit_connection_info) = task.await.log_err() { - this.update(cx, |this, cx| { - this.ensure_livekit_connection(live_kit_connection_info, cx); - }); + if task.await.log_err().is_some() { return true; } else { remaining_attempts -= 1; @@ -453,10 +447,7 @@ impl Room { anyhow::bail!("can't reconnect to room: client failed to re-establish connection"); } - fn rejoin( - &mut self, - cx: &mut Context, - ) -> Task>> { + fn rejoin(&mut self, cx: &mut Context) -> Task> { let mut projects = HashMap::default(); let mut reshared_projects = Vec::new(); let mut rejoined_projects = Vec::new(); @@ -516,8 +507,7 @@ impl Room { cx.spawn(async move |this, cx| { let response = response.await?; let message_id = response.message_id; - let mut response = response.payload; - let live_kit_connection_info = response.live_kit_connection_info.take(); + let response = response.payload; let room_proto = response.room.context("invalid room")?; this.update(cx, |this, cx| { this.status = RoomStatus::Online; @@ -540,22 +530,10 @@ impl Room { } anyhow::Ok(()) - })??; - Ok(live_kit_connection_info) + })? }) } - fn ensure_livekit_connection( - &mut self, - connection_info: Option, - cx: &mut Context, - ) { - if connection_info.is_none() || self.is_connected(cx) { - return; - } - self.livekit_connection_task = spawn_room_connection(connection_info, cx); - } - pub fn id(&self) -> u64 { self.id } @@ -1773,100 +1751,63 @@ impl Room { } } -/// The number of times we attempt to establish the LiveKit connection before -/// giving up. Retries cover transient failures (e.g. a network blip); a -/// genuinely bad token won't be fixed by retrying, so the bound keeps us from -/// hammering LiveKit indefinitely. -const LIVEKIT_CONNECT_ATTEMPTS: usize = 5; - fn spawn_room_connection( livekit_connection_info: Option, cx: &mut Context, -) -> Option> { - let connection_info = livekit_connection_info?; - Some(cx.spawn(async move |this, cx| { - // Retry the LiveKit connection with backoff, but deliberately do NOT - // ask collab for a fresh token here: a refreshed token is only needed - // when the collab connection itself is re-established, which is handled - // separately in `maintain_connection` via `ensure_livekit_connection`. - // Coupling this loop to `rejoin` previously turned an isolated LiveKit - // failure into a channel-wide `RejoinRoom` storm. - let mut backoff = Duration::from_secs(1); - for attempt in 1..=LIVEKIT_CONNECT_ATTEMPTS { - match livekit::Room::connect( - connection_info.server_url.clone(), - connection_info.token.clone(), - cx, - ) - .await - { - Ok((room, mut events)) => { - let weak_room = this.clone(); - let Ok(share_microphone) = this.update(cx, |this, cx| { - let _handle_updates = cx.spawn(async move |this, cx| { - while let Some(event) = events.next().await { - if this - .update(cx, |this, cx| { - this.livekit_room_updated(event, cx).warn_on_err(); - }) - .is_err() - { - break; - } - } - }); - - let muted_by_user = Room::mute_on_join(cx); - this.live_kit = Some(LiveKitRoom { - room: Rc::new(room), - screen_track: LocalTrack::None, - microphone_track: LocalTrack::None, - input_lag_us: None, - next_publish_id: 0, - muted_by_user, - deafened: false, - speaking: false, - _handle_updates, - }); - this.diagnostics = Some(cx.new(|cx| CallDiagnostics::new(weak_room, cx))); - cx.notify(); +) { + if let Some(connection_info) = livekit_connection_info { + cx.spawn(async move |this, cx| { + let (room, mut events) = + livekit::Room::connect(connection_info.server_url, connection_info.token, cx) + .await?; - // Always open the microphone track on join, even when - // `muted_by_user` is set. Note that the microphone will still - // be muted, as it is still gated in `share_microphone` by - // `muted_by_user`. For users that have `mute_on_join` enabled, - // this moves the Bluetooth profile switch (A2DP -> HFP) (which - // can cause 1-2 seconds of audio silence on some Bluetooth - // headphones) from first unmute to channel join, where - // instability is expected. - if this.can_use_microphone() { - this.share_microphone(cx) - } else { - Task::ready(Ok(())) + let weak_room = this.clone(); + this.update(cx, |this, cx| { + let _handle_updates = cx.spawn(async move |this, cx| { + while let Some(event) = events.next().await { + if this + .update(cx, |this, cx| { + this.livekit_room_updated(event, cx).warn_on_err(); + }) + .is_err() + { + break; } - }) else { - return; - }; - share_microphone.await.log_err(); - return; - } - Err(error) => { - log::error!( - "failed to connect to LiveKit room (attempt {attempt}/{LIVEKIT_CONNECT_ATTEMPTS}): {error:#}" - ); - } - } - - if attempt < LIVEKIT_CONNECT_ATTEMPTS { - cx.background_executor().timer(backoff).await; - backoff = (backoff * 2).min(Duration::from_secs(10)); - } - } + } + }); - log::error!( - "giving up on LiveKit room connection after {LIVEKIT_CONNECT_ATTEMPTS} attempts" - ); - })) + let muted_by_user = Room::mute_on_join(cx); + this.live_kit = Some(LiveKitRoom { + room: Rc::new(room), + screen_track: LocalTrack::None, + microphone_track: LocalTrack::None, + input_lag_us: None, + next_publish_id: 0, + muted_by_user, + deafened: false, + speaking: false, + _handle_updates, + }); + this.diagnostics = Some(cx.new(|cx| CallDiagnostics::new(weak_room, cx))); + + // Always open the microphone track on join, even when + // `muted_by_user` is set. Note that the microphone will still + // be muted, as it is still gated in `share_microphone` by + // `muted_by_user`. For users that have `mute_on_join` enabled, + // this moves the Bluetooth profile switch (A2DP -> HFP) (which + // can cause 1-2 seconds of audio silence on some Bluetooth + // headphones) from first unmute to channel join, where + // instability is expected. + if this.can_use_microphone() { + this.share_microphone(cx) + } else { + Task::ready(Ok(())) + } + })? + .await + }) + .detach_and_log_err(cx); + } } struct LiveKitRoom { diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index e73809e6047dfe..7d6632de4a719e 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -124,7 +124,7 @@ impl ChannelMembership { proto::channel_member::Kind::Member => 0, proto::channel_member::Kind::Invitee => 1, }, - username_order: &self.user.github_login, + username_order: &self.user.username, } } } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e8667c608753b9..04594e77904344 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -24,6 +24,8 @@ default = [] anyhow.workspace = true askpass.workspace = true clap.workspace = true +clap_complete.workspace = true +clap_complete_nushell.workspace = true collections.workspace = true console.workspace = true dialoguer.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 0823f1fafee092..d7ad0b347ed3f4 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -16,13 +16,14 @@ pub struct IpcHandshake { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum OpenBehavior { - /// Consult the user's `cli_default_open_behavior` setting to choose between - /// `ExistingWindow` or `Classic`. + /// Consult the user's `cli_default_open_behavior` setting. #[default] Default, /// Always create a new window. No matching against existing worktrees. /// Corresponds to `zed -n`. AlwaysNew, + /// Create a new window unless opening a subpath of an existing project. + PreferNewWindow, /// Match broadly including subdirectories, and fall back to any existing /// window if no worktree matched. Corresponds to `zed -a`. Add, @@ -47,9 +48,7 @@ pub enum OpenBehavior { pub enum CliBehaviorSetting { /// Open directories as a new workspace in the current Zed window's sidebar. ExistingWindow, - /// Classic behavior: open directories in a new window, but reuse an - /// existing window when opening files that are already part of an open - /// project. + /// Open paths in a new window unless they are subpaths of an existing project. NewWindow, } diff --git a/crates/cli/src/completions.rs b/crates/cli/src/completions.rs new file mode 100644 index 00000000000000..715d8d978de05e --- /dev/null +++ b/crates/cli/src/completions.rs @@ -0,0 +1,47 @@ +mod shells { + pub use clap_complete::aot::{Bash, Elvish, Fish, PowerShell, Zsh}; + pub use clap_complete_nushell::Nushell; +} + +use clap_complete::Generator; + +#[derive(Clone, Debug, clap::ValueEnum)] +#[non_exhaustive] +#[value(rename_all = "lower")] +pub(crate) enum Shell { + Bash, + Elvish, + Fish, + Nushell, + PowerShell, + Zsh, +} + +impl Generator for Shell { + fn file_name(&self, name: &str) -> String { + match self { + Shell::Bash => self::shells::Bash.file_name(name), + Shell::Elvish => self::shells::Elvish.file_name(name), + Shell::Fish => self::shells::Fish.file_name(name), + Shell::Nushell => self::shells::Nushell.file_name(name), + Shell::PowerShell => self::shells::PowerShell.file_name(name), + Shell::Zsh => self::shells::Zsh.file_name(name), + } + } + + fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::Write) { + match self { + Shell::Bash => self::shells::Bash.generate(cmd, buf), + Shell::Elvish => self::shells::Elvish.generate(cmd, buf), + Shell::Fish => self::shells::Fish.generate(cmd, buf), + Shell::Nushell => self::shells::Nushell.generate(cmd, buf), + Shell::PowerShell => self::shells::PowerShell.generate(cmd, buf), + Shell::Zsh => self::shells::Zsh.generate(cmd, buf), + } + } +} + +pub(crate) fn main(cmd: &clap::Command, shell: &Shell) { + let buf = &mut std::io::stdout(); + shell.generate(cmd, buf); +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d9c976b688cc71..82bec80e04da1d 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -7,8 +7,12 @@ allow(dead_code) )] +mod completions; + +use crate::completions::Shell; + use anyhow::{Context as _, Result}; -use clap::Parser; +use clap::{CommandFactory, Parser}; use cli::{CliRequest, CliResponse, IpcHandshake, ipc::IpcOneShotServer}; use parking_lot::Mutex; use std::{ @@ -89,11 +93,12 @@ struct Args { not(any(target_os = "windows", target_os = "macos")), doc = "`$XDG_DATA_HOME/zed`." )] - #[arg(long, value_name = "DIR")] + #[arg(long, value_name = "DIR", value_hint = clap::ValueHint::DirPath)] user_data_dir: Option, /// The paths to open in Zed (space-separated). /// /// Use `path:line:column` syntax to open a file at the given line and column. + #[arg(trailing_var_arg = true, value_hint = clap::ValueHint::AnyPath)] paths_with_position: Vec, /// Print Zed's version and the app path. #[arg(short, long)] @@ -131,8 +136,11 @@ struct Args { dev_container: bool, /// Pairs of file paths to diff. Can be specified multiple times. /// When directories are provided, recurses into them and shows all changed files in a single multi-diff view. - #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"])] + #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"], value_hint = clap::ValueHint::AnyPath)] diff: Vec, + /// Generate shell completions for Zed + #[arg(long, value_names = ["SHELL"])] + completions: Option, /// Uninstall Zed from user system #[cfg(all( any(target_os = "linux", target_os = "macos"), @@ -194,6 +202,13 @@ fn parse_path_with_position(argument_str: &str) -> anyhow::Result { .map(|path_with_pos| path_with_pos.to_string(&|path| path.to_string_lossy().into_owned())) } +/// Returns whether a `--diff` argument refers to an existing path, allowing a +/// trailing `:line:column` suffix (parsed later by the Zed side, matching how +/// regular `zed path:line:column` arguments are handled). +fn diff_path_exists(diff_path: &str) -> bool { + Path::new(diff_path).exists() || PathWithPosition::parse_str(diff_path).path.exists() +} + fn expand_directory_diff_pairs( diff_pairs: Vec<[String; 2]>, ) -> anyhow::Result<(Vec<[String; 2]>, Vec)> { @@ -514,6 +529,20 @@ fn run() -> Result<()> { let app = Detect::detect(args.zed.as_deref()).context("Bundle detection")?; + if let Some(shell) = &args.completions { + let file_path = std::env::current_exe()?; + let file_name = file_path + .file_name() + .and_then(OsStr::to_str) + .ok_or("--completions expects a UTF-8 name for cli bin") + .map_err(anyhow::Error::msg)?; + let mut cmd = Args::command(); + cmd.set_bin_name(file_name); + cmd.build(); + crate::completions::main(&cmd, shell); + return Ok(()); + } + if args.version { println!("{}", app.zed_version_string()); return Ok(()); @@ -620,7 +649,7 @@ fn run() -> Result<()> { let right = parse_path_with_position(&path[1])?; for diff_path in [&left, &right] { anyhow::ensure!( - Path::new(diff_path).exists(), + diff_path_exists(diff_path), "--diff path does not exist: {diff_path}" ); } diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 91c35a15bb69ae..f2892ba6114c04 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -19,7 +19,6 @@ test-support = ["clock/test-support", "collections/test-support", "gpui/test-sup anyhow.workspace = true async-channel.workspace = true async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots"] } -base64.workspace = true chrono = { workspace = true, features = ["serde"] } clock.workspace = true cloud_api_client.workspace = true @@ -35,11 +34,11 @@ gpui.workspace = true gpui_tokio.workspace = true http_client.workspace = true http_client_tls.workspace = true -httparse = "1.10" log.workspace = true parking_lot.workspace = true paths.workspace = true postage.workspace = true +proxy_handshake = { workspace = true, features = ["tokio"] } rand.workspace = true regex.workspace = true release_channel.workspace = true @@ -56,7 +55,6 @@ text.workspace = true thiserror.workspace = true time.workspace = true tiny_http.workspace = true -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 2c8dfa8ba47ef7..7273da72222c85 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -30,7 +30,7 @@ use gpui::{App, AsyncApp, Entity, Global, Task, TaskExt, WeakEntity, actions}; use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env}; use parking_lot::{Mutex, RwLock}; use postage::watch; -use proxy::connect_proxy_stream; +use proxy::{connect_proxy_stream, excluded_from_proxy}; use rand::prelude::*; use release_channel::{AppVersion, ReleaseChannel}; use rpc::proto::{AnyTypedEnvelope, EnvelopedMessage, PeerId, RequestMessage}; @@ -1362,8 +1362,10 @@ impl Client { .zip(rpc_url.port_or_known_default()) .context("missing host in rpc url")?; Ok(match proxy { - Some(proxy) => connect_proxy_stream(&proxy, rpc_host).await?, - None => Box::new(TcpStream::connect(rpc_host).await?), + Some(proxy) if !excluded_from_proxy(rpc_host.0) => { + connect_proxy_stream(&proxy, rpc_host).await? + } + _ => Box::new(TcpStream::connect(rpc_host).await?), }) } }) diff --git a/crates/client/src/proxy.rs b/crates/client/src/proxy.rs index eb3812ca07270b..53328b6e096e51 100644 --- a/crates/client/src/proxy.rs +++ b/crates/client/src/proxy.rs @@ -1,66 +1,92 @@ -//! client proxy - -mod http_proxy; -mod socks_proxy; +//! Proxied connections for the collaboration WebSocket. +//! +//! Proxy URLs come from settings or the environment (see +//! `ProxySettings::proxy_url`); this module dials the proxy, wraps the +//! connection in TLS when an `https://` proxy asks for it, and tunnels +//! through it with `proxy_handshake`. use anyhow::{Context as _, Result}; use http_client::Url; -use http_proxy::{HttpProxyType, connect_http_proxy_stream, parse_http_proxy}; -use socks_proxy::{SocksVersion, connect_socks_proxy_stream, parse_socks_proxy}; +use proxy_handshake::{ProxyScheme, ProxySpec, Target}; +use tokio::net::TcpStream; + +pub(crate) trait AsyncReadWrite: + tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static +{ +} +impl AsyncReadWrite + for T +{ +} + +/// Whether `NO_PROXY` in the environment excludes `host` from proxying, +/// matching the exclusions the HTTP client already applies to its own +/// requests. +pub(crate) fn excluded_from_proxy(host: &str) -> bool { + http_client::read_no_proxy_from_env() + .is_some_and(|no_proxy| proxy_handshake::no_proxy_matches(&no_proxy, host)) +} pub(crate) async fn connect_proxy_stream( proxy: &Url, rpc_host: (&str, u16), ) -> Result> { - let Some(((proxy_domain, proxy_port), proxy_type)) = parse_proxy_type(proxy) else { - // If parsing the proxy URL fails, we must avoid falling back to an insecure connection. - // SOCKS proxies are often used in contexts where security and privacy are critical, - // so any fallback could expose users to significant risks. - anyhow::bail!("Parsing proxy url failed"); + // If parsing the proxy URL fails, we must avoid falling back to an + // insecure connection. Proxies are often used in contexts where security + // and privacy are critical, so any fallback could expose users to + // significant risks. + let spec = ProxySpec::parse(proxy).context("parsing proxy URL")?; + + let target = if spec.remote_dns() { + Target::Domain(rpc_host.0.to_string(), rpc_host.1) + } else { + // SOCKS4 requests carry a raw IPv4 address, so the target must + // resolve to one. + let requires_ipv4 = matches!(spec.scheme, ProxyScheme::Socks4 { .. }); + let address = tokio::net::lookup_host(rpc_host) + .await + .with_context(|| format!("failed to lookup domain {}", rpc_host.0))? + .find(|address| !requires_ipv4 || address.is_ipv4()) + .with_context(|| format!("failed to lookup domain {}", rpc_host.0))?; + Target::Address(address) }; - // Connect to proxy and wrap protocol later - let stream = tokio::net::TcpStream::connect((proxy_domain.as_str(), proxy_port)) + let stream = TcpStream::connect((spec.host.as_str(), spec.port)) .await .context("Failed to connect to proxy")?; - let proxy_stream = match proxy_type { - ProxyType::SocksProxy(proxy) => connect_socks_proxy_stream(stream, proxy, rpc_host).await?, - ProxyType::HttpProxy(proxy) => { - connect_http_proxy_stream(stream, proxy, rpc_host, &proxy_domain).await? - } + let stream: Box = if spec.tls() { + Box::new(connect_tls_to_proxy(stream, &spec.host).await?) + } else { + Box::new(stream) }; - Ok(proxy_stream) -} - -enum ProxyType<'t> { - SocksProxy(SocksVersion<'t>), - HttpProxy(HttpProxyType<'t>), + let stream = proxy_handshake::tokio::establish(stream, &spec, &target) + .await + .context("error connecting through proxy")?; + Ok(Box::new(stream)) } -fn parse_proxy_type(proxy: &Url) -> Option<((String, u16), ProxyType<'_>)> { - let scheme = proxy.scheme(); - let host = proxy.host()?.to_string(); - let port = proxy.port_or_known_default()?; - let proxy_type = match scheme { - scheme if scheme.starts_with("socks") => { - Some(ProxyType::SocksProxy(parse_socks_proxy(scheme, proxy))) - } - scheme if scheme.starts_with("http") => { - Some(ProxyType::HttpProxy(parse_http_proxy(scheme, proxy))) - } - _ => None, - }?; +#[cfg(any(target_os = "windows", target_os = "macos"))] +async fn connect_tls_to_proxy( + stream: TcpStream, + proxy_domain: &str, +) -> Result> { + use tokio_native_tls::{TlsConnector, native_tls}; - Some(((host, port), proxy_type)) + let tls_connector = TlsConnector::from(native_tls::TlsConnector::new()?); + Ok(tls_connector.connect(proxy_domain, stream).await?) } -pub(crate) trait AsyncReadWrite: - tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static -{ -} -impl AsyncReadWrite - for T -{ +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +async fn connect_tls_to_proxy( + stream: TcpStream, + proxy_domain: &str, +) -> Result> { + let proxy_domain = rustls_pki_types::ServerName::try_from(proxy_domain) + .context("invalid DNS name for proxy TLS")? + .to_owned(); + let tls_connector = + tokio_rustls::TlsConnector::from(std::sync::Arc::new(http_client_tls::tls_config())); + Ok(tls_connector.connect(proxy_domain, stream).await?) } diff --git a/crates/client/src/proxy/http_proxy.rs b/crates/client/src/proxy/http_proxy.rs deleted file mode 100644 index f64c56b16ce527..00000000000000 --- a/crates/client/src/proxy/http_proxy.rs +++ /dev/null @@ -1,193 +0,0 @@ -use anyhow::{Context, Result}; -use base64::Engine; -use httparse::{EMPTY_HEADER, Response}; -use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufStream}, - net::TcpStream, -}; -#[cfg(any(target_os = "windows", target_os = "macos"))] -use tokio_native_tls::{TlsConnector, native_tls}; -#[cfg(not(any(target_os = "windows", target_os = "macos")))] -use tokio_rustls::TlsConnector; -use url::Url; - -use super::AsyncReadWrite; - -pub(super) enum HttpProxyType<'t> { - HTTP(Option>), - HTTPS(Option>), -} - -pub(super) struct HttpProxyAuthorization<'t> { - username: &'t str, - password: &'t str, -} - -pub(super) fn parse_http_proxy<'t>(scheme: &str, proxy: &'t Url) -> HttpProxyType<'t> { - let auth = proxy.password().map(|password| HttpProxyAuthorization { - username: proxy.username(), - password, - }); - if scheme.starts_with("https") { - HttpProxyType::HTTPS(auth) - } else { - HttpProxyType::HTTP(auth) - } -} - -pub(crate) async fn connect_http_proxy_stream( - stream: TcpStream, - http_proxy: HttpProxyType<'_>, - rpc_host: (&str, u16), - proxy_domain: &str, -) -> Result> { - match http_proxy { - HttpProxyType::HTTP(auth) => http_connect(stream, rpc_host, auth).await, - HttpProxyType::HTTPS(auth) => https_connect(stream, rpc_host, auth, proxy_domain).await, - } - .context("error connecting to http/https proxy") -} - -async fn http_connect( - stream: T, - target: (&str, u16), - auth: Option>, -) -> Result> -where - T: AsyncReadWrite, -{ - let mut stream = BufStream::new(stream); - let request = make_request(target, auth); - stream.write_all(request.as_bytes()).await?; - stream.flush().await?; - check_response(&mut stream).await?; - Ok(Box::new(stream)) -} - -#[cfg(any(target_os = "windows", target_os = "macos"))] -async fn https_connect( - stream: T, - target: (&str, u16), - auth: Option>, - proxy_domain: &str, -) -> Result> -where - T: AsyncReadWrite, -{ - let tls_connector = TlsConnector::from(native_tls::TlsConnector::new()?); - let stream = tls_connector.connect(proxy_domain, stream).await?; - http_connect(stream, target, auth).await -} - -#[cfg(not(any(target_os = "windows", target_os = "macos")))] -async fn https_connect( - stream: T, - target: (&str, u16), - auth: Option>, - proxy_domain: &str, -) -> Result> -where - T: AsyncReadWrite, -{ - let proxy_domain = rustls_pki_types::ServerName::try_from(proxy_domain) - .context("Address resolution failed")? - .to_owned(); - let tls_connector = TlsConnector::from(std::sync::Arc::new(http_client_tls::tls_config())); - let stream = tls_connector.connect(proxy_domain, stream).await?; - http_connect(stream, target, auth).await -} - -fn make_request(target: (&str, u16), auth: Option>) -> String { - let (host, port) = target; - let mut request = format!( - "CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\nProxy-Connection: Keep-Alive\r\n" - ); - if let Some(HttpProxyAuthorization { username, password }) = auth { - let auth = - base64::prelude::BASE64_STANDARD.encode(format!("{username}:{password}").as_bytes()); - let auth = format!("Proxy-Authorization: Basic {auth}\r\n"); - request.push_str(&auth); - } - request.push_str("\r\n"); - request -} - -async fn check_response(stream: &mut BufStream) -> Result<()> -where - T: AsyncReadWrite, -{ - let response = recv_response(stream).await?; - let mut dummy_headers = [EMPTY_HEADER; MAX_RESPONSE_HEADERS]; - let mut parser = Response::new(&mut dummy_headers); - parser.parse(response.as_bytes())?; - - match parser.code { - Some(code) => { - if code == 200 { - Ok(()) - } else { - Err(anyhow::anyhow!( - "Proxy connection failed with HTTP code: {code}" - )) - } - } - None => Err(anyhow::anyhow!( - "Proxy connection failed with no HTTP code: {}", - parser.reason.unwrap_or("Unknown reason") - )), - } -} - -const MAX_RESPONSE_HEADER_LENGTH: usize = 4096; -const MAX_RESPONSE_HEADERS: usize = 16; - -async fn recv_response(stream: &mut BufStream) -> Result -where - T: AsyncReadWrite, -{ - let mut response = String::new(); - loop { - if stream.read_line(&mut response).await? == 0 { - return Err(anyhow::anyhow!("End of stream")); - } - - if MAX_RESPONSE_HEADER_LENGTH < response.len() { - return Err(anyhow::anyhow!("Maximum response header length exceeded")); - } - - if response.ends_with("\r\n\r\n") { - return Ok(response); - } - } -} - -#[cfg(test)] -mod tests { - use url::Url; - - use super::{HttpProxyAuthorization, HttpProxyType, parse_http_proxy}; - - #[test] - fn test_parse_http_proxy() { - let proxy = Url::parse("http://proxy.example.com:1080").unwrap(); - let scheme = proxy.scheme(); - - let version = parse_http_proxy(scheme, &proxy); - assert!(matches!(version, HttpProxyType::HTTP(None))) - } - - #[test] - fn test_parse_http_proxy_with_auth() { - let proxy = Url::parse("http://username:password@proxy.example.com:1080").unwrap(); - let scheme = proxy.scheme(); - - let version = parse_http_proxy(scheme, &proxy); - assert!(matches!( - version, - HttpProxyType::HTTP(Some(HttpProxyAuthorization { - username: "username", - password: "password" - })) - )) - } -} diff --git a/crates/client/src/proxy/socks_proxy.rs b/crates/client/src/proxy/socks_proxy.rs deleted file mode 100644 index bf2a5eab627cba..00000000000000 --- a/crates/client/src/proxy/socks_proxy.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! socks proxy - -use anyhow::{Context as _, Result}; -use http_client::Url; -use tokio::net::TcpStream; -use tokio_socks::{ - IntoTargetAddr, TargetAddr, - tcp::{Socks4Stream, Socks5Stream}, -}; - -use super::AsyncReadWrite; - -/// Identification to a Socks V4 Proxy -pub(super) struct Socks4Identification<'a> { - user_id: &'a str, -} - -/// Authorization to a Socks V5 Proxy -pub(super) struct Socks5Authorization<'a> { - username: &'a str, - password: &'a str, -} - -/// Socks Proxy Protocol Version -/// -/// V4 allows identification using a user_id -/// V5 allows authorization using a username and password -pub(super) enum SocksVersion<'a> { - V4 { - local_dns: bool, - identification: Option>, - }, - V5 { - local_dns: bool, - authorization: Option>, - }, -} - -pub(super) fn parse_socks_proxy<'t>(scheme: &str, proxy: &'t Url) -> SocksVersion<'t> { - if scheme.starts_with("socks4") { - let identification = match proxy.username() { - "" => None, - username => Some(Socks4Identification { user_id: username }), - }; - SocksVersion::V4 { - local_dns: scheme != "socks4a", - identification, - } - } else { - let authorization = proxy.password().map(|password| Socks5Authorization { - username: proxy.username(), - password, - }); - SocksVersion::V5 { - local_dns: scheme != "socks5h", - authorization, - } - } -} - -pub(super) async fn connect_socks_proxy_stream( - stream: TcpStream, - socks_version: SocksVersion<'_>, - rpc_host: (&str, u16), -) -> Result> { - let rpc_host = rpc_host - .into_target_addr() - .context("Failed to parse target addr")?; - - let local_dns = match &socks_version { - SocksVersion::V4 { local_dns, .. } => local_dns, - SocksVersion::V5 { local_dns, .. } => local_dns, - }; - let rpc_host = match (rpc_host, local_dns) { - (TargetAddr::Domain(domain, port), true) => { - let ip_addr = tokio::net::lookup_host((domain.as_ref(), port)) - .await - .with_context(|| format!("Failed to lookup domain {}", domain))? - .next() - .ok_or_else(|| anyhow::anyhow!("Failed to lookup domain {}", domain))?; - TargetAddr::Ip(ip_addr) - } - (rpc_host, _) => rpc_host, - }; - - match socks_version { - SocksVersion::V4 { - identification: None, - .. - } => { - let socks = Socks4Stream::connect_with_socket(stream, rpc_host) - .await - .context("error connecting to socks")?; - Ok(Box::new(socks)) - } - SocksVersion::V4 { - identification: Some(Socks4Identification { user_id }), - .. - } => { - let socks = Socks4Stream::connect_with_userid_and_socket(stream, rpc_host, user_id) - .await - .context("error connecting to socks")?; - Ok(Box::new(socks)) - } - SocksVersion::V5 { - authorization: None, - .. - } => { - let socks = Socks5Stream::connect_with_socket(stream, rpc_host) - .await - .context("error connecting to socks")?; - Ok(Box::new(socks)) - } - SocksVersion::V5 { - authorization: Some(Socks5Authorization { username, password }), - .. - } => { - let socks = Socks5Stream::connect_with_password_and_socket( - stream, rpc_host, username, password, - ) - .await - .context("error connecting to socks")?; - Ok(Box::new(socks)) - } - } -} - -#[cfg(test)] -mod tests { - use url::Url; - - use super::*; - - #[test] - fn parse_socks4() { - let proxy = Url::parse("socks4://proxy.example.com:1080").unwrap(); - let scheme = proxy.scheme(); - - let version = parse_socks_proxy(scheme, &proxy); - assert!(matches!( - version, - SocksVersion::V4 { - local_dns: true, - identification: None - } - )) - } - - #[test] - fn parse_socks4_with_identification() { - let proxy = Url::parse("socks4://userid@proxy.example.com:1080").unwrap(); - let scheme = proxy.scheme(); - - let version = parse_socks_proxy(scheme, &proxy); - assert!(matches!( - version, - SocksVersion::V4 { - local_dns: true, - identification: Some(Socks4Identification { user_id: "userid" }) - } - )) - } - - #[test] - fn parse_socks4_with_remote_dns() { - let proxy = Url::parse("socks4a://proxy.example.com:1080").unwrap(); - let scheme = proxy.scheme(); - - let version = parse_socks_proxy(scheme, &proxy); - assert!(matches!( - version, - SocksVersion::V4 { - local_dns: false, - identification: None - } - )) - } - - #[test] - fn parse_socks5() { - let proxy = Url::parse("socks5://proxy.example.com:1080").unwrap(); - let scheme = proxy.scheme(); - - let version = parse_socks_proxy(scheme, &proxy); - assert!(matches!( - version, - SocksVersion::V5 { - local_dns: true, - authorization: None - } - )) - } - - #[test] - fn parse_socks5_with_authorization() { - let proxy = Url::parse("socks5://username:password@proxy.example.com:1080").unwrap(); - let scheme = proxy.scheme(); - - let version = parse_socks_proxy(scheme, &proxy); - assert!(matches!( - version, - SocksVersion::V5 { - local_dns: true, - authorization: Some(Socks5Authorization { - username: "username", - password: "password" - }) - } - )) - } - - #[test] - fn parse_socks5_with_remote_dns() { - let proxy = Url::parse("socks5h://proxy.example.com:1080").unwrap(); - let scheme = proxy.scheme(); - - let version = parse_socks_proxy(scheme, &proxy); - assert!(matches!( - version, - SocksVersion::V5 { - local_dns: false, - authorization: None - } - )) - } -} diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index ab9e1643fc98e9..a08dfb44bd6098 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -95,6 +95,13 @@ pub static MINIDUMP_ENDPOINT: LazyLock> = LazyLock::new(|| { .or_else(|| env::var("ZED_MINIDUMP_ENDPOINT").ok()) }); +pub fn should_install_crash_handler(channel: ReleaseChannel) -> bool { + matches!( + env::var("ZED_GENERATE_MINIDUMPS").as_deref(), + Ok("true" | "1") + ) || (channel != ReleaseChannel::Dev && MINIDUMP_ENDPOINT.is_some()) +} + static DOTNET_PROJECT_FILES_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"^(global\.json|Directory\.Build\.props|.*\.(csproj|fsproj|vbproj|sln))$").unwrap() }); @@ -156,18 +163,7 @@ pub fn os_version() -> String { ); "".to_string() }; - let mut name = "unknown"; - let mut version = "unknown"; - - for line in content.lines() { - match line.split_once('=') { - Some(("ID", val)) => name = val.trim_matches('"'), - Some(("VERSION_ID", val)) => version = val.trim_matches('"'), - _ => {} - } - } - - format!("{} {}", name, version) + util::parse_os_release(&content).unwrap_or_else(|| "unknown".to_string()) } target_os = "windows" => { let mut info = unsafe { std::mem::zeroed() }; @@ -512,6 +508,61 @@ impl Telemetry { Some(project_types) } + /// Report a telemetry event that originated on a remote server. + /// + /// The remote server cannot upload telemetry itself, so it forwards events + /// (as a JSON-serialized [`Event`]) to the client. Since the OS metadata in + /// [`EventRequestBody`] is batch-level (describing the uploading client), + /// the remote server's OS is attached as event properties instead, so the + /// origin can still be distinguished downstream. + pub fn report_remote_event( + self: &Arc, + event_json: &str, + connection_type: &str, + os_name: String, + os_version: Option, + architecture: String, + ) -> Result<()> { + // The remote server forwards a bare `telemetry_events::FlexibleEvent` + // (the type behind `telemetry::event!`), not the tagged `Event` enum. + let mut flexible: telemetry_events::FlexibleEvent = + serde_json::from_str(event_json).context("invalid remote telemetry event")?; + flexible + .event_properties + .insert("remote".into(), true.into()); + flexible + .event_properties + .insert("remote_connection_type".into(), connection_type.into()); + flexible + .event_properties + .insert("remote_os_name".into(), os_name.into()); + flexible + .event_properties + .insert("remote_architecture".into(), architecture.into()); + if let Some(os_version) = os_version { + flexible + .event_properties + .insert("remote_os_version".into(), os_version.into()); + } + self.report_event(Event::Flexible(flexible)); + Ok(()) + } + + /// Returns a snapshot of the currently queued (not-yet-flushed) telemetry + /// events, for use in tests. + #[cfg(any(test, feature = "test-support"))] + pub fn queued_events(self: &Arc) -> Vec { + self.state + .lock() + .events_queue + .iter() + .map(|wrapper| { + let Event::Flexible(event) = &wrapper.event; + event.clone() + }) + .collect() + } + fn report_event(self: &Arc, mut event: Event) { let mut state = self.state.lock(); // RUST_LOG=telemetry=trace to debug telemetry events @@ -822,6 +873,79 @@ mod tests { }); } + #[gpui::test] + async fn test_report_remote_event_tags_origin(cx: &mut TestAppContext) { + init_test(cx); + let clock = Arc::new(FakeSystemClock::new()); + let http = FakeHttpClient::with_200_response(); + + let telemetry = cx.update(|cx| { + let telemetry = Telemetry::new(clock.clone(), http, cx); + telemetry.start( + Some("system_id".to_string()), + Some("installation_id".to_string()), + "session_id".to_string(), + cx, + ); + telemetry + }); + + // Mirror what the remote server forwards: a bare `FlexibleEvent`, which + // is the type produced by `telemetry::event!` / sent over the queue. + let event_json = serde_json::to_string(&FlexibleEvent { + event_type: "fs_watcher_poll".to_string(), + event_properties: HashMap::from_iter([( + "path".to_string(), + serde_json::Value::String("/code/project".to_string()), + )]), + }) + .unwrap(); + + cx.update(|_| { + telemetry + .report_remote_event( + &event_json, + "ssh", + "Linux".to_string(), + Some("ubuntu 24.04".to_string()), + "aarch64".to_string(), + ) + .unwrap(); + }); + + let queue = telemetry.state.lock().events_queue.clone(); + assert_eq!(queue.len(), 1); + let Event::Flexible(event) = &queue[0].event; + assert_eq!(event.event_type, "fs_watcher_poll"); + // Original properties are preserved. + assert_eq!( + event.event_properties.get("path"), + Some(&serde_json::Value::String("/code/project".to_string())) + ); + // The remote server's OS is attached as properties, since the batch-level + // OS describes the uploading client rather than the remote host. + assert_eq!( + event.event_properties.get("remote"), + Some(&serde_json::Value::Bool(true)) + ); + assert_eq!( + event.event_properties.get("remote_connection_type"), + Some(&serde_json::Value::String("ssh".to_string())) + ); + assert_eq!( + event.event_properties.get("remote_os_name"), + Some(&serde_json::Value::String("Linux".to_string())) + ); + assert_eq!( + event.event_properties.get("remote_os_version"), + Some(&serde_json::Value::String("ubuntu 24.04".to_string())) + ); + assert_eq!( + event.event_properties.get("remote_architecture"), + Some(&serde_json::Value::String("aarch64".to_string())) + ); + } + #[gpui::test] fn test_project_discovery_does_not_double_report(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -960,7 +1084,7 @@ mod tests { .enumerate() .filter_map(|(i, path)| { Some(( - Arc::from(RelPath::unix(path).ok()?), + Arc::from(RelPath::from_unix_str(path).ok()?), ProjectEntryId::from_proto(i as u64 + 1), PathChange::Added, )) diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index 19ed333d2a6e48..1770bce23022af 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -236,14 +236,16 @@ pub fn parse_authorization_header(req: &Request) -> Option GetAuthenticatedUserResponse { GetAuthenticatedUserResponse { user: AuthenticatedUser { - id: user_id, + id_v2: format!("user_{user_id}"), + legacy_user_id: user_id, metrics_id: format!("metrics-id-{user_id}"), + username: username.clone(), avatar_url: "".to_string(), - github_login, + github_login: username, name: None, is_staff: false, accepted_tos_at: None, diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index c05518cdbacb69..898a810e533406 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -56,7 +56,7 @@ pub struct ParticipantIndex(pub u32); #[derive(Default, Debug)] pub struct User { pub legacy_id: LegacyUserId, - pub github_login: SharedString, + pub username: SharedString, pub avatar_uri: SharedUri, pub name: Option, } @@ -79,13 +79,13 @@ impl PartialOrd for User { impl Ord for User { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.github_login.cmp(&other.github_login) + self.username.cmp(&other.username) } } impl PartialEq for User { fn eq(&self, other: &Self) -> bool { - self.legacy_id == other.legacy_id && self.github_login == other.github_login + self.legacy_id == other.legacy_id && self.username == other.username } } @@ -108,7 +108,6 @@ pub enum ContactRequestStatus { pub struct UserStore { users: HashMap>, - by_github_login: HashMap, participant_indices: HashMap, update_contacts_tx: mpsc::UnboundedSender, edit_prediction_usage: Option, @@ -189,7 +188,6 @@ impl UserStore { Self { users: Default::default(), - by_github_login: Default::default(), current_user: current_user_rx, current_organization: None, organizations: Vec::new(), @@ -239,7 +237,7 @@ impl UserStore { let current_user_and_response = if let Some(response) = response { let user = Arc::new(User { legacy_id: user_id, - github_login: response.user.github_login.clone().into(), + username: response.user.username.clone().into(), avatar_uri: response.user.avatar_url.clone().into(), name: response.user.name.clone(), }); @@ -260,8 +258,6 @@ impl UserStore { cx.update(|cx| { if let Some((user, response)) = current_user_and_response { this.update(cx, |this, cx| { - this.by_github_login - .insert(user.github_login.clone(), user_id); this.users.insert(user_id, user); this.update_authenticated_user(response, cx) }) @@ -317,7 +313,6 @@ impl UserStore { #[cfg(feature = "test-support")] pub fn clear_cache(&mut self) { self.users.clear(); - self.by_github_login.clear(); } async fn handle_show_contacts( @@ -405,10 +400,11 @@ impl UserStore { .retain(|contact| !removed_contacts.contains(&contact.user.legacy_id)); // Update existing contacts and insert new ones for updated_contact in updated_contacts { - match this.contacts.binary_search_by_key( - &&updated_contact.user.github_login, - |contact| &contact.user.github_login, - ) { + match this + .contacts + .binary_search_by_key(&&updated_contact.user.username, |contact| { + &contact.user.username + }) { Ok(ix) => this.contacts[ix] = updated_contact, Err(ix) => this.contacts.insert(ix, updated_contact), } @@ -430,9 +426,8 @@ impl UserStore { for user in incoming_requests { match this .incoming_contact_requests - .binary_search_by_key(&&user.github_login, |contact| { - &contact.github_login - }) { + .binary_search_by_key(&&user.username, |contact| &contact.username) + { Ok(ix) => this.incoming_contact_requests[ix] = user, Err(ix) => this.incoming_contact_requests.insert(ix, user), } @@ -445,8 +440,8 @@ impl UserStore { for request in outgoing_requests { match this .outgoing_contact_requests - .binary_search_by_key(&&request.github_login, |contact| { - &contact.github_login + .binary_search_by_key(&&request.username, |contact| { + &contact.username }) { Ok(ix) => this.outgoing_contact_requests[ix] = request, Err(ix) => this.outgoing_contact_requests.insert(ix, request), @@ -468,7 +463,7 @@ impl UserStore { pub fn has_contact(&self, user: &Arc) -> bool { self.contacts - .binary_search_by_key(&&user.github_login, |contact| &contact.user.github_login) + .binary_search_by_key(&&user.username, |contact| &contact.user.username) .is_ok() } @@ -487,19 +482,19 @@ impl UserStore { pub fn contact_request_status(&self, user: &User) -> ContactRequestStatus { if self .contacts - .binary_search_by_key(&&user.github_login, |contact| &contact.user.github_login) + .binary_search_by_key(&&user.username, |contact| &contact.user.username) .is_ok() { ContactRequestStatus::RequestAccepted } else if self .outgoing_contact_requests - .binary_search_by_key(&&user.github_login, |user| &user.github_login) + .binary_search_by_key(&&user.username, |user| &user.username) .is_ok() { ContactRequestStatus::RequestSent } else if self .incoming_contact_requests - .binary_search_by_key(&&user.github_login, |user| &user.github_login) + .binary_search_by_key(&&user.username, |user| &user.username) .is_ok() { ContactRequestStatus::RequestReceived @@ -688,12 +683,6 @@ impl UserStore { }) } - pub fn cached_user_by_github_login(&self, github_login: &str) -> Option> { - self.by_github_login - .get(github_login) - .and_then(|id| self.users.get(id).cloned()) - } - pub fn current_user(&self) -> Option> { self.current_user.borrow().clone() } @@ -968,13 +957,7 @@ impl UserStore { let mut ret = Vec::with_capacity(users.len()); for user in users { let user = User::new(user); - if let Some(old) = self.users.insert(user.legacy_id, user.clone()) - && old.github_login != user.github_login - { - self.by_github_login.remove(&old.github_login); - } - self.by_github_login - .insert(user.github_login.clone(), user.legacy_id); + self.users.insert(user.legacy_id, user.clone()); ret.push(user) } ret @@ -1003,8 +986,8 @@ impl UserStore { let mut ret = HashMap::default(); let mut missing_user_ids = Vec::new(); for id in user_ids { - if let Some(github_login) = self.get_cached_user(id).map(|u| u.github_login.clone()) { - ret.insert(id, github_login); + if let Some(username) = self.get_cached_user(id).map(|u| u.username.clone()) { + ret.insert(id, username); } else { missing_user_ids.push(id) } @@ -1025,7 +1008,7 @@ impl User { fn new(message: proto::User) -> Arc { Arc::new(User { legacy_id: message.id, - github_login: message.github_login.into(), + username: message.username.into(), avatar_uri: message.avatar_url.into(), name: message.name, }) diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 0473ff3932aa56..d9cd62552265d4 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -69,6 +69,27 @@ pub fn skills_docs(cx: &App) -> String { format!("{docs_url}/ai/skills", docs_url = docs_url(cx)) } +/// Returns the URL to Zed's Agent sandboxing documentation. +/// +/// Pass `section` to deep-link to a specific section anchor on the page (for +/// example, `Some("installing-bubblewrap")`); pass `None` to link to the top of +/// the page. +/// +/// Unlike the account/app links above, this targets `zed.dev/docs` (via +/// [`release_channel::docs_url`]) rather than the configured `server_url`: the +/// docs are a static site hosted on `zed.dev`, so pointing at a local dev +/// `server_url` would 404. +pub fn sandboxing_docs(section: Option<&str>, cx: &App) -> String { + let base = release_channel::docs_url("ai/sandboxing", cx); + match section { + Some(section) => format!("{base}#{section}"), + None => base, + } +} +pub fn llm_provider_docs(cx: &App) -> String { + format!("{docs_url}/ai/llm-providers", docs_url = docs_url(cx)) +} + /// Returns the URL to Zed's ACP registry blog post. pub fn acp_registry_blog(cx: &App) -> String { format!( diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml index baca3c1ce6f52d..632cb9d5f356f5 100644 --- a/crates/cloud_api_client/Cargo.toml +++ b/crates/cloud_api_client/Cargo.toml @@ -16,7 +16,6 @@ anyhow.workspace = true cloud_api_types.workspace = true futures.workspace = true gpui.workspace = true -gpui_tokio.workspace = true http_client.workspace = true parking_lot.workspace = true serde.workspace = true @@ -24,3 +23,6 @@ serde_json.workspace = true async-lock.workspace = true thiserror.workspace = true yawc.workspace = true + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +gpui_tokio.workspace = true diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index e2f9d69002e236..b98b6c60c8fd05 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -3,12 +3,9 @@ mod websocket; use std::sync::Arc; -use anyhow::{Context, Result, anyhow}; -use cloud_api_types::websocket_protocol::{PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER_NAME}; +use anyhow::{Result, anyhow}; pub use cloud_api_types::*; use futures::AsyncReadExt as _; -use gpui::{App, Task}; -use gpui_tokio::Tokio; use http_client::http::request; use http_client::{ AsyncBody, HttpClientWithUrl, HttpRequestExt, Json, Method, Request, Response, StatusCode, @@ -16,9 +13,6 @@ use http_client::{ use parking_lot::RwLock; use serde::de::DeserializeOwned; use thiserror::Error; -use yawc::WebSocket; - -use crate::websocket::Connection; pub use llm_token::LlmApiToken; @@ -27,6 +21,12 @@ struct Credentials { access_token: String, } +#[derive(Clone, Copy)] +enum Authentication { + Credentials, + Session, +} + #[derive(Debug, Error)] pub enum ClientApiError { /// 401 — credentials are invalid or expired. @@ -62,6 +62,7 @@ pub enum ClientApiError { pub struct CloudApiClient { credentials: RwLock>, http_client: Arc, + authentication: Authentication, } impl CloudApiClient { @@ -69,6 +70,15 @@ impl CloudApiClient { Self { credentials: RwLock::new(None), http_client, + authentication: Authentication::Credentials, + } + } + + pub fn new_with_session_authentication(http_client: Arc) -> Self { + Self { + credentials: RwLock::new(None), + http_client, + authentication: Authentication::Session, } } @@ -87,7 +97,7 @@ impl CloudApiClient { *self.credentials.write() = None; } - fn cloud_host(&self) -> String { + pub fn cloud_host(&self) -> String { self.http_client .build_zed_cloud_url("/") .ok() @@ -95,16 +105,6 @@ impl CloudApiClient { .unwrap_or_else(|| "cloud.zed.dev".into()) } - fn build_request( - &self, - req: request::Builder, - body: impl Into, - ) -> Result, ClientApiError> { - let credentials = self.credentials.read(); - let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?; - build_request(req, body, credentials).map_err(ClientApiError::RequestBuildFailed) - } - pub async fn get_authenticated_user( &self, system_id: Option, @@ -121,37 +121,8 @@ impl CloudApiClient { builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id) }); - let request = self.build_request(request_builder, AsyncBody::default())?; - self.send_authenticated_json_request(request).await - } - - pub fn connect(&self, cx: &App) -> Result>> { - let mut connect_url = self - .http_client - .build_zed_cloud_url("/client/users/connect")?; - connect_url - .set_scheme(match connect_url.scheme() { - "https" => "wss", - "http" => "ws", - scheme => Err(anyhow!("invalid URL scheme: {scheme}"))?, - }) - .map_err(|_| anyhow!("failed to set URL scheme"))?; - - let credentials = self.credentials.read(); - let credentials = credentials.as_ref().context("no credentials provided")?; - let authorization_header = format!("{} {}", credentials.user_id, credentials.access_token); - - Ok(Tokio::spawn_result(cx, async move { - let ws = WebSocket::connect(connect_url) - .with_request( - request::Builder::new() - .header("Authorization", authorization_header) - .header(PROTOCOL_VERSION_HEADER_NAME, PROTOCOL_VERSION.to_string()), - ) - .await?; - - Ok(Connection::new(ws)) - })) + self.send_authenticated_json_request(request_builder, AsyncBody::default()) + .await } async fn create_llm_token( @@ -171,11 +142,11 @@ impl CloudApiClient { builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id) }); - let request = self.build_request( + self.send_authenticated_json_request( request_builder, Json(CreateLlmTokenBody { organization_id }), - )?; - self.send_authenticated_json_request(request).await + ) + .await } pub async fn update_system_settings( @@ -193,22 +164,36 @@ impl CloudApiClient { ) .header(ZED_SYSTEM_ID_HEADER_NAME, system_id); - let request = self.build_request(request_builder, Json(body))?; - self.send_authenticated_json_request(request).await + self.send_authenticated_json_request(request_builder, Json(body)) + .await } - async fn send_authenticated_json_request( + pub async fn send_authenticated_json_request( &self, - request: Request, + request_builder: request::Builder, + body: impl Into, ) -> Result { - let mut response = self.send_authenticated_request(request).await?; + let mut response = self + .send_authenticated_request(request_builder, body) + .await?; Self::read_response_json(&mut response).await } async fn send_authenticated_request( &self, - request: Request, + request_builder: request::Builder, + body: impl Into, ) -> Result, ClientApiError> { + let request = if matches!(self.authentication, Authentication::Session) { + build_request(request_builder, body, None) + .map_err(ClientApiError::RequestBuildFailed)? + } else { + let credentials = self.credentials.read(); + let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?; + build_request(request_builder, body, Some(credentials)) + .map_err(ClientApiError::RequestBuildFailed)? + }; + let host = self.cloud_host(); let mut response = self.http_client.send(request).await.map_err(|source| { ClientApiError::ConnectionFailed { @@ -260,10 +245,10 @@ impl CloudApiClient { .as_ref(), ), AsyncBody::default(), - &Credentials { + Some(&Credentials { user_id, access_token: access_token.into(), - }, + }), )?; let mut response = self.http_client.send(request).await?; @@ -285,16 +270,14 @@ impl CloudApiClient { } pub async fn submit_agent_feedback(&self, body: SubmitAgentThreadFeedbackBody) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/agent_thread")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; - - self.send_authenticated_request(request).await?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/agent_thread")? + .as_ref(), + ); + + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } @@ -302,16 +285,14 @@ impl CloudApiClient { &self, body: SubmitAgentThreadFeedbackCommentsBody, ) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/agent_thread_comments")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; - - self.send_authenticated_request(request).await?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/agent_thread_comments")? + .as_ref(), + ); + + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } @@ -319,16 +300,14 @@ impl CloudApiClient { &self, body: SubmitEditPredictionFeedbackBody, ) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/edit_prediction")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; - - self.send_authenticated_request(request).await?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/edit_prediction")? + .as_ref(), + ); + + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } } @@ -336,13 +315,60 @@ impl CloudApiClient { fn build_request( req: request::Builder, body: impl Into, - credentials: &Credentials, + credentials: Option<&Credentials>, ) -> Result> { Ok(req .header("Content-Type", "application/json") - .header( - "Authorization", - format!("{} {}", credentials.user_id, credentials.access_token), - ) + .when_some(credentials, |request, credentials| { + request.header( + "Authorization", + format!("{} {}", credentials.user_id, credentials.access_token), + ) + }) .body(body.into())?) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_session_authenticated_request_without_authorization_header() -> Result<()> { + let request = build_request( + Request::builder().uri("https://cloud.zed.dev/client/users/me"), + AsyncBody::default(), + None, + )?; + + assert_eq!( + request + .headers() + .get("Content-Type") + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert!(!request.headers().contains_key("Authorization")); + Ok(()) + } + + #[test] + fn build_credentials_authenticated_request_with_authorization_header() -> Result<()> { + let request = build_request( + Request::builder().uri("https://cloud.zed.dev/client/users/me"), + AsyncBody::default(), + Some(&Credentials { + user_id: 123, + access_token: "token".into(), + }), + )?; + + assert_eq!( + request + .headers() + .get("Authorization") + .and_then(|value| value.to_str().ok()), + Some("123 token") + ); + Ok(()) + } +} diff --git a/crates/cloud_api_client/src/websocket.rs b/crates/cloud_api_client/src/websocket.rs index 48a628db78b2fe..9f4c200ed7e5eb 100644 --- a/crates/cloud_api_client/src/websocket.rs +++ b/crates/cloud_api_client/src/websocket.rs @@ -1,73 +1,24 @@ use std::pin::Pin; -use std::time::Duration; use anyhow::Result; use cloud_api_types::websocket_protocol::MessageToClient; -use futures::channel::mpsc::unbounded; -use futures::stream::{SplitSink, SplitStream}; -use futures::{FutureExt as _, SinkExt as _, Stream, StreamExt as _, TryStreamExt as _, pin_mut}; -use gpui::{App, BackgroundExecutor, Task}; -use yawc::WebSocket; -use yawc::frame::{FrameView, OpCode}; +use futures::Stream; +use futures::channel::mpsc::UnboundedSender; +use yawc::frame::{Frame, OpCode}; -const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(1); +#[cfg(not(target_family = "wasm"))] +mod native; +#[cfg(target_family = "wasm")] +mod web; pub type MessageStream = Pin>>>; -pub struct Connection { - tx: SplitSink, - rx: SplitStream, -} - -impl Connection { - pub fn new(ws: WebSocket) -> Self { - let (tx, rx) = ws.split(); - - Self { tx, rx } - } - - pub fn spawn(self, cx: &App) -> (MessageStream, Task<()>) { - let (mut tx, rx) = (self.tx, self.rx); - - let (message_tx, message_rx) = unbounded(); - - let handle_io = |executor: BackgroundExecutor| async move { - // Send messages on this frequency so the connection isn't closed. - let keepalive_timer = executor.timer(KEEPALIVE_INTERVAL).fuse(); - futures::pin_mut!(keepalive_timer); - - let rx = rx.fuse(); - pin_mut!(rx); - - loop { - futures::select_biased! { - _ = keepalive_timer => { - let _ = tx.send(FrameView::ping(Vec::new())).await; - - keepalive_timer.set(executor.timer(KEEPALIVE_INTERVAL).fuse()); - } - frame = rx.next() => { - let Some(frame) = frame else { - break; - }; - - match frame.opcode { - OpCode::Binary => { - let message_result = MessageToClient::deserialize(&frame.payload); - message_tx.unbounded_send(message_result).ok(); - } - OpCode::Close => { - break; - } - _ => {} - } - } - } - } - }; - - let task = cx.spawn(async move |cx| handle_io(cx.background_executor().clone()).await); - - (message_rx.into_stream().boxed(), task) +fn forward_frame(frame: Frame, message_tx: &UnboundedSender>) -> bool { + match frame.opcode() { + OpCode::Binary => message_tx + .unbounded_send(MessageToClient::deserialize(frame.payload())) + .is_ok(), + OpCode::Close => false, + OpCode::Continuation | OpCode::Text | OpCode::Ping | OpCode::Pong => true, } } diff --git a/crates/cloud_api_client/src/websocket/native.rs b/crates/cloud_api_client/src/websocket/native.rs new file mode 100644 index 00000000000000..d19da9f0027e64 --- /dev/null +++ b/crates/cloud_api_client/src/websocket/native.rs @@ -0,0 +1,91 @@ +use std::time::Duration; + +use anyhow::{Context as _, Result, anyhow}; +use cloud_api_types::websocket_protocol::{PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER_NAME}; +use futures::channel::mpsc::unbounded; +use futures::stream::{SplitSink, SplitStream}; +use futures::{FutureExt as _, SinkExt as _, StreamExt as _, TryStreamExt as _}; +use gpui::{App, Task}; +use http_client::http::request; +use yawc::frame::Frame; +use yawc::{TcpWebSocket, WebSocket}; + +use super::{MessageStream, forward_frame}; +use crate::CloudApiClient; + +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(1); + +pub struct Connection { + tx: SplitSink, + rx: SplitStream, +} + +impl Connection { + fn new(websocket: TcpWebSocket) -> Self { + let (tx, rx) = websocket.split(); + Self { tx, rx } + } + + pub fn spawn(self, cx: &App) -> (MessageStream, Task<()>) { + let mut tx = self.tx; + let rx = self.rx.fuse(); + let (message_tx, message_rx) = unbounded(); + let executor = cx.background_executor().clone(); + let task = cx.spawn(async move |_cx| { + let keepalive_timer = executor.timer(KEEPALIVE_INTERVAL).fuse(); + futures::pin_mut!(keepalive_timer, rx); + + loop { + futures::select_biased! { + _ = keepalive_timer => { + if tx.send(Frame::ping(Vec::new())).await.is_err() { + break; + } + keepalive_timer.set(executor.timer(KEEPALIVE_INTERVAL).fuse()); + } + frame = rx.next() => { + let Some(frame) = frame else { + break; + }; + if !forward_frame(frame, &message_tx) { + break; + } + } + } + } + }); + + (message_rx.into_stream().boxed(), task) + } +} + +impl CloudApiClient { + pub fn connect(self: &std::sync::Arc, cx: &App) -> Result>> { + let mut connect_url = self + .http_client + .build_zed_cloud_url("/client/users/connect")?; + connect_url + .set_scheme(match connect_url.scheme() { + "https" => "wss", + "http" => "ws", + scheme => Err(anyhow!("invalid URL scheme: {scheme}"))?, + }) + .map_err(|_| anyhow!("failed to set URL scheme"))?; + + let credentials = self.credentials.read(); + let credentials = credentials.as_ref().context("no credentials provided")?; + let authorization_header = format!("{} {}", credentials.user_id, credentials.access_token); + + Ok(gpui_tokio::Tokio::spawn_result(cx, async move { + let websocket = WebSocket::connect(connect_url) + .with_request( + request::Builder::new() + .header("Authorization", authorization_header) + .header(PROTOCOL_VERSION_HEADER_NAME, PROTOCOL_VERSION.to_string()), + ) + .await?; + + Ok(Connection::new(websocket)) + })) + } +} diff --git a/crates/cloud_api_client/src/websocket/web.rs b/crates/cloud_api_client/src/websocket/web.rs new file mode 100644 index 00000000000000..223791cb9a2700 --- /dev/null +++ b/crates/cloud_api_client/src/websocket/web.rs @@ -0,0 +1,79 @@ +use std::time::Duration; + +use anyhow::{Result, anyhow}; + +use futures::channel::mpsc::unbounded; +use futures::stream::SplitStream; +use futures::{FutureExt as _, StreamExt as _, TryStreamExt as _}; +use gpui::{App, Task}; +use yawc::WebSocket; + +use super::{MessageStream, forward_frame}; +use crate::CloudApiClient; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +pub struct Connection { + rx: SplitStream, +} + +impl Connection { + fn new(websocket: WebSocket) -> Self { + let (_, rx) = websocket.split(); + Self { rx } + } + + pub fn spawn(self, cx: &App) -> (MessageStream, Task<()>) { + let mut rx = self.rx; + let (message_tx, message_rx) = unbounded(); + let task = cx.spawn(async move |_cx| { + while let Some(frame) = rx.next().await { + let frame = match frame { + Ok(frame) => frame, + Err(error) => { + let error = anyhow!("Cloud WebSocket error: {error}"); + if message_tx.unbounded_send(Err(error)).is_err() { + break; + } + continue; + } + }; + if !forward_frame(frame, &message_tx) { + break; + } + } + }); + + (message_rx.into_stream().boxed(), task) + } +} + +impl CloudApiClient { + pub fn connect(self: &std::sync::Arc, cx: &App) -> Result>> { + let client = self.clone(); + let executor = cx.background_executor().clone(); + Ok(cx.spawn(async move |_cx| { + let mut connect_url = client + .http_client + .build_zed_cloud_url("/client/users/connect")?; + connect_url + .set_scheme(match connect_url.scheme() { + "https" => "wss", + "http" => "ws", + scheme => return Err(anyhow!("invalid URL scheme: {scheme}")), + }) + .map_err(|_| anyhow!("failed to set URL scheme"))?; + + + let connect = WebSocket::connect(connect_url).fuse(); + let timeout = executor.timer(CONNECT_TIMEOUT).fuse(); + futures::pin_mut!(connect, timeout); + let websocket = futures::select_biased! { + result = connect => result.map_err(|error| anyhow!("failed to connect to Cloud WebSocket: {error}"))?, + _ = timeout => return Err(anyhow!("timed out connecting to Cloud WebSocket")), + }; + + Ok(Connection::new(websocket)) + })) + } +} diff --git a/crates/cloud_api_types/Cargo.toml b/crates/cloud_api_types/Cargo.toml index 66080cf9ec13e3..a220f688f515a8 100644 --- a/crates/cloud_api_types/Cargo.toml +++ b/crates/cloud_api_types/Cargo.toml @@ -19,7 +19,6 @@ cloud_llm_client.workspace = true serde.workspace = true serde_json.workspace = true strum.workspace = true -uuid.workspace = true zeta_prompt.workspace = true [dev-dependencies] diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 27fd96b9660e7e..b02f5756cce385 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -6,6 +6,7 @@ mod timestamp; pub mod websocket_protocol; use std::collections::BTreeMap; +use std::ops::Range; use std::path::Path; use std::sync::Arc; @@ -35,8 +36,10 @@ pub struct GetAuthenticatedUserResponse { #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct AuthenticatedUser { - pub id: i32, + pub id_v2: String, + pub legacy_user_id: i32, pub metrics_id: String, + pub username: String, pub avatar_url: String, pub github_login: String, pub name: Option, @@ -69,11 +72,6 @@ pub struct OrganizationEditPredictionConfiguration { pub is_feedback_enabled: bool, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct AcceptTermsOfServiceResponse { - pub user: AuthenticatedUser, -} - #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct LlmToken(pub String); @@ -129,58 +127,60 @@ pub struct SubmitEditPredictionFeedbackBody { } #[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct SubmitEditPredictionSettledBody { +pub struct SettledEditPrediction { pub request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] pub settled_editable_region: Option, pub ts_error_count_before_prediction: usize, pub ts_error_count_after_prediction: usize, pub can_collect_data: bool, pub is_in_open_source_repo: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_data: Option, #[serde(flatten)] pub kept_chars: EditPredictionSettledKeptChars, pub example: Option, pub model_version: Option, #[serde(rename = "e2e_latency")] - pub e2e_latency_ms: u128, + pub e2e_latency_ms: u64, } #[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct SubmitEditPredictionSettledResponse {} - -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct SubmitEditPredictionJumpExampleBody { - pub request_id: uuid::Uuid, - pub trigger: JumpExampleTrigger, +pub struct SettledEditPredictionSampleData { pub repository_url: Option, pub revision: Option, /// Note: this is only the uncommitted diff for files in `edit_history` /// This is done to avoid excessive memory usage + #[serde(default, skip_serializing_if = "Option::is_none")] pub uncommitted_diff: Option, - pub recently_opened_files: Vec, - pub recently_viewed_files: Vec, - pub cursor_path: Arc, - pub cursor_position: String, - pub edit_history: Vec>, - pub diagnostics: Vec, - pub future_edit_history: String, - pub navigation_history: Vec, - pub is_in_open_source_repo: bool, - pub can_collect_data: bool, + pub editable_path: Arc, + pub editable_offset_range: Range, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub buffer_diagnostics: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub editable_context: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub future_edit_history_events: Vec>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub navigation_history: Vec, + pub edit_events_before_quiescence: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_edit_cursor_offset: Option, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct SubmitEditPredictionJumpExampleResponse {} +pub const MAX_EDIT_PREDICTION_SETTLED_PER_REQUEST: usize = 32; -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum JumpExampleTrigger { - Prediction, - Diagnostic, +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct SubmitEditPredictionSettledBatchBody { + pub predictions: Vec, } #[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct JumpExampleRecentFile { +pub struct SubmitEditPredictionSettledResponse {} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct EditPredictionRecentFile { pub path: Arc, #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor_position: Option, diff --git a/crates/cloud_api_types/src/internal_api.rs b/crates/cloud_api_types/src/internal_api.rs index c7814a46c399c8..bfe47f9535c075 100644 --- a/crates/cloud_api_types/src/internal_api.rs +++ b/crates/cloud_api_types/src/internal_api.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; pub struct User { pub id: String, pub legacy_user_id: i32, + pub username: String, pub github_login: String, pub avatar_url: String, pub name: Option, @@ -43,14 +44,14 @@ pub struct FuzzySearchUsersResponse { } #[derive(Debug, Serialize, Deserialize)] -pub struct FuzzySearchChannelMembersByGithubLoginBody { +pub struct FuzzySearchChannelMembersBody { pub channel_id: i32, pub query: String, pub limit: u32, } #[derive(Debug, Serialize, Deserialize)] -pub struct FuzzySearchChannelMembersByGithubLoginResponse { +pub struct FuzzySearchChannelMembersResponse { pub channel_members: Vec, pub users: Vec, } diff --git a/crates/cloud_api_types/src/plan.rs b/crates/cloud_api_types/src/plan.rs index 1f40d1ddb5f0e7..964c7d859dd7bd 100644 --- a/crates/cloud_api_types/src/plan.rs +++ b/crates/cloud_api_types/src/plan.rs @@ -10,6 +10,7 @@ pub enum Plan { ZedPro, ZedProTrial, ZedBusiness, + ZedVip, ZedStudent, } diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 212f8c548a0bd7..167507e86a389d 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -1,5 +1,7 @@ #[cfg(feature = "predict-edits")] pub mod predict_edits_v3; +#[cfg(feature = "predict-edits")] +pub mod predict_edits_v4; use std::str::FromStr; use std::sync::Arc; @@ -129,6 +131,11 @@ pub enum PredictEditsRequestTrigger { LSPCompletionAccepted, PredictionAccepted, PredictionPartiallyAccepted, + EditorCreated, + ProviderChanged, + UserInfoChanged, + VimModeChanged, + SettingsChanged, #[default] Other, } @@ -194,6 +201,10 @@ pub enum EditPredictionRejectReason { Empty, /// Edits returned, but none remained after interpolation InterpolatedEmpty, + /// Edits returned, but could not be interpolated after buffer changes + InterpolateFailed, + /// A patch was returned, but could not be applied to the buffer + PatchApplyFailed, /// The new prediction was preferred over the current one Replaced, /// The current prediction was preferred over the new one @@ -314,6 +325,10 @@ pub struct LanguageModel { /// Only used by OpenAI and xAI. #[serde(default)] pub supports_parallel_tool_calls: bool, + #[serde(default)] + pub is_disabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/cloud_llm_client/src/predict_edits_v3.rs b/crates/cloud_llm_client/src/predict_edits_v3.rs index 77cdab33feef46..2ffb32840d6336 100644 --- a/crates/cloud_llm_client/src/predict_edits_v3.rs +++ b/crates/cloud_llm_client/src/predict_edits_v3.rs @@ -31,7 +31,7 @@ pub struct RawCompletionRequest { #[derive(Debug, Serialize, Deserialize)] pub struct PredictEditsV3Request { #[serde(flatten)] - pub input: zeta_prompt::ZetaPromptInput, + pub input: zeta_prompt::Zeta2PromptInput, } #[derive(Debug, Deserialize, Serialize)] diff --git a/crates/cloud_llm_client/src/predict_edits_v4.rs b/crates/cloud_llm_client/src/predict_edits_v4.rs new file mode 100644 index 00000000000000..62f58f00bfe9b6 --- /dev/null +++ b/crates/cloud_llm_client/src/predict_edits_v4.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct PredictEditsV4Request { + #[serde(flatten)] + pub input: zeta_prompt::Zeta3PromptInput, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PredictEditsV4Response { + pub request_id: String, + pub patch: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_version: Option, +} diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 64de772aec1a2b..c5889ae6663770 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -82,9 +82,10 @@ struct CurrentCompletion { 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. + /// Returns None if no predicted edits remain or the user's edits conflict with the predicted edits. fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option, Arc)>> { edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits) + .filter(|edits| !edits.is_empty()) } } diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 9ad0949e15580a..c21847d64bdcf3 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -128,6 +128,7 @@ sqlx = { version = "0.8", features = ["sqlite"] } task.workspace = true theme_settings = { workspace = true, features = ["test-support"] } theme.workspace = true +title_bar = { workspace = true, features = ["test-support"] } unindent.workspace = true util.workspace = true diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 730c3da9990393..98a7053ff271e1 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -432,14 +432,3 @@ CREATE TABLE IF NOT EXISTS "breakpoints" ( ); CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); - -CREATE TABLE IF NOT EXISTS "shared_threads" ( - "id" TEXT PRIMARY KEY NOT NULL, - "user_id" INTEGER NOT NULL, - "title" VARCHAR(512) NOT NULL, - "data" BLOB NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX "index_shared_threads_user_id" ON "shared_threads" ("user_id"); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 8b136c347ae82d..1e3cdaf66915d4 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -410,15 +410,6 @@ CREATE SEQUENCE public.servers_id_seq ALTER SEQUENCE public.servers_id_seq OWNED BY public.servers.id; -CREATE TABLE public.shared_threads ( - id uuid NOT NULL, - user_id integer NOT NULL, - title text NOT NULL, - data bytea NOT NULL, - created_at timestamp without time zone DEFAULT now() NOT NULL, - updated_at timestamp without time zone DEFAULT now() NOT NULL -); - CREATE TABLE public.users ( id integer NOT NULL, admin boolean NOT NULL, @@ -598,9 +589,6 @@ ALTER TABLE ONLY public.rooms ALTER TABLE ONLY public.servers ADD CONSTRAINT servers_pkey PRIMARY KEY (id); -ALTER TABLE ONLY public.shared_threads - ADD CONSTRAINT shared_threads_pkey PRIMARY KEY (id); - ALTER TABLE ONLY public.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); @@ -616,8 +604,6 @@ ALTER TABLE ONLY public.worktree_settings_files ALTER TABLE ONLY public.worktrees ADD CONSTRAINT worktrees_pkey PRIMARY KEY (project_id, id); -CREATE INDEX idx_shared_threads_user_id ON public.shared_threads 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); @@ -830,9 +816,6 @@ ALTER TABLE ONLY public.room_participants 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.shared_threads - ADD CONSTRAINT shared_threads_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; - 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; diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index 388c880eb596a7..7f0fa5b7a8a30e 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -67,7 +67,8 @@ pub async fn validate_header(mut req: Request, next: Next) -> impl Into .context("failed to parse response body")?; let user = User { - id: UserId(response_body.user.id), + id: UserId(response_body.user.legacy_user_id), + username: response_body.user.username, github_login: response_body.user.github_login, avatar_url: response_body.user.avatar_url, name: response_body.user.name, diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 8a9beac933062f..786f750cd0c700 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -487,7 +487,6 @@ pub struct RejoinedRoom { pub rejoined_projects: Vec, pub reshared_projects: Vec, pub channel: Option, - pub role: ChannelRole, } pub struct ResharedProject { @@ -526,6 +525,8 @@ impl RejoinedProject { visible: worktree.visible, abs_path: worktree.abs_path.clone(), root_repo_common_dir: None, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, }) .collect(), collaborators: self @@ -731,6 +732,10 @@ fn db_status_to_proto( }), diff_stat_added: entry.lines_added.map(|v| v as u32), diff_stat_deleted: entry.lines_deleted.map(|v| v as u32), + staged_diff_stat_added: None, + staged_diff_stat_deleted: None, + unstaged_diff_stat_added: None, + unstaged_diff_stat_deleted: None, }) } diff --git a/crates/collab/src/db/queries.rs b/crates/collab/src/db/queries.rs index a7d41adb685107..5b39661303bad4 100644 --- a/crates/collab/src/db/queries.rs +++ b/crates/collab/src/db/queries.rs @@ -9,5 +9,4 @@ pub mod notifications; pub mod projects; pub mod rooms; pub mod servers; -pub mod shared_threads; pub mod users; diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 3cf82e8518cb14..add702ff23b4e4 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -961,7 +961,7 @@ impl Database { let path_style = if project.windows_paths { PathStyle::Windows } else { - PathStyle::Posix + PathStyle::Unix }; let features: Vec = serde_json::from_str(&project.features).unwrap_or_default(); diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 8661523a14fb09..a04cd534102d9f 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -499,16 +499,6 @@ impl Database { return Err(anyhow!("room does not exist or was already joined"))?; } - let participant = room_participant::Entity::find() - .filter( - Condition::all() - .add(room_participant::Column::RoomId.eq(room_id)) - .add(room_participant::Column::UserId.eq(user_id)), - ) - .one(&*tx) - .await? - .context("participant not found")?; - let mut reshared_projects = Vec::new(); for reshared_project in &rejoin_room.reshared_projects { let project_id = ProjectId::from_proto(reshared_project.project_id); @@ -601,7 +591,6 @@ impl Database { channel, rejoined_projects, reshared_projects, - role: participant.role.unwrap_or(ChannelRole::Member), }) }) .await diff --git a/crates/collab/src/db/queries/shared_threads.rs b/crates/collab/src/db/queries/shared_threads.rs deleted file mode 100644 index 9cb320d45d46bc..00000000000000 --- a/crates/collab/src/db/queries/shared_threads.rs +++ /dev/null @@ -1,76 +0,0 @@ -use chrono::Utc; - -use super::*; -use crate::db::tables::shared_thread; - -impl Database { - pub async fn upsert_shared_thread( - &self, - id: SharedThreadId, - user_id: UserId, - title: &str, - data: Vec, - ) -> Result<()> { - let title = title.to_string(); - self.transaction(|tx| { - let title = title.clone(); - let data = data.clone(); - async move { - let now = Utc::now().naive_utc(); - - let existing = shared_thread::Entity::find_by_id(id).one(&*tx).await?; - - match existing { - Some(existing) => { - if existing.user_id != user_id { - Err(anyhow!("Cannot update shared thread owned by another user"))?; - } - - let mut active: shared_thread::ActiveModel = existing.into(); - active.title = ActiveValue::Set(title); - active.data = ActiveValue::Set(data); - active.updated_at = ActiveValue::Set(now); - active.update(&*tx).await?; - } - None => { - shared_thread::ActiveModel { - id: ActiveValue::Set(id), - user_id: ActiveValue::Set(user_id), - title: ActiveValue::Set(title), - data: ActiveValue::Set(data), - created_at: ActiveValue::Set(now), - updated_at: ActiveValue::Set(now), - } - .insert(&*tx) - .await?; - } - } - - Ok(()) - } - }) - .await - } - - pub async fn get_shared_thread( - &self, - share_id: SharedThreadId, - ) -> Result> { - self.transaction(|tx| async move { - let Some(thread) = shared_thread::Entity::find_by_id(share_id) - .one(&*tx) - .await? - else { - return Ok(None); - }; - - // We can no longer read the `github_login` from Collab. - // - // Opting to just use "Unknown" here, as the feature is staff-only and infrequently used. - let username = "Unknown".to_string(); - - Ok(Some((thread, username))) - }) - .await - } -} diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index 83be94dac57d04..6a1a2db4364aeb 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -21,7 +21,6 @@ pub mod project_repository_statuses; pub mod room; pub mod room_participant; pub mod server; -pub mod shared_thread; pub mod user; pub mod worktree; pub mod worktree_diagnostic_summary; diff --git a/crates/collab/src/db/tables/shared_thread.rs b/crates/collab/src/db/tables/shared_thread.rs deleted file mode 100644 index 6969a6e69ea45c..00000000000000 --- a/crates/collab/src/db/tables/shared_thread.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::db::{SharedThreadId, UserId}; -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "shared_threads")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: SharedThreadId, - pub user_id: UserId, - pub title: String, - pub data: Vec, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::user::Entity", - from = "Column::UserId", - to = "super::user::Column::Id" - )] - User, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::User.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/entities/user.rs b/crates/collab/src/entities/user.rs index f0eb46cdab8d39..fd53392e4e7f97 100644 --- a/crates/collab/src/entities/user.rs +++ b/crates/collab/src/entities/user.rs @@ -3,6 +3,7 @@ use crate::db::UserId; #[derive(Debug, Clone)] pub struct User { pub id: UserId, + pub username: String, pub github_login: String, pub avatar_url: String, pub name: Option, diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 337e363db697fe..9bbca16a39296c 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -7,8 +7,7 @@ use crate::{ db::{ self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser, Database, InviteMemberResult, MembershipUpdated, NotificationId, ProjectId, RejoinedProject, - RemoveChannelMemberResult, RespondToChannelInvite, RoomId, ServerId, SharedThreadId, - UserId, + RemoveChannelMemberResult, RespondToChannelInvite, RoomId, ServerId, UserId, }, executor::Executor, }; @@ -164,6 +163,7 @@ impl Principal { match &self { Principal::User(user) => { span.record("user_id", user.id.0); + span.record("username", &user.username); span.record("login", &user.github_login); } } @@ -291,7 +291,7 @@ impl Debug for Session { let mut result = f.debug_struct("Session"); match &self.principal { Principal::User(user) => { - result.field("user", &user.github_login); + result.field("user", &user.username); } } result.field("connection_id", &self.connection_id).finish() @@ -364,6 +364,7 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) @@ -405,6 +406,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_request_handler( forward_mutating_project_request::, @@ -425,6 +428,14 @@ impl Server { broadcast_project_message_from_host::, ) .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler( + broadcast_project_message_from_host::, + ) + .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler( + broadcast_project_message_from_host::, + ) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(broadcast_project_message_from_host::) @@ -479,8 +490,12 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) - .add_request_handler(forward_read_only_project_request::) - .add_request_handler(forward_read_only_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_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) @@ -512,9 +527,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) - .add_request_handler(share_agent_thread) - .add_request_handler(get_shared_agent_thread) - .add_request_handler(forward_project_search_chunk); + .add_request_handler(forward_project_search_chunk) + .add_request_handler(forward_read_only_project_request::); Arc::new(server) } @@ -1340,7 +1354,7 @@ async fn connection_lost( _ = executor.sleep(RECONNECT_TIMEOUT).fuse() => { log::info!("connection lost, removing all resources for user:{}, connection:{:?}", session.user_id(), session.connection_id); - leave_room_for_session(&session, session.connection_id, None).await.trace_err(); + leave_room_for_session(&session, session.connection_id).await.trace_err(); leave_channel_buffers_for_session(&session) .await .trace_err(); @@ -1494,44 +1508,6 @@ async fn rejoin_room( .rejoin_room(request, session.user_id(), session.connection_id) .await?; - // Include fresh LiveKit connection info so that clients whose LiveKit - // connection failed (e.g. because their token was revoked by a stale - // connection cleanup) can re-establish it. - let live_kit_connection_info = - session - .app_state - .livekit_client - .as_ref() - .and_then(|live_kit| { - let (can_publish, token) = if rejoined_room.role == ChannelRole::Guest { - ( - false, - live_kit - .guest_token( - &rejoined_room.room.livekit_room, - &session.user_id().to_string(), - ) - .trace_err()?, - ) - } else { - ( - true, - live_kit - .room_token( - &rejoined_room.room.livekit_room, - &session.user_id().to_string(), - ) - .trace_err()?, - ) - }; - - Some(LiveKitConnectionInfo { - server_url: live_kit.url().into(), - token, - can_publish, - }) - }); - response.send(proto::RejoinRoomResponse { room: Some(rejoined_room.room.clone()), reshared_projects: rejoined_room @@ -1551,7 +1527,6 @@ async fn rejoin_room( .iter() .map(|rejoined_project| rejoined_project.to_proto()) .collect(), - live_kit_connection_info, })?; room_updated(&rejoined_room.room, &session.peer); @@ -1639,6 +1614,8 @@ fn notify_rejoined_projects( abs_path: worktree.abs_path.clone(), root_name: worktree.root_name, root_repo_common_dir: worktree.root_repo_common_dir, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, updated_entries: worktree.updated_entries, removed_entries: worktree.removed_entries, scan_id: worktree.scan_id, @@ -1703,7 +1680,7 @@ async fn leave_room( response: Response, session: MessageContext, ) -> Result<()> { - leave_room_for_session(&session, session.connection_id, None).await?; + leave_room_for_session(&session, session.connection_id).await?; response.send(proto::Ack {})?; Ok(()) } @@ -2047,6 +2024,8 @@ async fn join_project( visible: worktree.visible, abs_path: worktree.abs_path.clone(), root_repo_common_dir: None, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, }) .collect::>(); @@ -2099,6 +2078,8 @@ async fn join_project( abs_path: worktree.abs_path.clone(), root_name: worktree.root_name, root_repo_common_dir: worktree.root_repo_common_dir, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, updated_entries: worktree.entries, removed_entries: Default::default(), scan_id: worktree.scan_id, @@ -2725,6 +2706,7 @@ async fn get_users( .into_iter() .map(|user| proto::User { id: user.id.to_proto(), + username: user.username, avatar_url: user.avatar_url, github_login: user.github_login, name: user.name, @@ -2763,6 +2745,7 @@ async fn fuzzy_search_users( .filter(|user| user.id != session.user_id()) .map(|user| proto::User { id: user.id.to_proto(), + username: user.username, avatar_url: user.avatar_url, github_login: user.github_login, name: user.name, @@ -3438,7 +3421,7 @@ async fn join_channel_internal( "cleaning up stale connection", ); drop(db); - leave_room_for_session(&session, connection, Some(channel_id)).await?; + leave_room_for_session(&session, connection).await?; db = session.db().await; } @@ -4068,11 +4051,7 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> Ok(()) } -async fn leave_room_for_session( - session: &Session, - connection_id: ConnectionId, - rejoining_channel_id: Option, -) -> Result<()> { +async fn leave_room_for_session(session: &Session, connection_id: ConnectionId) -> Result<()> { let mut contacts_to_update = HashSet::default(); let room_id; @@ -4081,7 +4060,6 @@ async fn leave_room_for_session( let delete_livekit_room; let room; let channel; - let left_channel_id; if let Some(mut left_room) = session.db().await.leave_room(connection_id).await? { contacts_to_update.insert(session.user_id()); @@ -4096,23 +4074,12 @@ async fn leave_room_for_session( delete_livekit_room = left_room.deleted; room = mem::take(&mut left_room.room); channel = mem::take(&mut left_room.channel); - left_channel_id = channel.as_ref().map(|channel| channel.id); room_updated(&room, &session.peer); } else { return Ok(()); } - // When this cleanup is part of rejoining the same channel, the user is - // immediately re-entering this LiveKit room with the same identity, which - // LiveKit handles by evicting the prior session. Calling - // `remove_participant` here is therefore redundant, and worse: on LiveKit - // Cloud it revokes the identity's tokens (including the freshly issued one - // for the rejoin), leaving the user connected to collab but unable to join - // audio. Skip the removal in that case. - let skip_livekit_removal = - rejoining_channel_id.is_some() && left_channel_id == rejoining_channel_id; - if let Some(channel) = channel { channel_updated( &channel, @@ -4145,12 +4112,10 @@ async fn leave_room_for_session( } if let Some(live_kit) = session.app_state.livekit_client.as_ref() { - if !skip_livekit_removal { - live_kit - .remove_participant(livekit_room.clone(), session.user_id().to_string()) - .await - .trace_err(); - } + live_kit + .remove_participant(livekit_room.clone(), session.user_id().to_string()) + .await + .trace_err(); if delete_livekit_room { live_kit.delete_room(livekit_room).await.trace_err(); @@ -4209,54 +4174,6 @@ fn project_left(project: &db::LeftProject, session: &Session) { } } -async fn share_agent_thread( - request: proto::ShareAgentThread, - response: Response, - session: MessageContext, -) -> Result<()> { - let user_id = session.user_id(); - - let share_id = SharedThreadId::from_proto(request.session_id.clone()) - .ok_or_else(|| anyhow!("Invalid session ID format"))?; - - session - .db() - .await - .upsert_shared_thread(share_id, user_id, &request.title, request.thread_data) - .await?; - - response.send(proto::Ack {})?; - - Ok(()) -} - -async fn get_shared_agent_thread( - request: proto::GetSharedAgentThread, - response: Response, - session: MessageContext, -) -> Result<()> { - let share_id = SharedThreadId::from_proto(request.session_id) - .ok_or_else(|| anyhow!("Invalid session ID format"))?; - - let result = session.db().await.get_shared_thread(share_id).await?; - - match result { - Some((thread, username)) => { - response.send(proto::GetSharedAgentThreadResponse { - title: thread.title, - thread_data: thread.data, - sharer_username: username, - created_at: thread.created_at.and_utc().to_rfc3339(), - })?; - } - None => { - return Err(anyhow!("Shared thread not found").into()); - } - } - - Ok(()) -} - pub trait ResultExt { type Ok; @@ -4285,6 +4202,7 @@ impl From for proto::User { fn from(user: User) -> Self { Self { id: user.id.to_proto(), + username: user.username, avatar_url: user.avatar_url, github_login: user.github_login, name: user.name, diff --git a/crates/collab/src/services/user_service.rs b/crates/collab/src/services/user_service.rs index f704bf5673b6c8..61541aa5a10dd8 100644 --- a/crates/collab/src/services/user_service.rs +++ b/crates/collab/src/services/user_service.rs @@ -1,10 +1,9 @@ use anyhow::{Context as _, anyhow}; use async_trait::async_trait; use cloud_api_types::internal_api::{ - self, FuzzySearchChannelMembersByGithubLoginBody, - FuzzySearchChannelMembersByGithubLoginResponse, FuzzySearchUsersBody, FuzzySearchUsersResponse, - LookUpUserByGithubLoginBody, LookUpUserByGithubLoginResponse, LookUpUsersByLegacyIdBody, - LookUpUsersByLegacyIdResponse, + self, FuzzySearchChannelMembersBody, FuzzySearchChannelMembersResponse, FuzzySearchUsersBody, + FuzzySearchUsersResponse, LookUpUserByGithubLoginBody, LookUpUserByGithubLoginResponse, + LookUpUsersByLegacyIdBody, LookUpUsersByLegacyIdResponse, }; use reqwest::RequestBuilder; use rpc::proto; @@ -157,14 +156,14 @@ impl UserService for CloudUserService { query: &str, limit: u32, ) -> Result<(Vec, Vec)> { - let response_body: FuzzySearchChannelMembersByGithubLoginResponse = self + let response_body: FuzzySearchChannelMembersResponse = self .send_request( self.http_client .post(format!( - "{}/internal/channel_members/fuzzy_search_by_github_login", + "{}/internal/channel_members/fuzzy_search", &self.zed_cloud_url )) - .json(&FuzzySearchChannelMembersByGithubLoginBody { + .json(&FuzzySearchChannelMembersBody { channel_id: channel.root_id().0, query: query.to_string(), limit, @@ -211,6 +210,7 @@ impl From for User { fn from(user: internal_api::User) -> Self { Self { id: UserId(user.legacy_user_id), + username: user.username, avatar_url: user.avatar_url, github_login: user.github_login, name: user.name, @@ -281,6 +281,8 @@ mod fake_user_service { user_id, User { id: user_id, + // For the purposes of the tests we treat these as interchangeable. + username: params.github_login.clone(), avatar_url: format!("https://github.com/{}.png?size=128", params.github_login), github_login: params.github_login, name: name.map(|name| name.to_string()), diff --git a/crates/collab/tests/integration/agent_sharing_tests.rs b/crates/collab/tests/integration/agent_sharing_tests.rs deleted file mode 100644 index e186a7af642114..00000000000000 --- a/crates/collab/tests/integration/agent_sharing_tests.rs +++ /dev/null @@ -1,214 +0,0 @@ -use agent::SharedThread; -use gpui::{BackgroundExecutor, TestAppContext}; -use rpc::proto; -use uuid::Uuid; - -use crate::TestServer; - -#[gpui::test] -async fn test_share_and_retrieve_thread( - executor: BackgroundExecutor, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, -) { - let mut server = TestServer::start(executor.clone()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - - executor.run_until_parked(); - - let session_id = Uuid::new_v4().to_string(); - - let original_thread = SharedThread { - title: "Shared Test Thread".into(), - messages: vec![], - updated_at: chrono::Utc::now(), - model: None, - version: SharedThread::VERSION.to_string(), - }; - - let thread_data = original_thread - .to_bytes() - .expect("Failed to serialize thread"); - - client_a - .client() - .request(proto::ShareAgentThread { - session_id: session_id.clone(), - title: original_thread.title.to_string(), - thread_data, - }) - .await - .expect("Failed to share thread"); - - let get_response = client_b - .client() - .request(proto::GetSharedAgentThread { - session_id: session_id.clone(), - }) - .await - .expect("Failed to get shared thread"); - - let imported_shared_thread = - SharedThread::from_bytes(&get_response.thread_data).expect("Failed to deserialize thread"); - - assert_eq!(imported_shared_thread.title, original_thread.title); - assert_eq!(imported_shared_thread.version, SharedThread::VERSION); - - let db_thread = imported_shared_thread.to_db_thread(); - - assert!( - db_thread.title.starts_with("🔗"), - "Imported thread title should have link prefix" - ); - assert!( - db_thread.title.contains("Shared Test Thread"), - "Imported thread should preserve original title" - ); -} - -#[gpui::test] -async fn test_reshare_updates_existing_thread( - executor: BackgroundExecutor, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, -) { - let mut server = TestServer::start(executor.clone()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - - executor.run_until_parked(); - - let session_id = Uuid::new_v4().to_string(); - - client_a - .client() - .request(proto::ShareAgentThread { - session_id: session_id.clone(), - title: "Original Title".to_string(), - thread_data: b"original data".to_vec(), - }) - .await - .expect("Failed to share thread"); - - client_a - .client() - .request(proto::ShareAgentThread { - session_id: session_id.clone(), - title: "Updated Title".to_string(), - thread_data: b"updated data".to_vec(), - }) - .await - .expect("Failed to re-share thread"); - - let get_response = client_b - .client() - .request(proto::GetSharedAgentThread { - session_id: session_id.clone(), - }) - .await - .expect("Failed to get shared thread"); - - assert_eq!(get_response.title, "Updated Title"); - assert_eq!(get_response.thread_data, b"updated data".to_vec()); -} - -#[gpui::test] -async fn test_get_nonexistent_thread(executor: BackgroundExecutor, cx: &mut TestAppContext) { - let mut server = TestServer::start(executor.clone()).await; - let client = server.create_client(cx, "user_a").await; - - executor.run_until_parked(); - - let nonexistent_session_id = Uuid::new_v4().to_string(); - - let result = client - .client() - .request(proto::GetSharedAgentThread { - session_id: nonexistent_session_id, - }) - .await; - - assert!(result.is_err(), "Should fail for nonexistent thread"); -} - -#[gpui::test] -async fn test_sync_imported_thread( - executor: BackgroundExecutor, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, -) { - let mut server = TestServer::start(executor.clone()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - - executor.run_until_parked(); - - let session_id = Uuid::new_v4().to_string(); - - // User A shares a thread with initial content. - let initial_thread = SharedThread { - title: "Initial Title".into(), - messages: vec![], - updated_at: chrono::Utc::now(), - model: None, - version: SharedThread::VERSION.to_string(), - }; - - client_a - .client() - .request(proto::ShareAgentThread { - session_id: session_id.clone(), - title: initial_thread.title.to_string(), - thread_data: initial_thread.to_bytes().expect("Failed to serialize"), - }) - .await - .expect("Failed to share thread"); - - // User B imports the thread. - let initial_response = client_b - .client() - .request(proto::GetSharedAgentThread { - session_id: session_id.clone(), - }) - .await - .expect("Failed to get shared thread"); - - let initial_imported = - SharedThread::from_bytes(&initial_response.thread_data).expect("Failed to deserialize"); - assert_eq!(initial_imported.title.as_ref(), "Initial Title"); - - // User A updates the shared thread. - let updated_thread = SharedThread { - title: "Updated Title".into(), - messages: vec![], - updated_at: chrono::Utc::now(), - model: None, - version: SharedThread::VERSION.to_string(), - }; - - client_a - .client() - .request(proto::ShareAgentThread { - session_id: session_id.clone(), - title: updated_thread.title.to_string(), - thread_data: updated_thread.to_bytes().expect("Failed to serialize"), - }) - .await - .expect("Failed to re-share thread"); - - // User B syncs the imported thread (fetches the latest version). - let synced_response = client_b - .client() - .request(proto::GetSharedAgentThread { - session_id: session_id.clone(), - }) - .await - .expect("Failed to sync shared thread"); - - let synced_thread = - SharedThread::from_bytes(&synced_response.thread_data).expect("Failed to deserialize"); - - // The synced thread should have the updated title. - assert_eq!(synced_thread.title.as_ref(), "Updated Title"); -} diff --git a/crates/collab/tests/integration/channel_tests.rs b/crates/collab/tests/integration/channel_tests.rs index ab4754082ed45c..7b20620082db6d 100644 --- a/crates/collab/tests/integration/channel_tests.rs +++ b/crates/collab/tests/integration/channel_tests.rs @@ -366,76 +366,6 @@ async fn test_joining_channel_ancestor_member( ); } -#[gpui::test] -async fn test_rejoining_channel_does_not_remove_livekit_participant( - executor: BackgroundExecutor, - cx_a1: &mut TestAppContext, - cx_a2: &mut TestAppContext, -) { - let mut server = TestServer::start(executor.clone()).await; - // Two connections for the same user. The second one joining the same - // channel reproduces the production scenario where the server runs - // stale-connection cleanup for the first connection (as it does after an - // abrupt disconnect/crash followed by a rejoin within RECONNECT_TIMEOUT). - // Mirror LiveKit Cloud: removing a participant revokes their tokens. This - // is what turns the redundant `remove_participant` during stale cleanup - // into a user-visible failure. - server - .test_livekit_server - .set_revoke_tokens_on_removal(true); - - let client_a1 = server.create_client(cx_a1, "user_a").await; - let client_a2 = server.create_client(cx_a2, "user_a").await; - client_a1.initialize_channel_store(cx_a1); - - let channel_id = server - .make_channel("zed", None, (&client_a1, cx_a1), &mut []) - .await; - - let active_call_a1 = cx_a1.read(ActiveCall::global); - active_call_a1 - .update(cx_a1, |active_call, cx| { - active_call.join_channel(channel_id, cx) - }) - .await - .unwrap(); - executor.run_until_parked(); - - // The second connection joins the same channel, triggering stale-connection - // cleanup of the first connection on the server. - let active_call_a2 = cx_a2.read(ActiveCall::global); - active_call_a2 - .update(cx_a2, |active_call, cx| { - active_call.join_channel(channel_id, cx) - }) - .await - .unwrap(); - executor.run_until_parked(); - - // The user-visible symptom: with the bug, stale cleanup revokes the - // rejoining user's token and they end up in the call with no audio. - let room_a2 = - cx_a2.read(|cx| active_call_a2.read_with(cx, |call, _| call.room().unwrap().clone())); - cx_a2.read(|cx| { - room_a2.read_with(cx, |room, cx| { - assert!( - room.is_connected(cx), - "rejoining the same channel should not break audio" - ) - }) - }); - - // The mechanism: that cleanup must NOT have removed the user from the - // LiveKit room, since they are immediately rejoining it. - let identity = client_a2.user_id().unwrap().to_string(); - assert!( - !server - .test_livekit_server - .participant_was_removed(&identity), - "rejoining the same channel should not remove the LiveKit participant" - ); -} - #[gpui::test] async fn test_channel_room( executor: BackgroundExecutor, @@ -659,6 +589,95 @@ async fn test_channel_room( ); } +#[gpui::test] +async fn test_rejoining_channel_after_stale_connection_cleanup_connects_livekit( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_a2: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel("zed", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + active_call_a + .update(cx_a, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(cx_b, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let old_room_a = + cx_a.read(|cx| active_call_a.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_a.read(|cx| old_room_a.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + + server.disconnect_client(client_a.peer_id().unwrap()); + executor.run_until_parked(); + server.advance_livekit_timestamp(); + + let client_a2 = server.create_client(cx_a2, "user_a").await; + let active_call_a2 = cx_a2.read(ActiveCall::global); + active_call_a2 + .update(cx_a2, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let room_a2 = + cx_a2.read(|cx| active_call_a2.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_a2.read(|cx| room_a2.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + assert_eq!( + room_participants(&room_a2, cx_a2), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: vec![] + } + ); + + let room_b = + cx_b.read(|cx| active_call_b.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_b.read(|cx| room_b.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: vec![] + } + ); + + cx_a2.read(|cx| { + client_a2.channel_store().read_with(cx, |channels, _| { + let mut participant_ids = channels + .channel_participants(channel_id) + .iter() + .map(|participant| participant.legacy_id) + .collect::>(); + participant_ids.sort_unstable(); + let mut expected_ids = vec![client_a2.user_id().unwrap(), client_b.user_id().unwrap()]; + expected_ids.sort_unstable(); + assert_eq!(participant_ids, expected_ids); + }) + }); +} + #[gpui::test] async fn test_channel_jumping(executor: BackgroundExecutor, cx_a: &mut TestAppContext) { let mut server = TestServer::start(executor.clone()).await; diff --git a/crates/collab/tests/integration/collab_tests.rs b/crates/collab/tests/integration/collab_tests.rs index 921319487bf4bf..c4c9f4d7d840f1 100644 --- a/crates/collab/tests/integration/collab_tests.rs +++ b/crates/collab/tests/integration/collab_tests.rs @@ -2,7 +2,6 @@ use call::Room; use client::ChannelId; use gpui::{Entity, TestAppContext}; -mod agent_sharing_tests; mod auto_watch_tests; mod channel_buffer_tests; mod channel_guest_tests; @@ -36,12 +35,12 @@ fn room_participants(room: &Entity, cx: &mut TestAppContext) -> RoomPartic let mut remote = room .remote_participants() .values() - .map(|participant| participant.user.github_login.clone().to_string()) + .map(|participant| participant.user.username.clone().to_string()) .collect::>(); let mut pending = room .pending_participants() .iter() - .map(|user| user.github_login.clone().to_string()) + .map(|user| user.username.clone().to_string()) .collect::>(); remote.sort(); pending.sort(); diff --git a/crates/collab/tests/integration/db_tests/db_tests.rs b/crates/collab/tests/integration/db_tests/db_tests.rs index 15a90fcfedbc18..c2d4c3719d74b1 100644 --- a/crates/collab/tests/integration/db_tests/db_tests.rs +++ b/crates/collab/tests/integration/db_tests/db_tests.rs @@ -1,6 +1,5 @@ use crate::test_both_dbs; -use super::*; use collab::db::RoomId; use collab::db::*; use pretty_assertions::assert_eq; @@ -228,121 +227,3 @@ async fn test_project_count(db: &Arc) { .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0); } - -test_both_dbs!( - test_upsert_shared_thread, - test_upsert_shared_thread_postgres, - test_upsert_shared_thread_sqlite -); - -async fn test_upsert_shared_thread(db: &Arc) { - use collab::db::SharedThreadId; - use uuid::Uuid; - - let user_id = new_test_user(db).await; - - let thread_id = SharedThreadId(Uuid::new_v4()); - let title = "My Test Thread"; - let data = b"test thread data".to_vec(); - - db.upsert_shared_thread(thread_id, user_id, title, data.clone()) - .await - .unwrap(); - - let result = db.get_shared_thread(thread_id).await.unwrap(); - assert!(result.is_some(), "Should find the shared thread"); - - let (thread, username) = result.unwrap(); - assert_eq!(thread.title, title); - assert_eq!(thread.data, data); - assert_eq!(thread.user_id, user_id); - assert_eq!(username, "Unknown"); -} - -test_both_dbs!( - test_upsert_shared_thread_updates_existing, - test_upsert_shared_thread_updates_existing_postgres, - test_upsert_shared_thread_updates_existing_sqlite -); - -async fn test_upsert_shared_thread_updates_existing(db: &Arc) { - use collab::db::SharedThreadId; - use uuid::Uuid; - - let user_id = new_test_user(db).await; - - let thread_id = SharedThreadId(Uuid::new_v4()); - - // Create initial thread. - db.upsert_shared_thread( - thread_id, - user_id, - "Original Title", - b"original data".to_vec(), - ) - .await - .unwrap(); - - // Update the same thread. - db.upsert_shared_thread( - thread_id, - user_id, - "Updated Title", - b"updated data".to_vec(), - ) - .await - .unwrap(); - - let result = db.get_shared_thread(thread_id).await.unwrap(); - let (thread, _) = result.unwrap(); - - assert_eq!(thread.title, "Updated Title"); - assert_eq!(thread.data, b"updated data".to_vec()); -} - -test_both_dbs!( - test_cannot_update_another_users_shared_thread, - test_cannot_update_another_users_shared_thread_postgres, - test_cannot_update_another_users_shared_thread_sqlite -); - -async fn test_cannot_update_another_users_shared_thread(db: &Arc) { - use collab::db::SharedThreadId; - use uuid::Uuid; - - let user1_id = new_test_user(db).await; - let user2_id = new_test_user(db).await; - - let thread_id = SharedThreadId(Uuid::new_v4()); - - db.upsert_shared_thread(thread_id, user1_id, "User 1 Thread", b"user1 data".to_vec()) - .await - .unwrap(); - - let result = db - .upsert_shared_thread(thread_id, user2_id, "User 2 Title", b"user2 data".to_vec()) - .await; - - assert!( - result.is_err(), - "Should not allow updating another user's thread" - ); -} - -test_both_dbs!( - test_get_nonexistent_shared_thread, - test_get_nonexistent_shared_thread_postgres, - test_get_nonexistent_shared_thread_sqlite -); - -async fn test_get_nonexistent_shared_thread(db: &Arc) { - use collab::db::SharedThreadId; - use uuid::Uuid; - - let result = db - .get_shared_thread(SharedThreadId(Uuid::new_v4())) - .await - .unwrap(); - - assert!(result.is_none(), "Should not find non-existent thread"); -} diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index e4e8fbfae01995..bb20e88d6b2783 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -10,6 +10,7 @@ use editor::{ CopyFileName, CopyFileNameWithoutExtension, ExpandMacroRecursively, MoveToEnd, Redo, Rename, SelectAll, ToggleCodeActions, Undo, }, + code_context_menus::CodeContextMenu, test::{ editor_test_context::{AssertionContextManager, EditorTestContext}, expand_macro_recursively, @@ -1411,6 +1412,142 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte ) } +#[gpui::test] +async fn test_collaborating_with_code_lens_resolve( + 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; + let active_call_a = cx_a.read(ActiveCall::global); + cx_b.update(editor::init); + cx_b.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.code_lens = Some(settings::CodeLens::Menu); + }); + }); + }); + + let capabilities = lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_lsp| { + fake_lsp.set_request_handler::( + |_, _| async move { + Ok(Some(vec![lsp::CodeLens { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 9), + ), + command: None, + data: Some(serde_json::json!({ "id": "lens" })), + }])) + }, + ); + fake_lsp.set_request_handler::( + |lens, _| async move { + Ok(lsp::CodeLens { + command: Some(lsp::Command { + title: "1 reference".to_string(), + command: "noop".to_string(), + arguments: None, + }), + ..lens + }) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;" + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + 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_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]); + }); + }); + cx_a.background_executor + .advance_clock(editor::CODE_ACTIONS_DEBOUNCE_TIMEOUT * 2); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from: None, + quick_launch: false, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + editor_b.update(cx_b, |editor, _| { + assert!(editor.context_menu_visible()); + let menu = editor.context_menu().borrow(); + let actions_menu = match menu.as_ref() { + Some(CodeContextMenu::CodeActions(m)) => m, + _ => panic!("Expected code actions menu to be visible"), + }; + let item = actions_menu + .actions + .get(0) + .expect("Expected at least one item in menu"); + assert_eq!(item.label(), "1 reference"); + }); +} + #[gpui::test(iterations = 10)] async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let mut server = TestServer::start(cx_a.executor()).await; @@ -1744,7 +1881,7 @@ async fn test_share_project( let incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); executor.run_until_parked(); let call = incoming_call_b.borrow().clone().unwrap(); - assert_eq!(call.calling_user.github_login, "user_a"); + assert_eq!(call.calling_user.username, "user_a"); let initial_project = call.initial_project.unwrap(); active_call_b .update(cx_b, |call, cx| call.accept_incoming(cx)) @@ -1860,7 +1997,7 @@ async fn test_share_project( let incoming_call_c = active_call_c.read_with(cx_c, |call, _| call.incoming()); executor.run_until_parked(); let call = incoming_call_c.borrow().clone().unwrap(); - assert_eq!(call.calling_user.github_login, "user_b"); + assert_eq!(call.calling_user.username, "user_b"); let initial_project = call.initial_project.unwrap(); active_call_c .update(cx_c, |call, cx| call.accept_incoming(cx)) @@ -4077,6 +4214,7 @@ 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(), + tag_names: Default::default(), }; client_a.fs().set_blame_for_repo( Path::new(path!("/my-repo/.git")), diff --git a/crates/collab/tests/integration/following_tests.rs b/crates/collab/tests/integration/following_tests.rs index 2a80a6f0b19283..90370b55e6f738 100644 --- a/crates/collab/tests/integration/following_tests.rs +++ b/crates/collab/tests/integration/following_tests.rs @@ -1,12 +1,13 @@ #![allow(clippy::reversed_empty_ranges)] use crate::TestServer; -use call::ActiveCall; +use call::{ActiveCall, Room}; use client::ChannelId; use collab_ui::{ channel_view::ChannelView, notifications::project_shared_notification::ProjectSharedNotification, }; use editor::{Editor, MultiBuffer, MultiBufferOffset, PathKey, SelectionEffects}; +use gpui::proptest::prelude::*; use gpui::{ Action, AppContext as _, BackgroundExecutor, BorrowAppContext, Entity, SharedString, TestAppContext, VisualContext, VisualTestContext, point, @@ -2161,6 +2162,442 @@ async fn share_workspace( .await } +#[derive(Clone, Copy, Debug)] +enum LocationProject { + First, + Second, +} + +#[derive(Clone, Copy, Debug)] +enum LocationWindow { + First, + Second, +} + +#[derive(Clone, Copy, Debug)] +enum ParticipantLocationAction { + ActivateWorkspace { + window: LocationWindow, + project: LocationProject, + }, + ActivateWindow(LocationWindow), + DeactivateActiveWindow, + ShareProject(LocationProject), + UnshareProject(LocationProject), +} + +fn location_project() -> impl Strategy { + prop_oneof![Just(LocationProject::First), Just(LocationProject::Second),] +} + +fn location_window() -> impl Strategy { + prop_oneof![Just(LocationWindow::First), Just(LocationWindow::Second),] +} + +fn participant_location_actions() -> impl Strategy> { + gpui::proptest::collection::vec( + prop_oneof![ + 3 => (location_window(), location_project()).prop_map(|(window, project)| { + ParticipantLocationAction::ActivateWorkspace { window, project } + }), + 2 => location_window().prop_map(ParticipantLocationAction::ActivateWindow), + 1 => Just(ParticipantLocationAction::DeactivateActiveWindow), + 2 => location_project().prop_map(ParticipantLocationAction::ShareProject), + 2 => location_project().prop_map(ParticipantLocationAction::UnshareProject), + ], + 0..8, + ) + .prop_map(|actions| { + let mut scenario = vec![ + ParticipantLocationAction::DeactivateActiveWindow, + ParticipantLocationAction::ActivateWindow(LocationWindow::First), + ParticipantLocationAction::ActivateWorkspace { + window: LocationWindow::Second, + project: LocationProject::Second, + }, + ]; + scenario.extend(actions); + scenario + }) +} + +fn participant_location( + room: &Entity, + user_id: u64, + cx: &TestAppContext, +) -> ParticipantLocation { + room.read_with(cx, |room, _| { + room.remote_participants() + .get(&user_id) + .expect("participant should be present") + .location + }) +} + +#[gpui::property_test(config = ProptestConfig { + cases: 8, + ..Default::default() +})] +async fn test_active_multi_workspace_determines_participant_location( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + #[strategy = participant_location_actions()] actions: Vec, +) { + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + cx_a.update(title_bar::init); + + client_a.fs().insert_tree(path!("/first"), json!({})).await; + client_a.fs().insert_tree(path!("/second"), json!({})).await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + let user_id_a = client_a.id(); + + let (first_project, _) = client_a.build_local_project(path!("/first"), cx_a).await; + let (second_project, _) = client_a.build_local_project(path!("/second"), cx_a).await; + let first_shared_project_id = active_call_a + .update(cx_a, |call, cx| { + call.share_project(first_project.clone(), cx) + }) + .await + .unwrap(); + let mut first_project_id = Some(first_shared_project_id); + let mut second_project_id = None; + + let mut second_window_context = cx_a.clone(); + let (first_window_first_workspace, cx_a) = client_a.build_workspace(&first_project, cx_a); + let first_multi_workspace = cx_a + .window_handle() + .downcast::() + .expect("window should contain a multi-workspace"); + let app_state = client_a.app_state.clone(); + let first_window_second_workspace = first_multi_workspace + .update(cx_a, |multi_workspace, window, cx| { + let workspace = + cx.new(|cx| Workspace::new(None, second_project.clone(), app_state, window, cx)); + multi_workspace.activate(workspace.clone(), None, window, cx); + workspace + }) + .unwrap(); + first_multi_workspace + .update(cx_a, |multi_workspace, window, cx| { + multi_workspace.activate(first_window_first_workspace.clone(), None, window, cx) + }) + .unwrap(); + + let (second_window_first_workspace, cx_a_second) = + client_a.build_workspace(&first_project, &mut second_window_context); + let second_multi_workspace = cx_a_second + .window_handle() + .downcast::() + .expect("window should contain a multi-workspace"); + let app_state = client_a.app_state.clone(); + let second_window_second_workspace = second_multi_workspace + .update(cx_a_second, |multi_workspace, window, cx| { + let workspace = + cx.new(|cx| Workspace::new(None, second_project.clone(), app_state, window, cx)); + multi_workspace.activate(workspace.clone(), None, window, cx); + workspace + }) + .unwrap(); + second_multi_workspace + .update(cx_a_second, |multi_workspace, window, cx| { + multi_workspace.activate(second_window_first_workspace.clone(), None, window, cx) + }) + .unwrap(); + + cx_a.update(|window, _| window.activate_window()); + executor.run_until_parked(); + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&first_project), cx)) + .await + .unwrap(); + executor.run_until_parked(); + assert_eq!( + participant_location(&room_b, user_id_a, cx_b), + ParticipantLocation::SharedProject { + project_id: first_shared_project_id, + }, + "test should start in the first window's first workspace" + ); + + let mut active_window = Some(LocationWindow::First); + let mut first_window_active_project = LocationProject::First; + let mut second_window_active_project = LocationProject::First; + let scenario = actions.clone(); + for action in actions { + match action { + ParticipantLocationAction::ActivateWorkspace { window, project } => match window { + LocationWindow::First => { + let workspace = match project { + LocationProject::First => first_window_first_workspace.clone(), + LocationProject::Second => first_window_second_workspace.clone(), + }; + first_multi_workspace + .update(cx_a, |multi_workspace, window, cx| { + multi_workspace.activate(workspace, None, window, cx) + }) + .unwrap(); + first_window_active_project = project; + } + LocationWindow::Second => { + let workspace = match project { + LocationProject::First => second_window_first_workspace.clone(), + LocationProject::Second => second_window_second_workspace.clone(), + }; + second_multi_workspace + .update(cx_a_second, |multi_workspace, window, cx| { + multi_workspace.activate(workspace, None, window, cx) + }) + .unwrap(); + second_window_active_project = project; + } + }, + ParticipantLocationAction::ActivateWindow(window) => { + match window { + LocationWindow::First => { + cx_a.update(|window, _| window.activate_window()); + } + LocationWindow::Second => { + cx_a_second.update(|window, _| window.activate_window()); + } + } + active_window = Some(window); + } + ParticipantLocationAction::DeactivateActiveWindow => { + if let Some(window) = active_window { + match window { + LocationWindow::First => cx_a.deactivate_window(), + LocationWindow::Second => cx_a_second.deactivate_window(), + } + active_window = None; + } + } + ParticipantLocationAction::ShareProject(project) => { + let is_shared = match project { + LocationProject::First => first_project_id.is_some(), + LocationProject::Second => second_project_id.is_some(), + }; + if !is_shared { + let project_entity = match project { + LocationProject::First => first_project.clone(), + LocationProject::Second => second_project.clone(), + }; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_entity, cx)) + .await + .unwrap(); + match project { + LocationProject::First => first_project_id = Some(project_id), + LocationProject::Second => second_project_id = Some(project_id), + } + } + } + ParticipantLocationAction::UnshareProject(project) => { + let is_shared = match project { + LocationProject::First => first_project_id.is_some(), + LocationProject::Second => second_project_id.is_some(), + }; + if is_shared { + let project_entity = match project { + LocationProject::First => first_project.clone(), + LocationProject::Second => second_project.clone(), + }; + active_call_a.update(cx_a, |call, cx| { + call.unshare_project(project_entity, cx).unwrap() + }); + match project { + LocationProject::First => first_project_id = None, + LocationProject::Second => second_project_id = None, + } + } + } + } + + executor.run_until_parked(); + let expected_location = match active_window { + Some(active_window) => { + let active_project = match active_window { + LocationWindow::First => first_window_active_project, + LocationWindow::Second => second_window_active_project, + }; + let active_project_id = match active_project { + LocationProject::First => first_project_id, + LocationProject::Second => second_project_id, + }; + match active_project_id { + Some(project_id) => ParticipantLocation::SharedProject { project_id }, + None => ParticipantLocation::UnsharedProject, + } + } + None => ParticipantLocation::External, + }; + assert_eq!( + participant_location(&room_b, user_id_a, cx_b), + expected_location, + "participant location should match the active window's active workspace after {action:?}; scenario: {scenario:?}" + ); + } +} + +#[gpui::property_test(config = ProptestConfig { + cases: 8, + ..Default::default() +})] +async fn test_active_multi_workspace_determines_follower_view( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + path!("/project"), + json!({ + "first.txt": "first", + "second.txt": "second", + }), + ) + .await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let (project_a, worktree_id) = client_a.build_local_project(path!("/project"), cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + 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; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let (first_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); + first_workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("first.txt")), None, true, window, cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + + let peer_id_a = client_a.peer_id().unwrap(); + workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.start_following(peer_id_a, window, cx).unwrap() + }) + .await + .unwrap(); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + let active_item = workspace + .active_item(cx) + .expect("follower should have an active item"); + assert_eq!(active_item.tab_content_text(0, cx), "first.txt"); + }); + + let multi_workspace_a = cx_a + .window_handle() + .downcast::() + .expect("window should contain a multi-workspace"); + let app_state = client_a.app_state.clone(); + let second_workspace_a = multi_workspace_a + .update(cx_a, |multi_workspace, window, cx| { + let workspace = + cx.new(|cx| Workspace::new(None, project_a.clone(), app_state, window, cx)); + multi_workspace.activate(workspace.clone(), None, window, cx); + workspace + }) + .unwrap(); + second_workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("second.txt")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap(); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + let active_item = workspace + .active_item(cx) + .expect("follower should have an active item"); + assert_eq!(active_item.tab_content_text(0, cx), "second.txt"); + }); + multi_workspace_a + .read_with(cx_a, |multi_workspace, _| { + assert!(multi_workspace.is_workspace_retained(&first_workspace_a)); + assert!(multi_workspace.is_workspace_retained(&second_workspace_a)); + }) + .unwrap(); + + cx_a.deactivate_window(); + multi_workspace_a + .update(cx_a, |multi_workspace, window, cx| { + multi_workspace.activate(first_workspace_a.clone(), None, window, cx) + }) + .unwrap(); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + let active_item = workspace + .active_item(cx) + .expect("follower should retain its active item while the host is inactive"); + assert_eq!(active_item.tab_content_text(0, cx), "second.txt"); + }); + + cx_a.update(|window, _| window.activate_window()); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + assert!(workspace.is_being_followed(peer_id_a)); + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(peer_id_a.into()) + ); + let active_item = workspace + .active_item(cx) + .expect("follower should have the active workspace's item"); + assert_eq!(active_item.tab_content_text(0, cx), "first.txt"); + }); + + workspace_b.update_in(cx_b, |workspace, window, cx| { + workspace.unfollow(peer_id_a, window, cx).unwrap(); + workspace.close_all_items_and_panes(&Default::default(), window, cx); + }); + first_workspace_a.update_in(cx_a, |workspace, window, cx| { + workspace.close_all_items_and_panes(&Default::default(), window, cx) + }); + second_workspace_a.update_in(cx_a, |workspace, window, cx| { + workspace.close_all_items_and_panes(&Default::default(), window, cx) + }); + executor.run_until_parked(); +} + #[gpui::test] async fn test_following_after_replacement(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let (_server, client_a, client_b, channel) = TestServer::start2(cx_a, cx_b).await; diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index d5f71085d5d661..1397bd271b1371 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -8,7 +8,9 @@ use client::RECEIVE_TIMEOUT; use collections::HashMap; use git::{ Oid, - repository::{CommitData, InitialGraphCommitData, RepoPath, Worktree as GitWorktree}, + repository::{ + CommitData, GitCommitTemplate, InitialGraphCommitData, RepoPath, Worktree as GitWorktree, + }, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; use git_ui::git_graph::GitGraph; @@ -1403,3 +1405,60 @@ async fn test_diff_stat_sync_between_host_and_downstream_client( "remote diff stats should be restored from the database after rejoining the call" ); } + +#[gpui::test] +async fn test_load_commit_template_over_collab( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).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; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a + .fs() + .insert_tree( + path!("/project"), + json!({ + ".git": {}, + "README.md": "# Project's README" + }), + ) + .await; + + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), false, |state| { + state.commit_template = Some(GitCommitTemplate { + template: "feat: add awesome feature".to_string(), + }) + }) + .expect("Should update git state"); + + let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; + 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; + executor.run_until_parked(); + + let repository_b = project_b.update(cx_b, |project, cx| project.active_repository(cx).unwrap()); + let commit_template = repository_b + .update(cx_b, |repository, _cx| { + repository.load_commit_template_text() + }) + .await + .unwrap() + .unwrap() + .expect("Guest should be able to load the host's commit template"); + + assert_eq!(commit_template.template, "feat: add awesome feature"); +} diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index dac33f9855b303..e6c377e3d451e6 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -126,7 +126,7 @@ async fn test_basic_calls( let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); let call_b = incoming_call_b.next().await.unwrap().unwrap(); - assert_eq!(call_b.calling_user.github_login, "user_a"); + assert_eq!(call_b.calling_user.username, "user_a"); // User B connects via another client and also receives a ring on the newly-connected client. let _client_b2 = server.create_client(cx_b2, "user_b").await; @@ -135,7 +135,7 @@ async fn test_basic_calls( let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming()); executor.run_until_parked(); let call_b2 = incoming_call_b2.next().await.unwrap().unwrap(); - assert_eq!(call_b2.calling_user.github_login, "user_a"); + assert_eq!(call_b2.calling_user.username, "user_a"); // User B joins the room using the first client. active_call_b @@ -190,7 +190,7 @@ async fn test_basic_calls( // User C receives the call, but declines it. let call_c = incoming_call_c.next().await.unwrap().unwrap(); - assert_eq!(call_c.calling_user.github_login, "user_b"); + assert_eq!(call_c.calling_user.username, "user_b"); active_call_c.update(cx_c, |call, cx| call.decline_incoming(cx).unwrap()); assert!(incoming_call_c.next().await.unwrap().is_none()); @@ -236,7 +236,7 @@ async fn test_basic_calls( // User C accepts the call. let call_c = incoming_call_c.next().await.unwrap().unwrap(); - assert_eq!(call_c.calling_user.github_login, "user_a"); + assert_eq!(call_c.calling_user.username, "user_a"); active_call_c .update(cx_c, |call, cx| call.accept_incoming(cx)) .await @@ -677,7 +677,7 @@ async fn test_room_uniqueness( let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); let call_b1 = incoming_call_b.next().await.unwrap().unwrap(); - assert_eq!(call_b1.calling_user.github_login, "user_a"); + assert_eq!(call_b1.calling_user.username, "user_a"); // Ensure calling users A and B from client C fails. active_call_c @@ -739,7 +739,7 @@ async fn test_room_uniqueness( .unwrap(); executor.run_until_parked(); let call_b2 = incoming_call_b.next().await.unwrap().unwrap(); - assert_eq!(call_b2.calling_user.github_login, "user_c"); + assert_eq!(call_b2.calling_user.username, "user_c"); } #[gpui::test(iterations = 10)] @@ -1873,7 +1873,7 @@ async fn test_active_call_events( vec![room::Event::RemoteProjectShared { owner: Arc::new(User { legacy_id: client_a.user_id().unwrap(), - github_login: "user_a".into(), + username: "user_a".into(), avatar_uri: "avatar_a".into(), name: None, }), @@ -1892,7 +1892,7 @@ async fn test_active_call_events( vec![room::Event::RemoteProjectShared { owner: Arc::new(User { legacy_id: client_b.user_id().unwrap(), - github_login: "user_b".into(), + username: "user_b".into(), avatar_uri: "avatar_b".into(), name: None, }), @@ -2281,12 +2281,7 @@ async fn test_room_location( room.read_with(cx, |room, _| { room.remote_participants() .values() - .map(|participant| { - ( - participant.user.github_login.to_string(), - participant.location, - ) - }) + .map(|participant| (participant.user.username.to_string(), participant.location)) .collect() }) } @@ -2312,10 +2307,11 @@ async fn test_propagate_saves_and_fs_changes( let rust = Arc::new(Language::new( LanguageConfig { name: "Rust".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), @@ -2323,10 +2319,11 @@ async fn test_propagate_saves_and_fs_changes( let javascript = Arc::new(Language::new( LanguageConfig { name: "JavaScript".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["js".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), @@ -3478,7 +3475,7 @@ async fn test_fs_operations( project_b .update(cx_b, |project, cx| { - project.delete_entry(dir_entry.id, false, cx).unwrap() + project.delete_entry(dir_entry.id, cx).unwrap() }) .await .unwrap(); @@ -3506,7 +3503,7 @@ async fn test_fs_operations( project_b .update(cx_b, |project, cx| { - project.delete_entry(entry.id, false, cx).unwrap() + project.delete_entry(entry.id, cx).unwrap() }) .await .unwrap(); @@ -4135,10 +4132,11 @@ async fn test_collaborating_with_diagnostics( client_a.language_registry().add(Arc::new(Language::new( LanguageConfig { name: "Rust".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), @@ -4851,10 +4849,11 @@ async fn test_prettier_formatting_buffer( client_a.language_registry().add(Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), @@ -5144,6 +5143,109 @@ async fn test_definition( }); } +#[gpui::test] +async fn test_edit_prediction_definition( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).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; + let active_call_a = cx_a.read(ActiveCall::global); + + let capabilities = lsp::ServerCapabilities { + definition_provider: Some(OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "a.rs": "const ONE: usize = TWO;", + "b.rs": "const TWO: usize = 2;", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + 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 (buffer_b, _handle) = project_b + .update(cx_b, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("a.rs")), cx) + }) + .await + .unwrap(); + + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.set_request_handler::( + |_, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new( + lsp::Uri::from_file_path(path!("/root/b.rs")).unwrap(), + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + ), + ))) + }, + ); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let definitions = project_b + .update(cx_b, |project, cx| { + project.edit_prediction_definitions(&buffer_b, 19, false, cx) + }) + .await + .unwrap(); + + cx_b.read(|cx| { + assert_eq!(definitions.len(), 1); + assert_eq!( + definitions[0].path, + ProjectPath { + worktree_id, + path: rel_path("b.rs").into(), + } + ); + assert_eq!( + definitions[0].range.start.0, + language::PointUtf16::new(0, 6) + ); + assert_eq!(definitions[0].range.end.0, language::PointUtf16::new(0, 9)); + assert!( + project_b + .read(cx) + .get_open_buffer(&definitions[0].path, cx) + .is_none() + ); + }); +} + #[gpui::test(iterations = 10)] async fn test_references( executor: BackgroundExecutor, @@ -6393,7 +6495,7 @@ async fn test_contacts( .iter() .map(|contact| { ( - contact.user.github_login.clone().to_string(), + contact.user.username.clone().to_string(), if contact.online { "online" } else { "offline" }, if contact.busy { "busy" } else { "free" }, ) @@ -6629,7 +6731,7 @@ async fn test_join_call_after_screen_was_shared( let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); let call_b = incoming_call_b.next().await.unwrap().unwrap(); - assert_eq!(call_b.calling_user.github_login, "user_a"); + assert_eq!(call_b.calling_user.username, "user_a"); // User A shares their screen let display = gpui::TestScreenCaptureSource::new(); @@ -7315,6 +7417,20 @@ async fn test_remote_git_branches( }); assert_eq!(host_branch.name(), "totally-new-branch"); + + let default_branch_b = cx_b + .update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(false))) + .await + .unwrap() + .unwrap(); + assert_eq!(default_branch_b.as_deref(), Some("main")); + + let default_branch_with_remote_b = cx_b + .update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(true))) + .await + .unwrap() + .unwrap(); + assert_eq!(default_branch_with_remote_b.as_deref(), Some("origin/main")); } #[gpui::test] diff --git a/crates/collab/tests/integration/random_project_collaboration_tests.rs b/crates/collab/tests/integration/random_project_collaboration_tests.rs index c997f16ad31bff..d8e66e7a5ee938 100644 --- a/crates/collab/tests/integration/random_project_collaboration_tests.rs +++ b/crates/collab/tests/integration/random_project_collaboration_tests.rs @@ -448,7 +448,7 @@ impl RandomizedTest for ProjectCollaborationTest { .choose(rng) .unwrap(); if entry.path.as_ref().is_empty() { - worktree.root_name().into() + worktree.root_name().to_rel_path_buf() } else { worktree.root_name().join(&entry.path) } @@ -1047,10 +1047,11 @@ impl RandomizedTest for ProjectCollaborationTest { client.language_registry().add(Arc::new(Language::new( LanguageConfig { name: "Rust".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, None, @@ -1524,7 +1525,7 @@ fn buffer_for_full_path( else { return false; }; - worktree.read(cx).root_name().join(&file.path()).as_ref() == full_path + worktree.read(cx).root_name().join(&file.path()) == *full_path }) }) .cloned() diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index 87784b6328149d..d039bce3b39fdd 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -14,7 +14,7 @@ use gpui::{ use http_client::BlockedHttpClient; use language::{ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, - language_settings::{Formatter, FormatterList, LanguageSettings}, + language_settings::{ConfiguredLanguageServer, Formatter, FormatterList, LanguageSettings}, rust_lang, tree_sitter_typescript, }; use node_runtime::NodeRuntime; @@ -180,7 +180,7 @@ async fn test_sharing_an_ssh_remote_project( cx_b.read(|cx| { assert_eq!( LanguageSettings::for_buffer(buffer_b.read(cx), cx).language_servers, - ["override-rust-analyzer".to_string()] + [ConfiguredLanguageServer::new("override-rust-analyzer")] ) }); @@ -686,10 +686,11 @@ async fn test_ssh_collaboration_formatting_with_prettier( let ts_lang = Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..LanguageMatcher::default() - }, + }) + .into(), ..LanguageConfig::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), @@ -752,6 +753,8 @@ async fn test_ssh_collaboration_formatting_with_prettier( cx_a.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |file| { + file.project.all_languages.defaults.format_on_save = + Some(settings::FormatOnSave::On); file.project.all_languages.defaults.formatter = Some(FormatterList::default()); file.project.all_languages.defaults.prettier = Some(PrettierSettingsContent { allowed: Some(true), @@ -763,6 +766,8 @@ async fn test_ssh_collaboration_formatting_with_prettier( cx_b.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |file| { + file.project.all_languages.defaults.format_on_save = + Some(settings::FormatOnSave::On); file.project.all_languages.defaults.formatter = Some(FormatterList::Single( Formatter::LanguageServer(LanguageServerFormatterSpecifier::Current), )); @@ -1437,7 +1442,7 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m cx_a.read(|cx| { assert_eq!( LanguageSettings::for_buffer(buffer_before_approval.read(cx), cx).language_servers, - ["...".to_string()], + [ConfiguredLanguageServer::new("...")], "remote .zed/settings.json must not sync before trust approval" ) }); @@ -1465,7 +1470,7 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m cx_a.read(|cx| { assert_eq!( LanguageSettings::for_buffer(buffer_before_approval.read(cx), cx).language_servers, - ["override-rust-analyzer".to_string()], + [ConfiguredLanguageServer::new("override-rust-analyzer")], "remote .zed/settings.json should sync after trust approval" ) }); diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index de4c4a4e165b5c..3434674e630c58 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -48,7 +48,7 @@ use std::{ use util::path; use workspace::{MultiWorkspace, Workspace, WorkspaceStore}; -use livekit_client::test::TestServer as LivekitTestServer; +use livekit_client::test::{ManualUnixTimestampSource, TestServer as LivekitTestServer}; use crate::db_tests::TestDb; @@ -56,6 +56,7 @@ pub struct TestServer { pub app_state: Arc, pub test_livekit_server: Arc, pub test_db: TestDb, + livekit_timestamp_source: Arc, server: Arc, next_github_user_id: i32, connection_killers: Arc>>>, @@ -96,11 +97,13 @@ impl TestServer { TestDb::sqlite(deterministic.clone()) }; let livekit_server_id = NEXT_LIVEKIT_SERVER_ID.fetch_add(1, SeqCst); - let livekit_server = LivekitTestServer::create( + let livekit_timestamp_source = Arc::new(ManualUnixTimestampSource::new(1_234_567)); + let livekit_server = LivekitTestServer::create_with_timestamp_source( format!("http://livekit.{}.test", livekit_server_id), format!("devkey-{}", livekit_server_id), format!("secret-{}", livekit_server_id), deterministic.clone(), + livekit_timestamp_source.clone(), ) .unwrap(); let executor = Executor::Deterministic(deterministic.clone()); @@ -121,10 +124,15 @@ impl TestServer { forbid_connections: Default::default(), next_github_user_id: 0, test_db, + livekit_timestamp_source, test_livekit_server: livekit_server, } } + pub fn advance_livekit_timestamp(&self) { + self.livekit_timestamp_source.advance(); + } + pub async fn start2( cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, @@ -725,17 +733,17 @@ impl TestClient { current: store .contacts() .iter() - .map(|contact| contact.user.github_login.clone().to_string()) + .map(|contact| contact.user.username.clone().to_string()) .collect(), outgoing_requests: store .outgoing_contact_requests() .iter() - .map(|user| user.github_login.clone().to_string()) + .map(|user| user.username.clone().to_string()) .collect(), incoming_requests: store .incoming_contact_requests() .iter() - .map(|user| user.github_login.clone().to_string()) + .map(|user| user.username.clone().to_string()) .collect(), }) } diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 978af1387cbe77..80ade0d74a346e 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -36,7 +36,6 @@ client.workspace = true collections.workspace = true db.workspace = true editor.workspace = true -feature_flags.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true @@ -51,6 +50,7 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smallvec.workspace = true +smol.workspace = true telemetry.workspace = true theme.workspace = true theme_settings.workspace = true diff --git a/crates/collab_ui/src/call_stats_modal.rs b/crates/collab_ui/src/call_stats_modal.rs index 1481fc1602bf8d..99293bcd7f418f 100644 --- a/crates/collab_ui/src/call_stats_modal.rs +++ b/crates/collab_ui/src/call_stats_modal.rs @@ -1,13 +1,24 @@ -use call::{ActiveCall, Room, room}; +use anyhow::Result; +use call::{ + ActiveCall, + diagnostics::{CallDiagnostics, RemoteAudioDiagnostics}, + room, +}; use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Render, Subscription, - Window, + ClipboardItem, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Render, + Subscription, Task, TaskExt as _, Window, }; use livekit_client::ConnectionQuality; +use release_channel::{AppVersion, ReleaseChannel}; +use serde::Serialize; +use std::{cmp::Reverse, path::PathBuf}; use ui::prelude::*; use workspace::{ModalView, Workspace}; use zed_actions::ShowCallStats; +const WEBRTC_AUDIO_SAMPLES_PER_MILLISECOND: f64 = 48.0; +const PLAYBACK_FRAME_DURATION_MILLISECONDS: u64 = 10; + pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _cx| { workspace.register_action(|workspace, _: &ShowCallStats, window, cx| { @@ -23,6 +34,15 @@ pub struct CallStatsModal { _diagnostics_subscription: Option, } +#[derive(Serialize)] +struct ExportedCallDiagnostics { + application_version: String, + release_channel: String, + operating_system: &'static str, + architecture: &'static str, + diagnostics: call::diagnostics::CallDiagnosticsReport, +} + impl CallStatsModal { fn new(cx: &mut Context) -> Self { let mut this = Self { @@ -41,7 +61,7 @@ impl CallStatsModal { } fn observe_diagnostics(&mut self, cx: &mut Context) { - let diagnostics = active_room(cx).and_then(|room| room.read(cx).diagnostics().cloned()); + let diagnostics = call_diagnostics(cx); if let Some(diagnostics) = diagnostics { self._diagnostics_subscription = Some(cx.observe(&diagnostics, |_, _, cx| cx.notify())); @@ -61,7 +81,7 @@ impl CallStatsModal { self.observe_diagnostics(cx); } room::Event::RoomLeft { .. } => { - self._diagnostics_subscription = None; + self.observe_diagnostics(cx); cx.notify(); } _ => {} @@ -71,10 +91,66 @@ impl CallStatsModal { fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { cx.emit(DismissEvent); } + + fn report(cx: &App) -> Option { + let diagnostics = call_diagnostics(cx)?.read(cx).report(); + let release_channel = ReleaseChannel::try_global(cx) + .map(|channel| channel.dev_name().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + Some(ExportedCallDiagnostics { + application_version: AppVersion::global(cx).to_string(), + release_channel, + operating_system: std::env::consts::OS, + architecture: std::env::consts::ARCH, + diagnostics, + }) + } + + fn serialize_report(cx: &App) -> Option>> { + let report = Self::report(cx)?; + Some( + cx.background_executor() + .spawn(async move { Ok(serde_json::to_string_pretty(&report)?) }), + ) + } + + fn copy_report(&mut self, cx: &mut Context) { + let Some(report) = Self::serialize_report(cx) else { + return; + }; + cx.spawn(async move |_, cx| { + let report = report.await?; + cx.update(|cx| { + cx.write_to_clipboard(ClipboardItem::new_string(report)); + }); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn save_report(&mut self, cx: &mut Context) { + let Some(report) = Self::report(cx) else { + return; + }; + let save_dialog = + cx.prompt_for_new_path(&PathBuf::default(), Some("zed-call-diagnostics.json")); + cx.spawn(async move |_, cx| { + let Some(path) = save_dialog.await?? else { + return anyhow::Ok(()); + }; + cx.background_spawn(async move { + let report = serde_json::to_string_pretty(&report)?; + smol::fs::write(path, report).await?; + anyhow::Ok(()) + }) + .await + }) + .detach_and_log_err(cx); + } } -fn active_room(cx: &App) -> Option> { - ActiveCall::try_global(cx)?.read(cx).room().cloned() +fn call_diagnostics(cx: &App) -> Option> { + ActiveCall::try_global(cx)?.read(cx).call_diagnostics(cx) } fn quality_label(quality: Option) -> (&'static str, Color) { @@ -111,10 +187,10 @@ fn metric_rating(label: &str, value_ms: f64) -> (&'static str, Color) { } } -fn input_lag_rating(value_ms: f64) -> (&'static str, Color) { - if value_ms < 20.0 { +fn input_lag_rating(value_ms: u128) -> (&'static str, Color) { + if value_ms < 20 { ("Normal", Color::Success) - } else if value_ms < 50.0 { + } else if value_ms < 50 { ("High", Color::Warning) } else { ("Poor", Color::Error) @@ -131,6 +207,26 @@ fn packet_loss_rating(loss_pct: f64) -> (&'static str, Color) { } } +fn audio_issue_score(audio: &RemoteAudioDiagnostics) -> u64 { + audio + .concealment_events + .saturating_add(audio.frames_dropped) + .saturating_add(audio.queue_underflows) + .saturating_add(u64::from( + audio.packet_loss_pct.is_some_and(|loss| loss >= 1.0), + )) +} + +fn format_audio_duration(milliseconds: f64) -> String { + if milliseconds > 0.0 && milliseconds < 1.0 { + "<1ms".to_string() + } else if milliseconds < 10.0 && milliseconds.fract() != 0.0 { + format!("{milliseconds:.1}ms") + } else { + format!("{milliseconds:.0}ms") + } +} + impl EventEmitter for CallStatsModal {} impl ModalView for CallStatsModal {} @@ -142,35 +238,59 @@ impl Focusable for CallStatsModal { impl Render for CallStatsModal { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let room = active_room(cx); - let is_connected = room.is_some(); - let Some(stats) = room.and_then(|room| { - let diagnostics = room.read(cx).diagnostics()?; - Some(diagnostics.read(cx).stats().clone()) - }) else { - return v_flex() - .key_context("CallStatsModal") - .on_action(cx.listener(Self::dismiss)) - .track_focus(&self.focus_handle) - .elevation_3(cx) - .w(rems(24.)) - .p_4() - .gap_3() - .child( - Label::new("Unable to fetch call statistics") - .size(LabelSize::Large) - .color(Color::Error), - ); - }; + let is_connected = ActiveCall::try_global(cx) + .is_some_and(|active_call| active_call.read(cx).room().is_some()); + let diagnostics = call_diagnostics(cx); + let (latest, sample_count, retained_duration, recent_issue_count) = diagnostics + .as_ref() + .map(|diagnostics| { + let diagnostics = diagnostics.read(cx); + let retained_duration = diagnostics + .history() + .front() + .zip(diagnostics.history().back()) + .and_then(|(first, last)| last.elapsed.0.checked_sub(first.elapsed.0)) + .unwrap_or_default(); + let recent_issue_count = diagnostics + .history() + .iter() + .rev() + .take(60) + .filter(|snapshot| { + snapshot + .remote_audio + .iter() + .any(|audio| audio_issue_score(audio) > 0) + }) + .count(); + ( + diagnostics.latest().cloned(), + diagnostics.history().len(), + retained_duration, + recent_issue_count, + ) + }) + .unwrap_or_default(); + let stats = latest + .as_ref() + .map(|snapshot| snapshot.stats.clone()) + .unwrap_or_default(); + let mut remote_audio = latest + .map(|snapshot| snapshot.remote_audio) + .unwrap_or_default(); + remote_audio.sort_by_key(|audio| Reverse(audio_issue_score(audio))); - let (quality_text, quality_color) = quality_label(stats.connection_quality); + let (quality_text, quality_color) = + quality_label(stats.connection_quality.map(|inner| inner.0)); + let has_diagnostics = sample_count > 0; v_flex() .key_context("CallStatsModal") .on_action(cx.listener(Self::dismiss)) .track_focus(&self.focus_handle) .elevation_3(cx) - .w(rems(24.)) + .w(rems(36.)) + .max_h(rems(42.)) .p_4() .gap_3() .child( @@ -183,64 +303,205 @@ impl Render for CallStatsModal { .color(quality_color), ), ) - .when(!is_connected, |this| { + .when(!is_connected && has_diagnostics, |this| { + this.child( + h_flex() + .justify_center() + .child(Label::new("Showing diagnostics from the most recent call").color(Color::Muted)), + ) + }) + .when(!has_diagnostics, |this| { this.child( h_flex() .justify_center() .py_4() - .child(Label::new("Not in a call").color(Color::Muted)), + .child(Label::new("No call diagnostics available").color(Color::Muted)), ) }) - .when(is_connected, |this| { + .when(has_diagnostics, |this| { this.child( v_flex() - .gap_1() + .id("call-diagnostics-scroll") + .gap_3() + .max_h(rems(32.)) + .overflow_y_scroll() .child( - h_flex() - .gap_2() - .child(Label::new("Network").weight(FontWeight::SEMIBOLD)), + Label::new(format!( + "{sample_count} samples · {:.0}s retained · {recent_issue_count} affected intervals in the last 60s", + retained_duration.as_secs_f64() + )) + .size(LabelSize::Small) + .color(Color::Muted), ) - .child(self.render_metric_row( - "Latency", - "Time for data to travel to the server", - stats.latency_ms, - |v| format!("{:.0}ms", v), - |v| metric_rating("Latency", v), - )) - .child(self.render_metric_row( - "Jitter", - "Variance or fluctuation in latency", - stats.jitter_ms, - |v| format!("{:.0}ms", v), - |v| metric_rating("Jitter", v), - )) - .child(self.render_metric_row( - "Packet loss", - "Amount of data lost during transfer", - stats.packet_loss_pct, - |v| format!("{:.1}%", v), - |v| packet_loss_rating(v), - )) - .child(self.render_metric_row( - "Input lag", - "Delay from audio capture to WebRTC", - stats.input_lag.map(|d| d.as_secs_f64() * 1000.0), - |v| format!("{:.1}ms", v), - |v| input_lag_rating(v), - )), + .child( + v_flex() + .gap_1() + .child(Label::new("Network").weight(FontWeight::SEMIBOLD)) + .child(self.render_metric_row( + "Latency", + "Time for data to travel to the server", + stats.latency_ms, + |v| format!("{:.0}ms", v), + |v| metric_rating("Latency", v), + )) + .child(self.render_metric_row( + "Jitter", + "Variance or fluctuation in latency", + stats.jitter_ms, + |v| format!("{:.0}ms", v), + |v| metric_rating("Jitter", v), + )) + .child(self.render_metric_row( + "Packet loss", + "Amount of data lost during transfer", + stats.packet_loss_pct, + |v| format!("{:.1}%", v), + packet_loss_rating, + )) + .child(self.render_metric_row( + "Input lag", + "Delay from audio capture to WebRTC", + stats.input_lag.map(|d| d.0.as_millis()), + |v| format!("{}ms", v), + input_lag_rating, + )), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Inbound audio").weight(FontWeight::SEMIBOLD)) + .when(remote_audio.is_empty(), |this| { + this.child( + Label::new("Waiting for inbound audio statistics") + .color(Color::Muted), + ) + }) + .children( + remote_audio + .into_iter() + .map(|audio| self.render_remote_audio(audio)), + ), + ), + ) + }) + .when(has_diagnostics, |this| { + this.child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("copy-call-diagnostics", "Copy Report") + .on_click(cx.listener(|this, _, _, cx| this.copy_report(cx))), + ) + .child( + Button::new("save-call-diagnostics", "Save Report…") + .on_click(cx.listener(|this, _, _, cx| this.save_report(cx))), + ), ) }) } } impl CallStatsModal { - fn render_metric_row( + fn render_remote_audio(&self, audio: RemoteAudioDiagnostics) -> impl IntoElement { + let issue_score = audio_issue_score(&audio); + let (status, color) = if issue_score > 0 { + ("Affected", Color::Warning) + } else { + ("Healthy", Color::Success) + }; + let packet_loss = audio + .packet_loss_pct + .map(|loss| format!("{loss:.1}%")) + .unwrap_or_else(|| "—".to_string()); + let jitter_buffer_delay = audio + .jitter_buffer_delay_ms + .map(|delay| format!("{delay:.1}ms")) + .unwrap_or_else(|| "—".to_string()); + let repaired_audio_duration = format_audio_duration( + audio.concealed_samples as f64 / WEBRTC_AUDIO_SAMPLES_PER_MILLISECOND, + ); + let starved_audio_duration = format_audio_duration( + audio + .queue_underflows + .saturating_mul(PLAYBACK_FRAME_DURATION_MILLISECONDS) as f64, + ); + let dropped_audio_duration = format_audio_duration( + audio + .frames_dropped + .saturating_mul(PLAYBACK_FRAME_DURATION_MILLISECONDS) as f64, + ); + let buffered_audio_duration = format_audio_duration( + audio + .current_queue_depth + .saturating_mul(PLAYBACK_FRAME_DURATION_MILLISECONDS) as f64, + ); + let peak_buffered_audio_duration = format_audio_duration( + audio + .maximum_queue_depth + .saturating_mul(PLAYBACK_FRAME_DURATION_MILLISECONDS) as f64, + ); + let repair_event_label = if audio.concealment_events == 1 { + "event" + } else { + "events" + }; + + v_flex() + .px_2() + .py_1() + .gap_1() + .rounded_md() + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_1() + .child(Label::new(audio.participant_name)) + .child( + Label::new(format!( + "{} · {}", + audio.participant_id, audio.track_id + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child(Label::new(status).color(color)), + ) + .child( + Label::new(format!( + "Loss {packet_loss} · jitter {:.1}ms · jitter buffer {jitter_buffer_delay}", + audio.jitter_ms + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!( + "WebRTC repaired {repaired_audio_duration} in {} {repair_event_label}", + audio.concealment_events, + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!( + "Local playback starved for {starved_audio_duration} · dropped {dropped_audio_duration} · buffered {buffered_audio_duration} (peak {peak_buffered_audio_duration})", + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + } + + fn render_metric_row( &self, title: &str, description: &str, - value: Option, - format_value: impl Fn(f64) -> String, - rate: impl Fn(f64) -> (&'static str, Color), + value: Option, + format_value: impl Fn(T) -> String, + rate: impl Fn(T) -> (&'static str, Color), ) -> impl IntoElement { let (rating_text, rating_color, value_text) = match value { Some(v) => { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 907c462aa6366a..3e3201f81013f4 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -6,19 +6,18 @@ use crate::{CollaborationPanelSettings, channel_view::ChannelView}; use anyhow::Context as _; use call::ActiveCall; use channel::{Channel, ChannelEvent, ChannelStore}; -use client::{ChannelId, Client, Contact, Notification, User, UserStore}; +use client::{ChannelId, Client, Contact, Notification, Status, User, UserStore}; use collections::{HashMap, HashSet}; use contact_finder::ContactFinder; use db::kvp::KeyValueStore; use editor::{Editor, EditorElement, EditorStyle}; -use feature_flags::{AutoWatchFeatureFlag, FeatureFlagAppExt as _}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div, - Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, ListOffset, - ListState, MouseDownEvent, Pixels, Point, PromptLevel, SharedString, Subscription, Task, - TextStyle, WeakEntity, Window, actions, anchored, canvas, deferred, div, fill, list, point, - prelude::*, px, + Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, MouseButton, + MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, SharedString, Subscription, Task, + TextStyle, UniformListScrollHandle, WeakEntity, Window, actions, anchored, canvas, deferred, + div, fill, point, prelude::*, px, size, uniform_list, }; use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious}; @@ -31,13 +30,14 @@ use rpc::{ use serde::{Deserialize, Serialize}; use settings::Settings; use smallvec::SmallVec; -use std::{mem, sync::Arc, time::Duration}; +use std::{mem, ops::Range, sync::Arc, time::Duration}; use theme::ActiveTheme; use theme_settings::ThemeSettings; use ui::{ - Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, Facepile, - HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, TintColor, Tooltip, - prelude::*, tooltip_container, + Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, + DecoratedIcon, Disclosure, DockSide, Facepile, HighlightedLabel, IconButtonShape, + IconDecoration, IconDecorationKind, IndentGuideColors, Indicator, ListHeader, ListItem, + ScrollAxes, Scrollbars, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, tooltip_container, }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ @@ -52,6 +52,12 @@ use workspace::{ const FILTER_OCCUPIED_CHANNELS_KEY: &str = "filter_occupied_channels"; const FAVORITE_CHANNELS_KEY: &str = "favorite_channels"; +const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel"; +const TOAST_DURATION: Duration = Duration::from_secs(5); + +fn panel_row_height() -> Rems { + rems_from_px(26.) +} actions!( collab_panel, @@ -92,8 +98,10 @@ struct ChannelMoveClipboard { channel_id: ChannelId, } -const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel"; -const TOAST_DURATION: Duration = Duration::from_secs(5); +struct ContactContextMenu { + user_id: u64, + via_ellipsis_button: bool, +} pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { @@ -258,7 +266,8 @@ pub struct CollabPanel { pending_favorites_serialization: Task>, pending_filter_serialization: Task>, context_menu: Option<(Entity, Point, Subscription)>, - list_state: ListState, + contact_context_menu: Option, + scroll_handle: UniformListScrollHandle, filter_editor: Entity, channel_name_editor: Entity, channel_editing_state: Option, @@ -274,6 +283,7 @@ pub struct CollabPanel { collapsed_channels: Vec, filter_occupied_channels: bool, workspace: WeakEntity, + hovered_channel: Option<(ChannelId, bool)>, notification_store: Entity, current_notification_toast: Option<(u64, Task<()>)>, mark_as_read_tasks: HashMap>>, @@ -340,6 +350,17 @@ enum ListEntry { } impl CollabPanel { + fn is_collaboration_disabled_by_organization(&self, cx: &App) -> bool { + self.user_store + .read(cx) + .current_organization_configuration() + .is_some_and(|config| !config.is_collaboration_enabled) + } + + fn is_signed_in_view_visible(status: Status, is_collaboration_disabled: bool) -> bool { + !is_collaboration_disabled && status.is_or_was_connected() && !status.is_signing_in() + } + pub fn new( workspace: &mut Workspace, window: &mut Window, @@ -375,7 +396,9 @@ impl CollabPanel { &channel_name_editor, window, |this: &mut Self, _, event, window, cx| { - if let editor::EditorEvent::Blurred = event { + if let editor::EditorEvent::Blurred = event + && window.is_window_active() + { if let Some(state) = &this.channel_editing_state && state.pending_name().is_some() { @@ -392,12 +415,14 @@ impl CollabPanel { let mut this = Self { focus_handle: cx.focus_handle(), channel_clipboard: None, + hovered_channel: None, fs: workspace.app_state().fs.clone(), pending_panel_serialization: Task::ready(None), pending_favorites_serialization: Task::ready(None), pending_filter_serialization: Task::ready(None), context_menu: None, - list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), + contact_context_menu: None, + scroll_handle: UniformListScrollHandle::new(), channel_name_editor, filter_editor, entries: Vec::default(), @@ -573,7 +598,8 @@ impl CollabPanel { } fn scroll_to_item(&mut self, ix: usize) { - self.list_state.scroll_to_reveal_item(ix) + self.scroll_handle + .scroll_to_item(ix, ScrollStrategy::Nearest) } fn update_entries(&mut self, select_same_item: bool, cx: &mut Context) { @@ -607,7 +633,7 @@ impl CollabPanel { if let Some(user) = self.user_store.read(cx).current_user() { self.match_candidates.clear(); self.match_candidates - .push(StringMatchCandidate::new(0, &user.github_login)); + .push(StringMatchCandidate::new(0, &user.username)); let matches = fg_executor.block_on(match_strings( &self.match_candidates, &query, @@ -649,7 +675,7 @@ impl CollabPanel { .extend(room.remote_participants().values().map(|participant| { StringMatchCandidate::new( participant.user.legacy_id as usize, - &participant.user.github_login, + &participant.user.username, ) })); let mut matches = fg_executor.block_on(match_strings( @@ -698,12 +724,14 @@ impl CollabPanel { // Populate pending participants. self.match_candidates.clear(); - self.match_candidates - .extend(room.pending_participants().iter().enumerate().map( - |(id, participant)| { - StringMatchCandidate::new(id, &participant.github_login) - }, - )); + self.match_candidates.extend( + room.pending_participants() + .iter() + .enumerate() + .map(|(id, participant)| { + StringMatchCandidate::new(id, &participant.username) + }), + ); let matches = fg_executor.block_on(match_strings( &self.match_candidates, &query, @@ -937,7 +965,7 @@ impl CollabPanel { incoming .iter() .enumerate() - .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)), + .map(|(ix, user)| StringMatchCandidate::new(ix, &user.username)), ); let matches = fg_executor.block_on(match_strings( &self.match_candidates, @@ -962,7 +990,7 @@ impl CollabPanel { outgoing .iter() .enumerate() - .map(|(ix, user)| StringMatchCandidate::new(ix, &user.github_login)), + .map(|(ix, user)| StringMatchCandidate::new(ix, &user.username)), ); let matches = fg_executor.block_on(match_strings( &self.match_candidates, @@ -995,7 +1023,7 @@ impl CollabPanel { contacts .iter() .enumerate() - .map(|(ix, contact)| StringMatchCandidate::new(ix, &contact.user.github_login)), + .map(|(ix, contact)| StringMatchCandidate::new(ix, &contact.user.username)), ); let matches = fg_executor.block_on(match_strings( @@ -1067,54 +1095,82 @@ impl CollabPanel { }); } - let old_scroll_top = self.list_state.logical_scroll_top(); - self.list_state.reset(self.entries.len()); - if scroll_to_top { - self.list_state.scroll_to(ListOffset::default()); - } else { - // Attempt to maintain the same scroll position. - if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) { - let new_scroll_top = self + let state = self.scroll_handle.0.borrow(); + state.base_handle.set_offset(Point::default()); + } else if let Some(row_height) = self.row_height(old_entries.len()) { + // Attempt to maintain the same scroll position. Since every row has + // the same height, the scroll offset can be translated to and from a + // logical entry index plus a pixel remainder. + let old_scroll_y = -self.scroll_handle.0.borrow().base_handle.offset().y; + let old_top_ix = (old_scroll_y / row_height) as usize; + let offset_in_item = old_scroll_y - row_height * old_top_ix as f32; + + if let Some(old_top_entry) = old_entries.get(old_top_ix) { + let new_scroll_y = self .entries .iter() .position(|entry| entry == old_top_entry) - .map(|item_ix| ListOffset { - item_ix, - offset_in_item: old_scroll_top.offset_in_item, - }) + .map(|item_ix| row_height * item_ix as f32 + offset_in_item) .or_else(|| { - let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?; + let entry_after_old_top = old_entries.get(old_top_ix + 1)?; let item_ix = self .entries .iter() .position(|entry| entry == entry_after_old_top)?; - Some(ListOffset { - item_ix, - offset_in_item: Pixels::ZERO, - }) + Some(row_height * item_ix as f32) }) .or_else(|| { - let entry_before_old_top = - old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?; + let entry_before_old_top = old_entries.get(old_top_ix.saturating_sub(1))?; let item_ix = self .entries .iter() .position(|entry| entry == entry_before_old_top)?; - Some(ListOffset { - item_ix, - offset_in_item: Pixels::ZERO, - }) - }); + Some(row_height * item_ix as f32) + }) + .unwrap_or(old_scroll_y); - self.list_state - .scroll_to(new_scroll_top.unwrap_or(old_scroll_top)); + let state = self.scroll_handle.0.borrow(); + let mut offset = state.base_handle.offset(); + offset.y = -new_scroll_y; + state.base_handle.set_offset(offset); } } cx.notify(); } + fn row_height(&self, item_count: usize) -> Option { + if item_count == 0 { + return None; + } + let state = self.scroll_handle.0.borrow(); + let height = state.last_item_size?.contents.height / item_count as f32; + (height > px(0.)).then_some(height) + } + + fn bounds_for_item(&self, ix: usize) -> Option> { + let row_height = self.row_height(self.entries.len())?; + let state = self.scroll_handle.0.borrow(); + let list_bounds = state.base_handle.bounds(); + let origin = list_bounds.origin + + point( + px(0.), + state.base_handle.offset().y + row_height * ix as f32, + ); + Some(Bounds::new( + origin, + size(list_bounds.size.width, row_height), + )) + } + + fn dock_side(&self, cx: &App) -> DockSide { + match CollaborationPanelSettings::get_global(cx).dock { + DockPosition::Right => DockSide::Right, + _ => DockSide::Left, + } + } + fn render_call_participant( &self, user: &Arc, @@ -1131,7 +1187,7 @@ impl CollabPanel { .current_user() .map(|user| user.legacy_id) == Some(user_id); - let tooltip = format!("Follow {}", user.github_login); + let tooltip = format!("Follow {}", user.username); let is_call_admin = ActiveCall::global(cx).read(cx).room().is_some_and(|room| { room.read(cx).local_participant().role == proto::ChannelRole::Admin @@ -1155,10 +1211,11 @@ impl CollabPanel { Empty.into_any_element() }; - ListItem::new(user.github_login.clone()) + ListItem::new(user.username.clone()) .start_slot(Avatar::new(user.avatar_uri.clone())) .child(render_participant_name_and_handle(user)) - .toggle_state(is_selected) + .focused(is_selected) + .dock(self.dock_side(cx)) .end_slot(end_slot) .tooltip(Tooltip::text("Click to Follow")) .when_some(peer_id, |el, peer_id| { @@ -1205,8 +1262,9 @@ impl CollabPanel { .into(); ListItem::new(project_id as usize) - .height(rems_from_px(24.)) - .toggle_state(is_selected) + .height(panel_row_height()) + .focused(is_selected) + .dock(self.dock_side(cx)) .on_click(cx.listener(move |this, _, window, cx| { this.workspace .update(cx, |workspace, cx| { @@ -1246,8 +1304,9 @@ impl CollabPanel { let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize); ListItem::new(("screen", id)) - .height(rems_from_px(24.)) - .toggle_state(is_selected) + .height(panel_row_height()) + .focused(is_selected) + .dock(self.dock_side(cx)) .start_slot( h_flex() .gap_1p5() @@ -1293,8 +1352,9 @@ impl CollabPanel { let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id); ListItem::new("channel-notes") - .height(rems_from_px(24.)) - .toggle_state(is_selected) + .height(panel_row_height()) + .focused(is_selected) + .dock(self.dock_side(cx)) .on_click(cx.listener(move |this, _, window, cx| { this.open_channel_notes(channel_id, window, cx); })) @@ -1450,7 +1510,7 @@ impl CollabPanel { if this.context_menu.as_ref().is_some_and(|context_menu| { context_menu.0.focus_handle(cx).contains_focused(window, cx) }) { - cx.focus_self(window); + this.activation_focus_handle(cx).focus(window, cx); } this.context_menu.take(); cx.notify(); @@ -1634,7 +1694,7 @@ impl CollabPanel { if this.context_menu.as_ref().is_some_and(|context_menu| { context_menu.0.focus_handle(cx).contains_focused(window, cx) }) { - cx.focus_self(window); + this.activation_focus_handle(cx).focus(window, cx); } this.context_menu.take(); cx.notify(); @@ -1649,9 +1709,14 @@ impl CollabPanel { &mut self, position: Point, contact: Arc, + via_ellipsis_button: bool, window: &mut Window, cx: &mut Context, ) { + self.contact_context_menu = Some(ContactContextMenu { + user_id: contact.user.legacy_id, + via_ellipsis_button, + }); let this = cx.entity(); let in_room = ActiveCall::global(cx).read(cx).room().is_some(); @@ -1660,9 +1725,9 @@ impl CollabPanel { if contact.online && !contact.busy { let label = if in_room { - format!("Invite {} to join", contact.user.github_login) + format!("Invite {} to join", contact.user.username) } else { - format!("Call {}", contact.user.github_login) + format!("Call {}", contact.user.username) }; context_menu = context_menu.entry(label, None, { let this = this.clone(); @@ -1680,7 +1745,7 @@ impl CollabPanel { this.update(cx, |this, cx| { this.remove_contact( contact.user.legacy_id, - &contact.user.github_login, + &contact.user.username, window, cx, ); @@ -1697,9 +1762,10 @@ impl CollabPanel { if this.context_menu.as_ref().is_some_and(|context_menu| { context_menu.0.focus_handle(cx).contains_focused(window, cx) }) { - cx.focus_self(window); + this.activation_focus_handle(cx).focus(window, cx); } this.context_menu.take(); + this.contact_context_menu.take(); cx.notify(); }, ); @@ -1930,7 +1996,7 @@ impl CollabPanel { cx.notify(); } } - cx.focus_self(window); + self.activation_focus_handle(cx).focus(window, cx); true } else { false @@ -1997,7 +2063,7 @@ impl CollabPanel { self.serialize(cx); self.update_entries(true, cx); cx.notify(); - cx.focus_self(window); + self.activation_focus_handle(cx).focus(window, cx); } fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool { @@ -2353,10 +2419,7 @@ impl CollabPanel { window: &mut Window, cx: &mut Context, ) { - let Some(bounds) = self - .selection - .and_then(|ix| self.list_state.bounds_for_item(ix)) - else { + let Some(bounds) = self.selection.and_then(|ix| self.bounds_for_item(ix)) else { return; }; @@ -2373,7 +2436,7 @@ impl CollabPanel { }; if let Some(contact) = self.selected_contact() { - self.deploy_contact_context_menu(bounds.center(), contact, window, cx); + self.deploy_contact_context_menu(bounds.center(), contact, false, window, cx); cx.stop_propagation(); } } @@ -2510,8 +2573,10 @@ impl CollabPanel { .update(cx, |channels, _| channels.remove_channel(channel_id)) .await .notify_workspace_async_err(workspace, &mut cx); - this.update_in(cx, |_, window, cx| cx.focus_self(window)) - .ok(); + this.update_in(cx, |this, window, cx| { + this.activation_focus_handle(cx).focus(window, cx); + }) + .ok(); } anyhow::Ok(()) }) @@ -2703,7 +2768,9 @@ impl CollabPanel { ) -> AnyElement { let entry = self.entries[ix].clone(); - let is_selected = self.selection == Some(ix); + let is_selected = + self.selection == Some(ix) && self.focus_handle.contains_focused(window, cx); + match entry { ListEntry::Header(section) => { let is_collapsed = self.collapsed_sections.contains(§ion); @@ -2728,13 +2795,14 @@ impl CollabPanel { channel, depth, has_children, + is_favorite, string_match, - .. } => self .render_channel( &channel, depth, has_children, + is_favorite, is_selected, ix, string_match.as_ref(), @@ -2780,7 +2848,7 @@ impl CollabPanel { } } - fn render_signed_in(&mut self, _: &mut Window, cx: &mut Context) -> Div { + fn render_signed_in(&mut self, window: &mut Window, cx: &mut Context) -> Div { self.channel_store.update(cx, |channel_store, _| { channel_store.initialize(); }); @@ -2816,11 +2884,40 @@ impl CollabPanel { }), ) .child( - list( - self.list_state.clone(), - cx.processor(Self::render_list_entry), - ) - .size_full(), + div() + .size_full() + .child( + uniform_list( + "collab-panel-entries", + self.entries.len(), + cx.processor(|this, range: Range, window, cx| { + range + .map(|ix| this.render_list_entry(ix, window, cx)) + .collect() + }), + ) + .size_full() + .track_scroll(&self.scroll_handle) + .with_decoration( + ui::indent_guides(px(20.), IndentGuideColors::panel(cx)) + .with_left_offset(ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET) + .with_compute_indents_fn(cx.entity(), |this, range, _, _| { + range + .map(|ix| match this.entries.get(ix) { + Some(ListEntry::Channel { depth, .. }) + | Some(ListEntry::ChannelEditor { depth }) => *depth, + _ => 0, + }) + .collect() + }), + ), + ) + .custom_scrollbars( + Scrollbars::new(ScrollAxes::Vertical) + .tracked_scroll_handle(&self.scroll_handle), + window, + cx, + ), ) } @@ -2877,10 +2974,10 @@ impl CollabPanel { channel_link = Some(channel.link(cx)); (channel_icon, channel_tooltip_text) = match channel.visibility { proto::ChannelVisibility::Public => { - (Some("icons/public.svg"), Some("Copy public channel link.")) + (Some(IconName::Hash), Some("Copy Public Channel Link")) } proto::ChannelVisibility::Members => { - (Some("icons/hash.svg"), Some("Copy private channel link.")) + (Some(IconName::Lock), Some("Copy Private Channel Link")) } }; @@ -2911,80 +3008,61 @@ impl CollabPanel { let is_auto_watching = auto_watch_state.enabled(); let button = match section { - Section::ActiveCall => { - let has_auto_watch_flag = cx.has_flag::(); - let show_auto_watch = has_auto_watch_flag && is_auto_watching; - let show_copy = channel_link.is_some(); - - if show_auto_watch || show_copy { - Some( - h_flex() - .when_some(channel_link, |this, channel_link| { - this.child( - CopyButton::new("copy-channel-link", channel_link) - .visible_on_hover("section-header") - .tooltip_label("Copy Channel Link"), - ) - }) - .when(has_auto_watch_flag, |this| { - this.child( - IconButton::new( - "auto-watch-screens", - if is_auto_watching { - IconName::Eye - } else { - IconName::EyeOff - }, - ) - .icon_size(IconSize::Small) - .toggle_state(is_auto_watching) - .selected_style(match auto_watch_state { - AutoWatch::Paused => { - ButtonStyle::Tinted(TintColor::Warning) - } - _ => ButtonStyle::Tinted(TintColor::Accent), - }) - .when(!is_auto_watching, |this| { - this.visible_on_hover("section-header") - }) - .tooltip(Tooltip::text(match auto_watch_state { - AutoWatch::Paused => { - "Auto Watch Screens (paused while sharing)" - } - AutoWatch::Active { .. } => "Stop Auto Watching Screens", - AutoWatch::Off => "Auto Watch Screens", - })) - .on_click(cx.listener( - |this, _, window, cx| { - this.workspace - .update(cx, |workspace, cx| { - workspace.toggle_auto_watch(window, cx) - }) - .ok(); - }, - )), - ) - }) - .into_any_element(), + Section::ActiveCall => Some( + h_flex() + .when_some(channel_link, |this, channel_link| { + this.child( + CopyButton::new("copy-channel-link", channel_link) + .visible_on_hover("section-header") + .tooltip_label("Copy Channel Link"), + ) + }) + .child( + IconButton::new( + "auto-watch-screens", + if is_auto_watching { + IconName::Eye + } else { + IconName::EyeOff + }, + ) + .icon_size(IconSize::Small) + .toggle_state(is_auto_watching) + .selected_style(match auto_watch_state { + AutoWatch::Paused => ButtonStyle::Tinted(TintColor::Warning), + _ => ButtonStyle::Tinted(TintColor::Accent), + }) + .when(!is_auto_watching, |this| { + this.visible_on_hover("section-header") + }) + .tooltip(Tooltip::text(match auto_watch_state { + AutoWatch::Paused => "Auto Watch Screens (paused while sharing)", + AutoWatch::Active { .. } => "Stop Auto Watching Screens", + AutoWatch::Off => "Auto Watch Screens", + })) + .on_click(cx.listener(|this, _, window, cx| { + this.workspace + .update(cx, |workspace, cx| workspace.toggle_auto_watch(window, cx)) + .ok(); + })), ) - } else { - None - } - } + .into_any_element(), + ), Section::Contacts => Some( IconButton::new("add-contact", IconName::Plus) .icon_size(IconSize::Small) .on_click( cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)), ) - .tooltip(Tooltip::text("Search for new contact")) + .tooltip(Tooltip::text("Search for New Contact")) .into_any_element(), ), Section::Channels => { Some( h_flex() + .gap_px() .child( - IconButton::new("filter-occupied-channels", IconName::ListFilter) + IconButton::new("filter-occupied-channels", IconName::OnCall) .icon_size(IconSize::Small) .toggle_state(self.filter_occupied_channels) .on_click(cx.listener(|this, _, _window, cx| { @@ -3024,18 +3102,21 @@ impl CollabPanel { | Section::Offline => true, }; - h_flex().w_full().group("section-header").child( + h_flex().group("section-header").w_full().child( ListHeader::new(text) + .height(panel_row_height()) .when(can_collapse, |header| { - header.toggle(Some(!is_collapsed)).on_toggle(cx.listener( - move |this, _, _, cx| { + header + .toggle(Some(!is_collapsed)) + .disclosure_shape(IconButtonShape::Square) + .on_toggle(cx.listener(move |this, _, _, cx| { this.toggle_section_expanded(section, cx); - }, - )) + })) }) .inset(true) .end_slot::(button) - .toggle_state(is_selected), + .focused(is_selected) + .dock(self.dock_side(cx)), ) } @@ -3048,30 +3129,70 @@ impl CollabPanel { ) -> impl IntoElement { let online = contact.online; let busy = contact.busy || calling; - let github_login = contact.user.github_login.clone(); - let item = ListItem::new(github_login.clone()) + let username = contact.user.username.clone(); + let open_context_menu = self + .contact_context_menu + .as_ref() + .filter(|menu| menu.user_id == contact.user.legacy_id); + let context_menu_open_via_button = + open_context_menu.is_some_and(|menu| menu.via_ellipsis_button); + + let context_menu_open_via_row = + open_context_menu.is_some_and(|menu| !menu.via_ellipsis_button); + + let item = ListItem::new(username.clone()) .indent_level(1) .indent_step_size(px(20.)) - .toggle_state(is_selected) + .toggle_state(context_menu_open_via_row) + .focused(is_selected) + .dock(self.dock_side(cx)) .child( h_flex() .w_full() .justify_between() - .child(render_participant_name_and_handle(&contact.user)) + .child( + h_flex() + .pl_2() + .gap_1p5() + .child( + Avatar::new(contact.user.avatar_uri.clone()) + .indicator::(if online { + let background = if is_selected || context_menu_open_via_row { + cx.theme().colors().ghost_element_selected + } else { + cx.theme().colors().panel_background + }; + Some( + AvatarAvailabilityIndicator::new(match busy { + true => ui::CollaboratorAvailability::Busy, + false => ui::CollaboratorAvailability::Free, + }) + .border_color(background), + ) + } else { + None + }), + ) + .child(render_participant_name_and_handle(&contact.user)), + ) .when(calling, |el| { - el.child(Label::new("Calling").color(Color::Muted)) + el.child(Label::new("Calling…").color(Color::Muted)) }) .when(!calling, |el| { el.child( IconButton::new("contact context menu", IconName::Ellipsis) .icon_color(Color::Muted) - .visible_on_hover("") + .toggle_state(context_menu_open_via_button) + .when(!context_menu_open_via_button, |this| { + this.visible_on_hover("") + }) .on_click(cx.listener({ let contact = contact.clone(); move |this, event: &ClickEvent, window, cx| { this.deploy_contact_context_menu( event.position(), contact.clone(), + true, window, cx, ); @@ -3083,40 +3204,36 @@ impl CollabPanel { .on_secondary_mouse_down(cx.listener({ let contact = contact.clone(); move |this, event: &MouseDownEvent, window, cx| { - this.deploy_contact_context_menu(event.position, contact.clone(), window, cx); + this.deploy_contact_context_menu( + event.position, + contact.clone(), + false, + window, + cx, + ); } - })) - .start_slot( - // todo handle contacts with no avatar - Avatar::new(contact.user.avatar_uri.clone()) - .indicator::(if online { - Some(AvatarAvailabilityIndicator::new(match busy { - true => ui::CollaboratorAvailability::Busy, - false => ui::CollaboratorAvailability::Free, - })) - } else { - None - }), - ); + })); div() - .id(github_login.clone()) + .id(username.clone()) .group("") .child(item) - .tooltip(move |_, cx| { - let text = if !online { - format!(" {} is offline", &github_login) - } else if busy { - format!(" {} is on a call", &github_login) - } else { - let room = ActiveCall::global(cx).read(cx).room(); - if room.is_some() { - format!("Invite {} to join call", &github_login) + .when(open_context_menu.is_none(), |this| { + this.tooltip(move |_, cx| { + let text = if !online { + format!(" {} is Offline", &username) + } else if busy { + format!(" {} is on a Call", &username) } else { - format!("Call {}", &github_login) - } - }; - Tooltip::simple(text, cx) + let room = ActiveCall::global(cx).read(cx).room(); + if room.is_some() { + format!("Invite {} to Join Call", &username) + } else { + format!("Call {}", &username) + } + }; + Tooltip::simple(text, cx) + }) }) } @@ -3127,7 +3244,7 @@ impl CollabPanel { is_selected: bool, cx: &mut Context, ) -> impl IntoElement { - let github_login = user.github_login.clone(); + let username = user.username.clone(); let user_id = user.legacy_id; let is_response_pending = self.user_store.read(cx).is_contact_request_pending(user); let color = if is_response_pending { @@ -3152,7 +3269,7 @@ impl CollabPanel { .tooltip(Tooltip::text("Accept invite")), ] } else { - let github_login = github_login.clone(); + let github_login = username.clone(); vec![ IconButton::new("remove_contact", IconName::Close) .on_click(cx.listener(move |this, _, window, cx| { @@ -3163,15 +3280,16 @@ impl CollabPanel { ] }; - ListItem::new(github_login.clone()) + ListItem::new(username.clone()) .indent_level(1) .indent_step_size(px(20.)) - .toggle_state(is_selected) + .focused(is_selected) + .dock(self.dock_side(cx)) .child( h_flex() .w_full() .justify_between() - .child(Label::new(github_login)) + .child(Label::new(username)) .child(h_flex().children(controls)), ) .start_slot(Avatar::new(user.avatar_uri.clone())) @@ -3210,7 +3328,8 @@ impl CollabPanel { ]; ListItem::new(("channel-invite", channel.id.0 as usize)) - .toggle_state(is_selected) + .focused(is_selected) + .dock(self.dock_side(cx)) .child( h_flex() .w_full() @@ -3229,7 +3348,8 @@ impl CollabPanel { ListItem::new("contact-placeholder") .child(Icon::new(IconName::Plus)) .child(Label::new("Add a Contact")) - .toggle_state(is_selected) + .focused(is_selected) + .dock(self.dock_side(cx)) .on_click(cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx))) } @@ -3238,6 +3358,7 @@ impl CollabPanel { channel: &Channel, depth: usize, has_children: bool, + is_favorite_entry: bool, is_selected: bool, ix: usize, string_match: Option<&StringMatch>, @@ -3310,7 +3431,60 @@ impl CollabPanel { (IconName::Star, Color::Default, "Add to Favorites") }; - let height = rems_from_px(24.); + let height = panel_row_height(); + + let icon_name = if is_public { + IconName::Hash + } else { + IconName::Lock + }; + + let icon_knockout_bg = if is_selected || is_active { + cx.theme().colors().ghost_element_selected + } else { + cx.theme().colors().panel_background + }; + + let is_hovered = self.hovered_channel == Some((channel_id, is_favorite_entry)); + + let icon_slot = h_flex().size_4().flex_none().justify_center().map(|slot| { + if has_children && is_hovered { + slot.child( + h_flex() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Disclosure::new("toggle", disclosed.unwrap_or(false)) + .shape(IconButtonShape::Square) + .on_click(cx.listener(move |this, _, window, cx| { + this.toggle_channel_collapsed(channel_id, window, cx) + })), + ), + ) + } else if has_notes_notification { + slot.child(DecoratedIcon::new( + Icon::new(icon_name) + .size(IconSize::Small) + .color(Color::Muted), + Some( + IconDecoration::new(IconDecorationKind::Dot, icon_knockout_bg, cx) + .color(cx.theme().colors().text_accent) + .position(Point { + x: px(-3.), + y: px(6.), + }), + ), + )) + } else { + slot.child( + Icon::new(icon_name) + .size(IconSize::Small) + .color(Color::Muted), + ) + } + }); + + let panel_bg = cx.theme().colors().panel_background; + let hover_bg = panel_bg.blend(cx.theme().colors().ghost_element_hover); h_flex() .id(ix) @@ -3318,6 +3492,15 @@ impl CollabPanel { .h(height) .w_full() .overflow_hidden() + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + let hovered_channel = hovered.then_some((channel_id, is_favorite_entry)); + if this.hovered_channel != hovered_channel + && (*hovered || this.hovered_channel == Some((channel_id, is_favorite_entry))) + { + this.hovered_channel = hovered_channel; + cx.notify(); + } + })) .when(!channel.is_root_channel(), |el| { el.on_drag(channel.clone(), move |channel, _, _, cx| { cx.new(|_| DraggedChannelView { @@ -3346,14 +3529,11 @@ impl CollabPanel { .child( ListItem::new(ix) .height(height) - // Add one level of depth for the disclosure arrow. - .indent_level(depth + 1) + .indent_level(depth) .indent_step_size(px(20.)) - .toggle_state(is_selected || is_active) - .toggle(disclosed) - .on_toggle(cx.listener(move |this, _, window, cx| { - this.toggle_channel_collapsed(channel_id, window, cx) - })) + .toggle_state(is_active) + .focused(is_selected) + .dock(self.dock_side(cx)) .on_click(cx.listener(move |this, _, window, cx| { if is_active { this.open_channel_notes(channel_id, window, cx) @@ -3377,27 +3557,8 @@ impl CollabPanel { .id(format!("inside-{}", channel_id.0)) .w_full() .gap_1() - .child( - div() - .relative() - .child( - Icon::new(if is_public { - IconName::Public - } else { - IconName::Hash - }) - .size(IconSize::Small) - .color(Color::Muted), - ) - .children(has_notes_notification.then(|| { - div() - .w_1p5() - .absolute() - .right(px(-1.)) - .top(px(-1.)) - .child(Indicator::dot().color(Color::Info)) - })), - ) + .pl_0p5() + .child(icon_slot) .child( h_flex() .id(channel_id.0 as usize) @@ -3426,17 +3587,20 @@ impl CollabPanel { ) .child( h_flex() + .id("channel-controls") .visible_on_hover("") .h_full() .absolute() .right_0() - .px_1() + .pl_1() + .pr_2() .gap_px() + .bg(hover_bg) .rounded_l_md() - .bg(cx.theme().colors().background) .child({ let focus_handle = self.focus_handle.clone(); IconButton::new("channel_favorite", favorite_icon) + .layer(ui::ElevationIndex::ModalSurface) .icon_size(IconSize::Small) .icon_color(favorite_color) .on_click(cx.listener(move |this, _, _window, cx| { @@ -3454,6 +3618,7 @@ impl CollabPanel { .child({ let focus_handle = self.focus_handle.clone(); IconButton::new("channel_notes", IconName::Reader) + .layer(ui::ElevationIndex::ModalSurface) .icon_size(IconSize::Small) .on_click(cx.listener(move |this, _, window, cx| { this.open_channel_notes(channel_id, window, cx) @@ -3478,8 +3643,7 @@ impl CollabPanel { ) -> impl IntoElement { let item = ListItem::new("channel-editor") .inset(false) - // Add one level of depth for the disclosure arrow. - .indent_level(depth + 1) + .indent_level(depth) .indent_step_size(px(20.)) .start_slot( Icon::new(IconName::Hash) @@ -3532,14 +3696,14 @@ impl CollabPanel { let requester = user_store.get_cached_user(*sender_id)?; Some(( Some(requester.clone()), - format!("{} wants to add you as a contact", requester.github_login), + format!("{} wants to add you as a contact", requester.username), )) } Notification::ContactRequestAccepted { responder_id } => { let responder = user_store.get_cached_user(*responder_id)?; Some(( Some(responder.clone()), - format!("{} accepted your contact request", responder.github_login), + format!("{} accepted your contact request", responder.username), )) } Notification::ChannelInvitation { @@ -3552,7 +3716,7 @@ impl CollabPanel { Some(inviter.clone()), format!( "{} invited you to join the #{channel_name} channel", - inviter.github_login + inviter.username ), )) } @@ -3678,7 +3842,6 @@ fn render_tree_branch( cx: &mut App, ) -> impl IntoElement { let rem_size = window.rem_size(); - let line_height = window.text_style().line_height_in_pixels(rem_size); let thickness = px(1.); let color = cx.theme().colors().icon_disabled; @@ -3711,14 +3874,14 @@ fn render_tree_branch( }, ) .w(rem_size) - .h(line_height - px(2.)) + .h(panel_row_height()) } fn render_participant_name_and_handle(user: &User) -> impl IntoElement { Label::new(if let Some(ref display_name) = user.name { - format!("{display_name} ({})", user.github_login) + format!("{display_name} ({})", user.username) } else { - user.github_login.to_string() + user.username.to_string() }) } @@ -3726,11 +3889,9 @@ impl Render for CollabPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let status = *self.client.status().borrow(); - let is_collaboration_disabled = self - .user_store - .read(cx) - .current_organization_configuration() - .is_some_and(|config| !config.is_collaboration_enabled); + let is_collaboration_disabled = self.is_collaboration_disabled_by_organization(cx); + let is_signed_in_view_visible = + Self::is_signed_in_view_visible(status, is_collaboration_disabled); v_flex() .key_context(self.dispatch_context(window, cx)) @@ -3753,10 +3914,10 @@ impl Render for CollabPanel { .size_full() .child(if is_collaboration_disabled { self.render_disabled_by_organization(cx) - } else if !status.is_or_was_connected() || status.is_signing_in() { - self.render_signed_out(cx) - } else { + } else if is_signed_in_view_visible { self.render_signed_in(window, cx) + } else { + self.render_signed_out(cx) }) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( @@ -3773,6 +3934,17 @@ impl Render for CollabPanel { impl EventEmitter for CollabPanel {} impl Panel for CollabPanel { + fn activation_focus_handle(&self, cx: &App) -> FocusHandle { + let status = *self.client.status().borrow(); + let is_collaboration_disabled = self.is_collaboration_disabled_by_organization(cx); + + if Self::is_signed_in_view_visible(status, is_collaboration_disabled) { + self.filter_editor.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } + fn position(&self, _window: &Window, cx: &App) -> DockPosition { CollaborationPanelSettings::get_global(cx).dock } @@ -3845,8 +4017,8 @@ impl Panel for CollabPanel { } impl Focusable for CollabPanel { - fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - self.filter_editor.focus_handle(cx) + fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { + self.focus_handle.clone() } } @@ -3964,9 +4136,9 @@ impl Render for DraggedChannelView { .child( Icon::new( if self.channel.visibility == proto::ChannelVisibility::Public { - IconName::Public - } else { IconName::Hash + } else { + IconName::Lock }, ) .size(IconSize::Small) @@ -4149,7 +4321,7 @@ impl CollabPanel { string_entries.push(format!(" (invite) #{}{selected_marker}", channel.name)); } ListEntry::CallParticipant { user, .. } => { - string_entries.push(format!(" {}{selected_marker}", user.github_login)); + string_entries.push(format!(" {}{selected_marker}", user.username)); } ListEntry::ParticipantProject { worktree_root_names, @@ -4164,20 +4336,13 @@ impl CollabPanel { string_entries.push(format!(" (screen){selected_marker}")); } ListEntry::IncomingRequest(user) => { - string_entries.push(format!( - " (incoming) {}{selected_marker}", - user.github_login - )); + string_entries.push(format!(" (incoming) {}{selected_marker}", user.username)); } ListEntry::OutgoingRequest(user) => { - string_entries.push(format!( - " (outgoing) {}{selected_marker}", - user.github_login - )); + string_entries.push(format!(" (outgoing) {}{selected_marker}", user.username)); } ListEntry::Contact { contact, .. } => { - string_entries - .push(format!(" {}{selected_marker}", contact.user.github_login)); + string_entries.push(format!(" {}{selected_marker}", contact.user.username)); } ListEntry::ContactPlaceholder => {} } diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index f9077a9ab359d0..6452d55d1ecebf 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -65,7 +65,7 @@ impl ChannelModal { window, cx, ) - .modal(false) + .embedded() }); Self { @@ -146,7 +146,6 @@ impl Render for ChannelModal { .on_action(cx.listener(Self::toggle_mode)) .on_action(cx.listener(Self::dismiss)) .elevation_3(cx) - .w(rems(34.)) .child( v_flex() .px_2() @@ -259,6 +258,10 @@ pub struct ChannelModalDelegate { impl PickerDelegate for ChannelModalDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "channel modal" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search collaborator by username...".into() } @@ -293,10 +296,11 @@ impl PickerDelegate for ChannelModalDelegate { Mode::ManageMembers => { if self.has_all_members { self.match_candidates.clear(); - self.match_candidates - .extend(self.members.iter().enumerate().map(|(id, member)| { - StringMatchCandidate::new(id, &member.user.github_login) - })); + self.match_candidates.extend( + self.members.iter().enumerate().map(|(id, member)| { + StringMatchCandidate::new(id, &member.user.username) + }), + ); let matches = cx.foreground_executor().block_on(match_strings( &self.match_candidates, @@ -419,7 +423,7 @@ impl PickerDelegate for ChannelModalDelegate { .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .start_slot(Avatar::new(user.avatar_uri.clone())) - .child(Label::new(user.github_login.clone())) + .child(Label::new(user.username.clone())) .end_slot(h_flex().gap_2().map(|slot| { match self.mode { Mode::ManageMembers => slot diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index e665e660a96163..22c09b285b91d3 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -21,7 +21,7 @@ impl ContactFinder { potential_contacts: Arc::from([]), selected_index: 0, }; - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); Self { picker } } @@ -48,7 +48,6 @@ impl Render for ContactFinder { .child(h_flex().child(Label::new("Invite new contacts"))), ) .child(self.picker.clone()) - .w(rems(34.)) } } @@ -71,6 +70,10 @@ impl Focusable for ContactFinder { impl PickerDelegate for ContactFinderDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "contact finder" + } + fn match_count(&self) -> usize { self.potential_contacts.len() } @@ -164,7 +167,7 @@ impl PickerDelegate for ContactFinderDelegate { .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .start_slot(Avatar::new(user.avatar_uri.clone())) - .child(Label::new(user.github_login.clone())) + .child(Label::new(user.username.clone())) .end_slot::(icon_path.map(Icon::from_path)), ) } diff --git a/crates/collab_ui/src/notifications/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs index 089bcd5f73af3f..34e0b7716f4c83 100644 --- a/crates/collab_ui/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui/src/notifications/incoming_call_notification.rs @@ -127,7 +127,7 @@ impl Render for IncomingCallNotification { ) .child(Label::new(format!( "{} is sharing a project in Zed", - self.state.call.calling_user.github_login + self.state.call.calling_user.username ))), ) } diff --git a/crates/collab_ui/src/notifications/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs index 99a920b7fe80d0..00e5080f832857 100644 --- a/crates/collab_ui/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui/src/notifications/project_shared_notification.rs @@ -126,7 +126,7 @@ impl Render for ProjectSharedNotification { let punctuation = if no_worktree_root_names { "" } else { ":" }; let main_label = format!( "{} is sharing a project with you{}", - self.owner.github_login.clone(), + self.owner.username.clone(), punctuation ); diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 569d8407b6f7b4..42236ea0a983c8 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -83,6 +83,15 @@ impl CommandPalette { window: &mut Window, cx: &mut Context, ) { + if workspace.active_modal::(cx).is_some() { + workspace.hide_modal(window, cx); + return; + } + + if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) { + return; + } + let Some(previous_focus_handle) = window.focused(cx) else { return; }; @@ -125,7 +134,10 @@ impl CommandPalette { ); let picker = cx.new(|cx| { - let picker = Picker::uniform_list(delegate, window, cx); + // One-shot action; there's nothing to reopen. + let picker = Picker::uniform_list(delegate, window, cx) + .reopenable(false, cx) + .show_scrollbar(true); picker.set_query(query, window, cx); picker }); @@ -150,7 +162,6 @@ impl Render for CommandPalette { fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .key_context("CommandPalette") - .w(rems(34.)) .child(self.picker.clone()) } } @@ -376,6 +387,10 @@ impl CommandPaletteDelegate { impl PickerDelegate for CommandPaletteDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "command palette" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Execute a command...".into() } @@ -1025,6 +1040,28 @@ mod tests { }); } + #[gpui::test] + async fn test_reopen_command_palette_over_another_modal(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + cx.simulate_keystrokes("cmd-n"); + + for _ in 0..2 { + cx.simulate_keystrokes("cmd-shift-p"); + cx.simulate_input("go to line: Toggle"); + cx.simulate_keystrokes("enter"); + + workspace.update(cx, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_some()); + }); + } + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let app_state = AppState::test(cx); diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index b6f786f2d8ceeb..9783be6354ea09 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, channel::oneshot, future, select}; use futures_lite::future::yield_now; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, Task}; use parking_lot::Mutex; -use postage::barrier; +use postage::{barrier, prelude::Stream as _}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, value::RawValue}; use slotmap::SlotMap; @@ -21,6 +21,7 @@ use std::{ use util::{ResultExt, TryFutureExt}; use crate::{ + oauth::WwwAuthenticate, transport::{StdioTransport, Transport}, types::{CancelledParams, ClientNotification, Notification as _, notifications::Cancelled}, }; @@ -65,19 +66,19 @@ pub(crate) struct Client { #[allow(clippy::type_complexity)] #[allow(dead_code)] io_tasks: Mutex>, Task>)>>, - #[allow(dead_code)] output_done_rx: Mutex>, executor: BackgroundExecutor, - #[allow(dead_code)] transport: Arc, request_timeout: Option, /// Single-slot side channel for the last transport-level error. When the /// output task encounters a send failure it stashes the error here and - /// exits; the next request to observe cancellation `.take()`s it so it can - /// propagate a typed error (e.g. `TransportError::AuthRequired`) instead - /// of a generic "cancelled". This works because `initialize` is the sole - /// in-flight request at startup, but would need rethinking if concurrent - /// requests are ever issued during that phase. + /// exits; the next request to observe cancellation `.take()`s it so it + /// can fail with the underlying cause (e.g. "connection refused") instead + /// of a generic "cancelled". This is best-effort diagnostics: with + /// concurrent requests in flight, a single arbitrary one receives the + /// stashed error. Nothing may depend on it for correctness — + /// authentication challenges are observed via [`Self::wait_for_shutdown`] + /// and [`Transport::auth_challenge`], which do not involve requests. last_transport_error: Arc>>, } @@ -357,6 +358,28 @@ impl Client { Ok(()) } + /// A future that resolves once the transport's output loop has terminated + /// — after a send failure, or when this client is dropped — yielding the + /// authentication challenge recorded by the transport if it shut down on a + /// `401 Unauthorized` response. + /// + /// Unlike `last_transport_error`, this does not require a request to be in + /// flight when the transport fails. Returns `None` if the shutdown signal + /// was already claimed: there is a single signal per client. + pub(crate) fn wait_for_shutdown( + &self, + ) -> Option>> { + let mut output_done = self.output_done_rx.lock().take()?; + let transport = self.transport.clone(); + Some( + async move { + output_done.recv().await; + transport.auth_challenge() + } + .boxed(), + ) + } + /// Sends a JSON-RPC request to the context server and waits for a response. /// This function handles serialization, deserialization, timeout, and error handling. pub async fn request( diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 05a3451ea863a3..865f779b160599 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -21,6 +21,7 @@ use parking_lot::RwLock; pub use settings::ContextServerCommand; use url::Url; +use crate::oauth::WwwAuthenticate; use crate::transport::HttpTransport; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -106,6 +107,16 @@ impl ContextServer { self.client.read().clone() } + /// The authentication challenge from the last `401 Unauthorized` response + /// this server's transport gave up on, if any. See + /// [`crate::transport::Transport::auth_challenge`]. + pub fn auth_challenge(&self) -> Option { + match &self.configuration { + ContextServerTransport::Stdio(..) => None, + ContextServerTransport::Custom(transport) => transport.auth_challenge(), + } + } + pub async fn start(&self, cx: &AsyncApp) -> Result<()> { self.initialize(self.new_client(cx)?).await } diff --git a/crates/context_server/src/protocol.rs b/crates/context_server/src/protocol.rs index 05082637c276ff..6eb4a84a46cc90 100644 --- a/crates/context_server/src/protocol.rs +++ b/crates/context_server/src/protocol.rs @@ -8,11 +8,12 @@ use std::time::Duration; use anyhow::Result; -use futures::channel::oneshot; +use futures::{channel::oneshot, future::BoxFuture}; use gpui::AsyncApp; use serde_json::Value; use crate::client::{Client, NotificationSubscription}; +use crate::oauth::WwwAuthenticate; use crate::types::{self, Notification, Request}; pub struct ModelContextProtocol { @@ -122,6 +123,20 @@ impl InitializedContextServerProtocol { self.inner.notify(T::METHOD, params) } + /// A future that resolves once the underlying transport's output loop has + /// terminated — after a send failure, or when the client is dropped — + /// yielding the authentication challenge recorded by the transport if it + /// shut down on a `401 Unauthorized` response. + /// + /// Servers may accept `initialize` unauthenticated and only challenge a + /// later request or notification. Awaiting this is what lets the owner of + /// the connection notice such a challenge even when no request was in + /// flight to carry a typed error back. Returns `None` if the shutdown + /// signal was already claimed: there is a single signal per client. + pub fn wait_for_shutdown(&self) -> Option>> { + self.inner.wait_for_shutdown() + } + pub fn on_notification( &self, method: &'static str, diff --git a/crates/context_server/src/transport.rs b/crates/context_server/src/transport.rs index bffd7e4c4d84a8..edb8a5e8da026d 100644 --- a/crates/context_server/src/transport.rs +++ b/crates/context_server/src/transport.rs @@ -6,6 +6,8 @@ use async_trait::async_trait; use futures::Stream; use std::pin::Pin; +use crate::oauth::WwwAuthenticate; + pub use http::*; pub use stdio_transport::*; @@ -19,4 +21,16 @@ pub trait Transport: Send + Sync { /// need the negotiated version (currently only HTTP, which must attach an /// `MCP-Protocol-Version` header from 2025-06-18 onward) can pick it up. fn set_protocol_version(&self, _version: &str) {} + + /// The authentication challenge from the last `401 Unauthorized` response + /// this transport gave up on, if any (currently only set by the HTTP + /// transport). + /// + /// The challenge is recorded right before the failed send tears down the + /// client's output loop. Observers of the client's shutdown read it from + /// here, so a 401 can initiate the OAuth flow even when it arrived on a + /// notification, with no request in flight to carry a typed error. + fn auth_challenge(&self) -> Option { + None + } } diff --git a/crates/context_server/src/transport/http.rs b/crates/context_server/src/transport/http.rs index bf374586b35fb5..6e2ef73475b851 100644 --- a/crates/context_server/src/transport/http.rs +++ b/crates/context_server/src/transport/http.rs @@ -58,6 +58,10 @@ pub struct HttpTransport { /// When set, the transport attaches `Authorization: Bearer` headers and /// handles 401 responses with token refresh + retry. token_provider: Option>, + /// The challenge from the last 401 this transport gave up on; cleared at + /// the start of each send so it always describes the most recent attempt. + /// See [`Transport::auth_challenge`]. + auth_challenge: SyncMutex>, } impl HttpTransport { @@ -92,6 +96,7 @@ impl HttpTransport { error_rx, headers, token_provider, + auth_challenge: SyncMutex::new(None), } } @@ -133,8 +138,21 @@ impl HttpTransport { Ok(request_builder.body(AsyncBody::from(message.to_vec()))?) } + /// Record the challenge so it remains observable after the failed send + /// tears down the client (see [`Transport::auth_challenge`]), and build + /// the typed error for the send itself. + fn auth_required(&self, www_authenticate: WwwAuthenticate) -> anyhow::Error { + *self.auth_challenge.lock() = Some(www_authenticate.clone()); + TransportError::AuthRequired { www_authenticate }.into() + } + /// Send a message and handle the response based on content type. async fn send_message(&self, message: String) -> Result<()> { + // The same server instance can be restarted over this transport; a + // challenge recorded by a previous client generation must not be + // observed by the current one. + *self.auth_challenge.lock() = None; + let is_notification = !message.contains("\"id\":") || message.contains("notifications/initialized"); @@ -174,13 +192,13 @@ impl HttpTransport { // If still 401 after refresh, give up. if response.status().as_u16() == 401 { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } else { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } else { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } @@ -282,7 +300,7 @@ impl HttpTransport { data_buffer.clear(); } in_message = false; - } else if let Some(data) = line.strip_prefix("data: ") { + } else if let Some(data) = line.strip_prefix("data:") { // Handle data lines let data = data.trim(); if !data.is_empty() { @@ -335,6 +353,10 @@ impl Transport for HttpTransport { fn set_protocol_version(&self, version: &str) { *self.protocol_version.lock() = Some(version.to_string()); } + + fn auth_challenge(&self) -> Option { + self.auth_challenge.lock().clone() + } } impl Drop for HttpTransport { @@ -390,9 +412,13 @@ impl Drop for HttpTransport { mod tests { use super::*; use async_trait::async_trait; + use futures::FutureExt as _; use gpui::TestAppContext; use parking_lot::Mutex as SyncMutex; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::{ + sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + time::Duration, + }; /// A mock token provider that returns a configurable token and tracks /// refresh attempts. @@ -470,6 +496,52 @@ mod tests { .unwrap()) } + #[gpui::test] + async fn test_sse_data_field(cx: &mut TestAppContext) { + for data_prefix in ["data:", "data: "] { + let body = format!( + "id:1\nevent:message\n{data_prefix}{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{{}}}}\n\n" + ); + let client = make_fake_http_client(move |_req| { + let body = body.clone(); + Box::pin(async move { + Ok(Response::builder() + .status(200) + .header("Content-Type", "text/event-stream;charset=UTF-8") + .body(AsyncBody::from(body.into_bytes())) + .unwrap()) + }) + }); + + let transport = HttpTransport::new( + client, + "http://mcp.example.com/mcp".to_string(), + HashMap::default(), + cx.background_executor.clone(), + ); + + transport + .send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#.to_string()) + .await + .expect("send should succeed"); + + let mut responses = transport.receive(); + let next_response = responses.next().fuse(); + let timeout = cx.background_executor.timer(Duration::from_secs(1)).fuse(); + futures::pin_mut!(next_response, timeout); + + let response = futures::select_biased! { + response = next_response => response.expect("expected SSE response"), + _ = timeout => panic!("timed out waiting for SSE response with {data_prefix:?}"), + }; + + assert_eq!( + response, r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, + "unexpected SSE response for {data_prefix:?}", + ); + } + } + #[gpui::test] async fn test_bearer_token_attached_to_requests(cx: &mut TestAppContext) { // Capture the Authorization header from the request. diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 0d9d9ed1e61ab1..3b73bacb2e0037 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -48,7 +48,7 @@ util.workspace = true workspace.workspace = true [target.'cfg(windows)'.dependencies] -async-std = { version = "1.12.0", features = ["unstable"] } +async-std.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index d295d94198f5b7..85446c47928000 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -283,7 +283,12 @@ mod tests { .await; let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); cx.set_state(indoc! {" @@ -491,7 +496,12 @@ mod tests { .await; let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); // Setup the editor with a completion request. @@ -623,7 +633,12 @@ mod tests { .await; let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); cx.set_state(indoc! {" @@ -718,7 +733,12 @@ mod tests { }); let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor.update_in(cx, |editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); handle_copilot_completion_request( @@ -848,7 +868,12 @@ mod tests { .await; let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }); cx.set_state(indoc! {" @@ -1014,7 +1039,12 @@ mod tests { let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor .update(cx, |editor, window, cx| { - editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) + editor.set_edit_prediction_provider( + Some(copilot_provider), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ) }) .unwrap(); diff --git a/crates/copilot_ui/Cargo.toml b/crates/copilot_ui/Cargo.toml index 14b9fe436791eb..ba5935c65edf62 100644 --- a/crates/copilot_ui/Cargo.toml +++ b/crates/copilot_ui/Cargo.toml @@ -28,6 +28,7 @@ log.workspace = true lsp.workspace = true menu.workspace = true project.workspace = true +release_channel.workspace = true serde_json.workspace = true settings.workspace = true ui.workspace = true diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 6d1f67607fc48f..5741d61348538f 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -9,6 +9,7 @@ use gpui::{ Subscription, TaskExt, Window, WindowBounds, WindowOptions, div, point, }; use project::project_settings::ProjectSettings; +use release_channel::ReleaseChannel; use settings::Settings as _; use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; use util::ResultExt as _; @@ -55,22 +56,25 @@ pub fn reinstall_and_sign_in(copilot: Entity, window: &mut 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 width = px(450.); + let height = px(350.); let window_bounds = WindowBounds::Windowed(gpui::bounds( - current_window_center - point(height / 2.0, width / 2.0), - gpui::size(height, width), + current_window_center - point(width / 2.0, height / 2.0), + gpui::size(width, height), )); + let app_id = ReleaseChannel::global(cx).app_id(); cx.open_window( WindowOptions { - kind: gpui::WindowKind::PopUp, + kind: gpui::WindowKind::Floating, window_bounds: Some(window_bounds), is_resizable: false, is_movable: true, titlebar: Some(gpui::TitlebarOptions { + title: Some("Use GitHub Copilot in Zed".into()), appears_transparent: true, ..Default::default() }), + app_id: Some(app_id.to_owned()), ..Default::default() }, |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)), @@ -466,6 +470,10 @@ pub struct ConfigurationView { copilot_status: Option, is_authenticated: Box bool + 'static>, edit_prediction: bool, + /// When `true`, renders a compact control suitable for an inline settings + /// row: no explanatory labels (those live in the row's left column) and + /// content-sized buttons instead of full-width ones. + compact: bool, _subscription: Option, } @@ -487,6 +495,7 @@ impl ConfigurationView { copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()), is_authenticated: Box::new(is_authenticated), edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), + compact: false, _subscription: copilot.as_ref().map(|copilot| { cx.observe(&copilot.0, |this, model, cx| { this.copilot_status = Some(model.read(cx).status()); @@ -495,6 +504,14 @@ impl ConfigurationView { }), } } + + /// Renders the view compactly for an inline settings row (no labels, + /// content-sized buttons). The explanatory copy is expected to be shown + /// elsewhere (e.g. the row's left column). + pub fn compact(mut self) -> Self { + self.compact = true; + self + } } impl ConfigurationView { @@ -536,34 +553,34 @@ impl ConfigurationView { edit_prediction: bool, ) -> impl IntoElement { Button::new("loading_button", label) - .full_width() + .map(|this| { + if edit_prediction || self.compact { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) .disabled(true) .loading(true) .style(ButtonStyle::Outlined) - .when(edit_prediction, |this| this.size(ButtonSize::Medium)) } 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" + "Sign In" }; Button::new("sign_in", label) .map(|this| { - if edit_prediction { + if edit_prediction || self.compact { this.size(ButtonSize::Medium) } else { this.full_width() } }) .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Github) - .size(IconSize::Small) - .color(Color::Muted), - ) .when(edit_prediction, |this| this.tab_index(0isize)) .on_click(|_, window, cx| { let app_state = AppState::global(cx); @@ -582,7 +599,7 @@ impl ConfigurationView { Button::new("reinstall_and_sign_in", label) .map(|this| { - if edit_prediction { + if edit_prediction || self.compact { this.size(ButtonSize::Medium) } else { this.full_width() @@ -656,31 +673,32 @@ impl ConfigurationView { 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() + let (label, button) = if let Some(msg) = self.loading_message() { + ( + start_label, + 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() + ( + ERROR_LABEL, + 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() + ( + no_status_label, + 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() - } + ( + start_label, + self.render_sign_in_button(false).into_any_element(), + ) + }; + + v_flex() + .gap_2() + .when(!self.compact, |this| this.child(Label::new(label))) + .child(button) } } @@ -689,13 +707,15 @@ impl Render for ConfigurationView { let is_authenticated = &self.is_authenticated; if is_authenticated(cx) { - return ConfiguredApiCard::new("Authorized") + let sign_out = |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + if let Some(auth) = GlobalCopilotAuth::try_global(cx) { + initiate_sign_out(auth.0.clone(), window, cx); + } + }; + + return ConfiguredApiCard::new("copilot-authorized", "Authorized") .button_label("Sign Out") - .on_click(|_, window, cx| { - if let Some(auth) = GlobalCopilotAuth::try_global(cx) { - initiate_sign_out(auth.0.clone(), window, cx); - } - }) + .on_click(sign_out) .into_any_element(); } diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index f8b898112c1881..1b118097cb4708 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -16,6 +16,9 @@ serde_json.workspace = true system_specs.workspace = true zstd.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true + [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 28217496cadb78..5d3fc954d308d7 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -138,6 +138,17 @@ where info!("crash signal handlers installed"); send_crash_server_message(&client, CrashServerMessage::Init(crash_init)); + #[cfg(all(target_os = "linux", target_env = "gnu"))] + if let Some(address) = abort_message_address() { + send_crash_server_message( + &client, + CrashServerMessage::AbortMessageLocation(AbortMessageLocation { + pid: process::id(), + address, + }), + ); + } + #[cfg(target_os = "linux")] handler.set_ptracer(Some(_crash_handler.id())); @@ -170,6 +181,7 @@ pub struct CrashServer { panic_info: Mutex>, active_gpu: Mutex>, user_info: Mutex>, + abort_message_location: Mutex>, has_connection: Arc, logs_dir: PathBuf, } @@ -179,11 +191,27 @@ pub struct CrashInfo { pub init: InitCrashHandler, pub panic: Option, pub minidump_error: Option, + /// The diagnostic the C runtime recorded before aborting the process, e.g. + /// glibc's "free(): invalid pointer". Only present when the crash was a + /// runtime-initiated abort rather than a signal like SIGSEGV or a panic. + #[serde(default)] + pub abort_message: Option, pub gpus: Vec, pub active_gpu: Option, pub user_info: Option, } +/// Where to find the C runtime's abort diagnostic in the crashed process's +/// memory. Sent by the client at startup so that after a crash the server can +/// recover the message with `process_vm_readv`; the crashed process itself +/// can't safely do this work, since its heap may be corrupt and its allocator +/// locks may be held by the crashed thread. +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +pub struct AbortMessageLocation { + pub pid: u32, + pub address: u64, +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct InitCrashHandler { pub session_id: String, @@ -233,6 +261,110 @@ enum CrashServerMessage { Panic(CrashPanic), GPUInfo(GpuSpecs), UserInfo(UserInfo), + AbortMessageLocation(AbortMessageLocation), +} + +/// glibc records the diagnostic it prints just before aborting (malloc integrity +/// failures like "free(): invalid pointer", assertion failures, stack-smashing +/// reports) in the private global `__abort_msg`, specifically so it can be +/// recovered post-mortem. Resolve its address here, in a safe context at startup. +/// The symbol is only exported at the GLIBC_PRIVATE version, which plain `dlsym` +/// won't resolve, and it has no stability guarantee, so a null result (e.g. musl, +/// or a future glibc removing it) just disables this diagnostic. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn abort_message_address() -> Option { + let ptr = unsafe { + libc::dlvsym( + libc::RTLD_DEFAULT, + c"__abort_msg".as_ptr(), + c"GLIBC_PRIVATE".as_ptr(), + ) + }; + std::ptr::NonNull::new(ptr).map(|ptr| ptr.as_ptr() as u64) +} + +/// Read the crashed process's abort diagnostic. `__abort_msg` points to a +/// `struct abort_msg_s { unsigned int size; char msg[]; }` that glibc allocates +/// with mmap so that it stays intact even when the heap is corrupt. `size` is +/// the total byte size of that mapping (header included, rounded up to whole +/// pages), not the message length; the message itself is NUL-terminated. +#[cfg(target_os = "linux")] +fn read_abort_message(location: AbortMessageLocation) -> Option { + let pointer_bytes = read_process_memory(location.pid, location.address, size_of::())?; + let message_address = usize::from_ne_bytes(pointer_bytes.try_into().ok()?) as u64; + if message_address == 0 { + return None; + } + let size_bytes = read_process_memory(location.pid, message_address, size_of::())?; + let size = u32::from_ne_bytes(size_bytes.try_into().ok()?); + let message_bytes = read_process_memory( + location.pid, + message_address + size_of::() as u64, + abort_message_read_len(size)?, + )?; + parse_abort_message(&message_bytes) +} + +/// How many message bytes to read given the `size` field of glibc's +/// `abort_msg_s`. `size` holds the total size of the mmap'd allocation, so a +/// value that isn't a whole number of pages means the layout has changed and +/// we shouldn't trust it. Reading is capped at (one page minus the header), +/// which both bounds the work and ensures the read never extends past the end +/// of the mapping. +#[cfg(any(target_os = "linux", test))] +fn abort_message_read_len(size: u32) -> Option { + // Every Linux page size (4 KiB, 16 KiB, 64 KiB, ...) is a multiple of 4 KiB. + const PAGE_MULTIPLE: usize = 4096; + const MAX_READ: usize = 4096; + + let size = size as usize; + if size == 0 || !size.is_multiple_of(PAGE_MULTIPLE) { + log::warn!("__abort_msg size field {size} is not page-rounded; layout may have changed"); + return None; + } + Some(size.min(MAX_READ) - size_of::()) +} + +/// The message is NUL-terminated inside a zero-filled mapping, so truncate at +/// the first NUL; `trim` alone would keep the padding, since NUL is not +/// whitespace. +#[cfg(any(target_os = "linux", test))] +fn parse_abort_message(bytes: &[u8]) -> Option { + let len = bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(bytes.len()); + let message = String::from_utf8_lossy(&bytes[..len]).trim().to_string(); + (!message.is_empty()).then_some(message) +} + +#[cfg(target_os = "linux")] +fn read_process_memory(pid: u32, address: u64, len: usize) -> Option> { + let mut buffer = vec![0u8; len]; + let local = libc::iovec { + iov_base: buffer.as_mut_ptr().cast(), + iov_len: len, + }; + let remote = libc::iovec { + iov_base: address as *mut libc::c_void, + iov_len: len, + }; + let bytes_read = + unsafe { libc::process_vm_readv(pid as libc::pid_t, &local, 1, &remote, 1, 0) }; + if bytes_read < 0 { + log::warn!( + "process_vm_readv of {len} bytes at {address:#x} in pid {pid} failed: {}", + io::Error::last_os_error() + ); + return None; + } + if bytes_read as usize != len { + log::warn!( + "process_vm_readv short read at {address:#x} in pid {pid}: {bytes_read} of {len} bytes" + ); + return None; + } + Some(buffer) } impl minidumper::ServerHandler for CrashServer { @@ -281,6 +413,14 @@ impl minidumper::ServerHandler for CrashServer { } }; + // The crashed process is still alive at this point: it stays parked in + // its signal handler until the server acknowledges the dump request, + // which happens after this callback returns. + #[cfg(target_os = "linux")] + let abort_message = (*self.abort_message_location.lock()).and_then(read_abort_message); + #[cfg(not(target_os = "linux"))] + let abort_message = None; + let crash_info = CrashInfo { init: self .initialization_params @@ -289,6 +429,7 @@ impl minidumper::ServerHandler for CrashServer { .expect("not initialized"), panic: self.panic_info.lock().clone(), minidump_error, + abort_message, active_gpu: self.active_gpu.lock().clone(), gpus, user_info: self.user_info.lock().clone(), @@ -320,6 +461,9 @@ impl minidumper::ServerHandler for CrashServer { CrashServerMessage::UserInfo(user_info) => { self.user_info.lock().replace(user_info); } + CrashServerMessage::AbortMessageLocation(location) => { + self.abort_message_location.lock().replace(location); + } } } @@ -523,6 +667,7 @@ pub fn crash_server(socket: &Path, logs_dir: PathBuf) { initialization_params: Mutex::default(), panic_info: Mutex::default(), user_info: Mutex::default(), + abort_message_location: Mutex::default(), has_connection, active_gpu: Mutex::default(), logs_dir, @@ -532,3 +677,99 @@ pub fn crash_server(socket: &Path, logs_dir: PathBuf) { ) .expect("failed to run server"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn abort_message_read_len_requires_page_rounded_total() { + assert_eq!(abort_message_read_len(0), None); + // A message length rather than a mapping total means the glibc layout + // has changed out from under us. + assert_eq!(abort_message_read_len(23), None); + assert_eq!(abort_message_read_len(4097), None); + // The read must stay within the mapping: one page minus the header. + assert_eq!(abort_message_read_len(4096), Some(4092)); + // Larger totals (long messages, larger page sizes) are clamped. + assert_eq!(abort_message_read_len(8192), Some(4092)); + assert_eq!(abort_message_read_len(65536), Some(4092)); + } + + #[test] + fn parse_abort_message_truncates_at_nul() { + let mut buffer = b"free(): invalid pointer\n\0".to_vec(); + buffer.resize(4092, 0); + assert_eq!( + parse_abort_message(&buffer), + Some("free(): invalid pointer".to_string()) + ); + } + + #[test] + fn parse_abort_message_handles_missing_nul() { + assert_eq!( + parse_abort_message(b"double free or corruption (out)"), + Some("double free or corruption (out)".to_string()) + ); + } + + #[test] + fn parse_abort_message_rejects_empty() { + assert_eq!(parse_abort_message(&[]), None); + assert_eq!(parse_abort_message(&[0; 16]), None); + assert_eq!(parse_abort_message(b"\n \0garbage after nul"), None); + } + + /// End-to-end check of `read_abort_message` against a synthetic + /// `abort_msg_s` in this very process (`process_vm_readv` may always read + /// one's own memory). The message page is followed by a `PROT_NONE` guard + /// page so the test fails if the read ever extends past the mapping glibc + /// would have allocated. + #[cfg(target_os = "linux")] + #[test] + fn read_abort_message_reads_glibc_layout_from_a_live_process() { + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize; + unsafe { + let mapping = libc::mmap( + std::ptr::null_mut(), + 2 * page_size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_ANON | libc::MAP_PRIVATE, + -1, + 0, + ); + assert_ne!(mapping, libc::MAP_FAILED); + assert_eq!( + libc::mprotect( + mapping.cast::().add(page_size).cast(), + page_size, + libc::PROT_NONE + ), + 0 + ); + + mapping.cast::().write(page_size as u32); + let message = b"free(): invalid pointer\n\0"; + std::ptr::copy_nonoverlapping( + message.as_ptr(), + mapping.cast::().add(size_of::()), + message.len(), + ); + + // Stands in for the `__abort_msg` global: a pointer variable whose + // address we hand to the reader. + let abort_msg: *mut libc::c_void = mapping; + let location = AbortMessageLocation { + pid: process::id(), + address: (&raw const abort_msg) as u64, + }; + assert_eq!( + read_abort_message(location), + Some("free(): invalid pointer".to_string()) + ); + + libc::munmap(mapping, 2 * page_size); + } + } +} diff --git a/crates/csv_preview/Cargo.toml b/crates/csv_preview/Cargo.toml index ff4c0a61240356..1dbec1cc58d89c 100644 --- a/crates/csv_preview/Cargo.toml +++ b/crates/csv_preview/Cargo.toml @@ -10,8 +10,10 @@ path = "src/csv_preview.rs" [dependencies] anyhow.workspace = true feature_flags.workspace = true +fuzzy.workspace = true gpui.workspace = true editor.workspace = true +picker.workspace = true ui.workspace = true workspace.workspace = true log.workspace = true diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 322777da042c2b..3f8504ceae355f 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -8,12 +8,12 @@ use std::{ time::{Duration, Instant}, }; -use crate::table_data_engine::TableDataEngine; +use crate::table_data_engine::{DisplayToDataMapping, TableDataEngine}; use ui::{ AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState, TableResizeBehavior, prelude::*, }; -use workspace::{Item, SplitDirection, Workspace}; +use workspace::{Item, Pane, Workspace}; use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent}; @@ -41,10 +41,18 @@ pub struct CsvPreviewView { pub(crate) table_interaction_state: Entity, pub(crate) column_widths: ColumnWidths, pub(crate) parsing_task: Option>>, + pub(crate) is_parsing: bool, + /// Background task computing the display-to-data mapping after a filter/sort change. + /// Stored here so that a new change cancels the previous in-flight computation. + pub(crate) filter_sort_task: Option>, pub(crate) settings: CsvPreviewSettings, /// Performance metrics for debugging and monitoring CSV operations. pub(crate) performance_metrics: PerformanceMetrics, pub(crate) list_state: gpui::ListState, + /// Cached row height, refreshed from the actual text line height on every render. + /// Used to size not-yet-rendered rows for the scrollbar without a full `.measure_all()` + /// pass, so it tracks the real row height instead of a hardcoded guess. + pub(crate) row_height: Pixels, /// Time when the last parsing operation ended, used for smart debouncing pub(crate) last_parse_end_time: Option, } @@ -85,62 +93,19 @@ impl CsvPreviewView { workspace.register_action_renderer(|div, _, _, cx| { div.when(cx.has_flag::(), |div| { div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| { - if let Some(editor) = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .filter(|editor| Self::is_csv_file(editor, cx)) - { - let csv_preview = Self::new(&editor, cx); - workspace.active_pane().update(cx, |pane, cx| { - let existing = pane - .items_of_type::() - .find(|view| view.read(cx).active_editor_state.editor == editor); - if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { - pane.activate_item(idx, true, true, window, cx); - } else { - pane.add_item(Box::new(csv_preview), true, true, None, window, cx); - } - }); - cx.notify(); + if let Some(editor) = Self::resolve_active_item_as_csv_editor(workspace, cx) { + let pane = workspace.active_pane().clone(); + Self::open_preview_in_pane(editor, pane, window, cx); } })) .on_action(cx.listener( |workspace, _: &OpenPreviewToTheSide, window, cx| { - if let Some(editor) = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .filter(|editor| Self::is_csv_file(editor, cx)) + if let Some(editor) = Self::resolve_active_item_as_csv_editor(workspace, cx) { - let csv_preview = Self::new(&editor, cx); - let pane = workspace - .find_pane_in_direction(SplitDirection::Right, cx) - .unwrap_or_else(|| { - workspace.split_pane( - workspace.active_pane().clone(), - SplitDirection::Right, - window, - cx, - ) - }); - pane.update(cx, |pane, cx| { - let existing = - pane.items_of_type::().find(|view| { - view.read(cx).active_editor_state.editor == editor - }); - if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { - pane.activate_item(idx, true, true, window, cx); - } else { - pane.add_item( - Box::new(csv_preview), - false, - false, - None, - window, - cx, - ); - } - }); - cx.notify(); + let pane = workspace.active_pane().clone(); + Self::open_preview_to_the_side_of_pane( + workspace, editor, pane, window, cx, + ); } }, )) @@ -148,7 +113,58 @@ impl CsvPreviewView { }); } - fn new(editor: &Entity, cx: &mut Context) -> Entity { + pub fn open_preview_in_pane( + editor: Entity, + pane: Entity, + window: &mut Window, + cx: &mut Context, + ) { + Self::activate_or_add_preview(editor, pane, true, window, cx); + } + + pub fn open_preview_to_the_side_of_pane( + workspace: &mut Workspace, + editor: Entity, + origin_pane: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx); + Self::activate_or_add_preview(editor, target_pane, false, window, cx); + } + + fn activate_or_add_preview( + editor: Entity, + pane: Entity, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + let existing_view_idx = Self::find_existing_preview_item_idx(pane.read(cx), &editor, cx); + if let Some(existing_view_idx) = existing_view_idx { + pane.update(cx, |pane, cx| { + pane.activate_item(existing_view_idx, focus, focus, window, cx); + }); + } else { + let csv_preview = Self::new(&editor, window, cx); + pane.update(cx, |pane, cx| { + pane.add_item(Box::new(csv_preview), focus, focus, None, window, cx); + }); + } + cx.notify(); + } + + fn find_existing_preview_item_idx( + pane: &Pane, + editor: &Entity, + cx: &App, + ) -> Option { + pane.items_of_type::() + .find(|view| &view.read(cx).active_editor_state.editor == editor) + .and_then(|view| pane.index_for_item(&view)) + } + + fn new(editor: &Entity, window: &Window, cx: &mut Context) -> Entity { let contents = TableLikeContent::default(); let table_interaction_state = cx.new(|cx| { TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::< @@ -169,6 +185,7 @@ impl CsvPreviewView { }, ); + let row_height = window.pixel_snap(window.line_height()); let mut view = CsvPreviewView { focus_handle: cx.focus_handle(), active_editor_state: EditorState { @@ -178,9 +195,12 @@ impl CsvPreviewView { table_interaction_state, column_widths: ColumnWidths::new(cx, 1), parsing_task: None, + is_parsing: false, + filter_sort_task: None, performance_metrics: PerformanceMetrics::default(), list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) - .measure_all(), + .with_uniform_item_height(row_height), + row_height, settings: CsvPreviewSettings::default(), last_parse_end_time: None, engine: TableDataEngine::default(), @@ -194,22 +214,53 @@ impl CsvPreviewView { pub(crate) fn editor_state(&self) -> &EditorState { &self.active_editor_state } - pub(crate) fn apply_sort(&mut self) { - self.performance_metrics.record("Sort", || { - self.engine.apply_sort(); - }); + pub(crate) fn apply_sort(&mut self, cx: &mut Context) { + self.apply_filter_sort(cx); } - /// Update ordered indices when ordering or content changes - pub(crate) fn apply_filter_sort(&mut self) { - self.performance_metrics.record("Filter&sort", || { - self.engine.calculate_d2d_mapping(); - }); + pub fn clear_filters(&mut self, col: types::AnyColumn, cx: &mut Context) { + self.engine.clear_filters_for_col(col); + self.apply_filter_sort(cx); + } - // Update list state with filtered row count - let visible_rows = self.engine.d2d_mapping().visible_row_count(); - self.list_state = - gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)).measure_all(); + pub fn toggle_filter( + &mut self, + col: types::AnyColumn, + value: Option, + cx: &mut Context, + ) { + if let Err(err) = self.engine.toggle_filter(col, value) { + log::error!("Failed to toggle filter: {err}"); + return; + } + self.apply_filter_sort(cx); + } + + /// Spawns a background task to recompute the display-to-data mapping after a filter or sort + /// change. Storing the task cancels any previous in-flight computation automatically. + pub(crate) fn apply_filter_sort(&mut self, cx: &mut Context) { + let contents = self.engine.contents.clone(); + let filter_stack = self.engine.filter_stack.clone(); + let sorting = self.engine.applied_sorting; + + self.filter_sort_task = Some(cx.spawn(async move |this, cx| { + let mapping = cx + .background_spawn(async move { + DisplayToDataMapping::compute(&contents, &filter_stack, sorting) + }) + .await; + + this.update(cx, |view, cx| { + view.engine.set_d2d_mapping(mapping); + let visible_rows = view.engine.d2d_mapping().visible_row_count(); + // Uses the row height measured on the last render. Cheaper than a full + // `.measure_all()` pass; exact row heights are re-measured on scrolling. + view.list_state + .reset_with_uniform_height(visible_rows, view.row_height); + cx.notify(); + }) + .ok(); + })); } pub fn resolve_active_item_as_csv_editor( @@ -222,7 +273,7 @@ impl CsvPreviewView { Self::is_csv_file(&editor, cx).then_some(editor) } - fn is_csv_file(editor: &Entity, cx: &App) -> bool { + pub fn is_csv_file(editor: &Entity, cx: &App) -> bool { editor .read(cx) .buffer() @@ -301,7 +352,7 @@ impl PerformanceMetrics { .map(|(name, (duration, time))| { let took = duration.as_secs_f32() * 1000.; let ago = time.elapsed().as_secs(); - format!("{name}: {took:.2}ms {ago}s ago") + format!("{name}: {took:.3}ms {ago}s ago") }) .collect::>() .join("\n") diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs index efa3573d7aa53d..116c8912a38684 100644 --- a/crates/csv_preview/src/parser.rs +++ b/crates/csv_preview/src/parser.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::{ CsvPreviewView, types::TableLikeContent, @@ -23,6 +25,7 @@ impl CsvPreviewView { cx: &mut Context, ) { let editor = self.active_editor_state.editor.clone(); + self.is_parsing = true; self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx)); } @@ -80,11 +83,13 @@ impl CsvPreviewView { .insert("Parsing", (parse_duration, Instant::now())); log::debug!("Parsed {} rows", parsed_csv.rows.len()); - view.engine.contents = parsed_csv; + view.engine.contents = Arc::new(parsed_csv); + view.engine.calculate_available_filters(); view.sync_column_widths(cx); view.last_parse_end_time = Some(parse_end_time); - view.apply_filter_sort(); + view.is_parsing = false; + view.apply_filter_sort(cx); cx.notify(); }) }) diff --git a/crates/csv_preview/src/renderer/performance_metrics_overlay.rs b/crates/csv_preview/src/renderer/performance_metrics_overlay.rs index 3d0cf50cf1d34f..d9e7ce6ad9b61b 100644 --- a/crates/csv_preview/src/renderer/performance_metrics_overlay.rs +++ b/crates/csv_preview/src/renderer/performance_metrics_overlay.rs @@ -20,7 +20,7 @@ impl CsvPreviewView { let children = div() .absolute() - .top_24() + .bottom_8() .right_4() .px_3() .py_2() diff --git a/crates/csv_preview/src/renderer/preview_view.rs b/crates/csv_preview/src/renderer/preview_view.rs index 90500d53d06917..f46014294324bc 100644 --- a/crates/csv_preview/src/renderer/preview_view.rs +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -1,22 +1,30 @@ use std::time::Instant; -use ui::{div, prelude::*}; +use ui::{SpinnerLabel, div, prelude::*}; use crate::CsvPreviewView; impl Render for CsvPreviewView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); - + let row_height = window.pixel_snap(window.line_height()); + if row_height != self.row_height { + self.row_height = row_height; + // Font size (rem size, buffer font override, ...) changed since the list was last + // measured: existing rows and unmeasured-item height hints are now the wrong size. + // Unlike `reset_with_uniform_height`, this preserves scroll position and keeps each + // item's prior size as a hint rather than dropping straight to a fresh guess. + self.list_state.remeasure(); + } let render_prep_start = Instant::now(); let table_with_settings = v_flex() .size_full() - .p_4() .bg(theme.colors().editor_background) .track_focus(&self.focus_handle) .child(self.render_settings_panel(window, cx)) .child({ - if self.engine.contents.number_of_cols == 0 { + let is_parsing = self.is_parsing; + if is_parsing || self.engine.contents.number_of_cols == 0 { div() .flex() .items_center() @@ -25,7 +33,15 @@ impl Render for CsvPreviewView { .text_ui(cx) .font_buffer(cx) .text_color(cx.theme().colors().text_muted) - .child("No CSV content to display") + .when(is_parsing, |div| { + div.child( + h_flex() + .gap_2() + .child(SpinnerLabel::new()) + .child("Loading…"), + ) + }) + .when(!is_parsing, |div| div.child("No CSV content to display")) .into_any_element() } else { self.create_table(&self.column_widths.widths, cx) diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs index 3a7e1b3a04664d..9a38a3fedd36ae 100644 --- a/crates/csv_preview/src/renderer/render_table.rs +++ b/crates/csv_preview/src/renderer/render_table.rs @@ -51,7 +51,6 @@ impl CsvPreviewView { Table::new(cols) .interactable(&self.table_interaction_state) - .striped() .width_config(ColumnWidthConfig::Resizable(current_widths.clone())) .header(headers) .disable_base_style() @@ -70,6 +69,7 @@ impl CsvPreviewView { cols, display_row, row_identifier_text_color, + this.row_height, cx, ) .unwrap_or_else(|| panic!("Expected to render a table row")) @@ -84,6 +84,7 @@ impl CsvPreviewView { .rendered_indices .extend(range.clone()); + let row_height = this.row_height; range .filter_map(|display_index| { Self::render_single_table_row( @@ -91,6 +92,7 @@ impl CsvPreviewView { cols, DisplayRow(display_index), row_identifier_text_color, + row_height, cx, ) }) @@ -111,6 +113,7 @@ impl CsvPreviewView { cols: usize, display_row: DisplayRow, row_identifier_text_color: gpui::Hsla, + row_height: Pixels, cx: &Context, ) -> Option> { // Get the actual row index from our sorted indices @@ -129,14 +132,23 @@ impl CsvPreviewView { let display_cell_id = DisplayCellId::new(display_row, col); - let cell = div().size_full().whitespace_nowrap().text_ellipsis().child( - CsvPreviewView::create_selectable_cell( + let cell = div() + .size_full() + .when( + !this.settings.multiline_cells_effectively_enabled(), + |div| { + div.whitespace_nowrap() + .text_ellipsis() + .h(row_height) + .overflow_hidden() + }, + ) + .child(CsvPreviewView::create_selectable_cell( display_cell_id, cell_content, this.settings.vertical_alignment, cx, - ), - ); + )); elements.push( div() @@ -155,7 +167,6 @@ impl CsvPreviewView { }, )) }) - .text_ui(cx) .child(cell) .into_any_element(), ); diff --git a/crates/csv_preview/src/renderer/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs index 06a26e4696e471..b997f645769b95 100644 --- a/crates/csv_preview/src/renderer/row_identifiers.rs +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -34,7 +34,7 @@ impl LineNumber { if start + 1 == end { format!("{start}\n{end}") } else { - format!("{start}\n...\n{end}") + format!("{start}\n-\n{end}") } } RowIdentDisplayMode::Horizontal => { @@ -78,11 +78,6 @@ impl CsvPreviewView { (max_line_number as f32).log10().floor() as usize + 1 }; - // if !self.settings.multiline_cells_enabled { - // // Uses horizontal line numbers layout like `123-456`. Needs twice the size - // digit_count *= 2; - // } - let char_width_px = 9.0; // TODO: get real width of the characters let base_width = (digit_count as f32) * char_width_px; let padding = 20.0; @@ -157,7 +152,7 @@ impl CsvPreviewView { .contents .line_numbers .get(*data_row)? - .display_string(if self.settings.multiline_cells_enabled { + .display_string(if self.settings.multiline_cells_effectively_enabled() { RowIdentDisplayMode::Vertical } else { RowIdentDisplayMode::Horizontal @@ -169,14 +164,14 @@ impl CsvPreviewView { let value = div() .flex() .px_1() - .border_b_1() .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) .h_full() - .text_ui(cx) - // Row identifiers are always centered + .text_color(cx.theme().colors().text_muted) + .justify_center() .items_center() - .justify_end() .font_buffer(cx) + .text_ui(cx) .child(row_identifier) .into_any_element(); Some(value) diff --git a/crates/csv_preview/src/renderer/settings.rs b/crates/csv_preview/src/renderer/settings.rs index cafa2a4c1bd954..fe8f671f92c09f 100644 --- a/crates/csv_preview/src/renderer/settings.rs +++ b/crates/csv_preview/src/renderer/settings.rs @@ -1,9 +1,13 @@ use ui::{ - ActiveTheme as _, AnyElement, ButtonSize, Context, ContextMenu, DropdownMenu, ElementId, - IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex, + ActiveTheme as _, AnyElement, ButtonSize, Checkbox, Context, ContextMenu, DropdownMenu, + ElementId, IntoElement as _, ParentElement as _, Styled as _, ToggleState, Tooltip, Window, + div, h_flex, }; -use crate::{CsvPreviewView, settings::VerticalAlignment}; +use crate::{ + CsvPreviewView, + settings::{FilterSortOrder, VerticalAlignment}, +}; ///// Settings related ///// impl CsvPreviewView { @@ -18,6 +22,11 @@ impl CsvPreviewView { VerticalAlignment::Center => "Center", }; + let current_filter_sort_text = match self.settings.filter_sort_order { + FilterSortOrder::AlphaThenCount => "A-Z, then Count", + FilterSortOrder::CountThenAlpha => "Count, then A-Z", + }; + let view = cx.entity(); let alignment_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { menu.entry("Top", None, { @@ -40,6 +49,27 @@ impl CsvPreviewView { }) }); + let filter_sort_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.entry("A-Z, then Count", None, { + let view = view.clone(); + move |_window, cx| { + view.update(cx, |this, cx| { + this.settings.filter_sort_order = FilterSortOrder::AlphaThenCount; + cx.notify(); + }); + } + }) + .entry("Count, then A-Z", None, { + let view = view.clone(); + move |_window, cx| { + view.update(cx, |this, cx| { + this.settings.filter_sort_order = FilterSortOrder::CountThenAlpha; + cx.notify(); + }); + } + }) + }); + let panel = h_flex() .gap_4() .p_2() @@ -68,8 +98,54 @@ impl CsvPreviewView { "Choose vertical text alignment within cells", )), ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .text_sm() + .text_color(cx.theme().colors().text_muted) + .child("Filter Sort:"), + ) + .child( + DropdownMenu::new( + ElementId::Name("filter-sort-order-dropdown".into()), + current_filter_sort_text, + filter_sort_dropdown_menu, + ) + .trigger_size(ButtonSize::Compact) + .trigger_tooltip(Tooltip::text( + "Choose how filter values are sorted in the filter menu", + )), + ), ); + let multiline_enabled = self.settings.multiline_cells_enabled; + let panel = panel.child({ + let view = view.clone(); + Checkbox::new( + ElementId::Name("multiline-rows-checkbox".into()), + if multiline_enabled { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Display multiline rows") + .tooltip(Tooltip::text( + "When enabled, row height grows to show all content. \ + When disabled, only the first line is visible — hover a cell to see the rest.", + )) + .on_click(move |_state, _window, cx| { + view.update(cx, |this, cx| { + this.settings.multiline_cells_enabled = !this.settings.multiline_cells_enabled; + cx.notify(); + }); + }) + }); + #[cfg(feature = "dev-tools")] let panel = panel.child( h_flex() @@ -120,7 +196,6 @@ fn create_dev_only_popover_menu( view_entity.update(cx, |view, cx| { view.settings.rendering_with = RowRenderMechanism::VariableList; - view.settings.multiline_cells_enabled = true; cx.notify(); }) } @@ -137,7 +212,6 @@ fn create_dev_only_popover_menu( view_entity.update(cx, |view, cx| { view.settings.rendering_with = RowRenderMechanism::UniformList; - view.settings.multiline_cells_enabled = false; cx.notify(); }) } diff --git a/crates/csv_preview/src/renderer/table_cell.rs b/crates/csv_preview/src/renderer/table_cell.rs index 8100731e13adb9..e792bcac68d44d 100644 --- a/crates/csv_preview/src/renderer/table_cell.rs +++ b/crates/csv_preview/src/renderer/table_cell.rs @@ -27,28 +27,19 @@ fn create_table_cell( cx: &Context<'_, CsvPreviewView>, ) -> gpui::Stateful
{ div() - .id(ElementId::Name( - format!( - "csv-display-cell-{}-{}", - *display_cell_id.row, *display_cell_id.col - ) - .into(), + .id(ElementId::NamedInteger( + format!("csv-display-cell-{}", *display_cell_id.row).into(), + *display_cell_id.col as u64, )) .cursor_pointer() .flex() .h_full() .px_1() - .bg(cx.theme().colors().editor_background) - .border_b_1() .border_color(cx.theme().colors().border_variant) .map(|div| match vertical_alignment { VerticalAlignment::Top => div.items_start(), VerticalAlignment::Center => div.items_center(), }) - .map(|div| match vertical_alignment { - VerticalAlignment::Top => div.content_start(), - VerticalAlignment::Center => div.content_center(), - }) .font_buffer(cx) .tooltip(Tooltip::text(cell_content.clone())) .child(div().child(cell_content)) diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs index 05652b49a48ca9..bc96f239026e5b 100644 --- a/crates/csv_preview/src/renderer/table_header.rs +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -1,12 +1,519 @@ -use gpui::ElementId; -use ui::{Tooltip, prelude::*}; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use gpui::{ + BackgroundExecutor, DismissEvent, ElementId, Entity, Focusable, ForegroundExecutor, Task, +}; +use picker::{Picker, PickerDelegate}; +use ui::{ + Color, GradientFade, HighlightedLabel, Icon, IconButton, IconName, IconSize, Label, LabelSize, + ListItem, ListItemSpacing, PopoverMenu, Tooltip, prelude::*, +}; use crate::{ CsvPreviewView, - table_data_engine::sorting_by_column::{AppliedSorting, SortDirection}, + settings::FilterSortOrder, + table_data_engine::{ + filtering_by_column::{FilterEntry, FilterEntryState}, + sorting_by_column::{AppliedSorting, SortDirection}, + }, types::AnyColumn, }; +struct ColumnFilterRow { + entry: FilterEntry, + is_applied: bool, + /// Set when this value is hidden because another column's active filter + /// excludes every row containing it. + hidden_by: Option, +} + +enum ColumnFilterListEntry { + Header(SharedString), + Row { + row_index: usize, + positions: Vec, + }, +} + +struct ColumnFilterDelegate { + col: AnyColumn, + view: Entity, + /// Row order frozen at open time (available entries sorted per the + /// column's `FilterSortOrder`, then entries hidden by other columns' + /// filters). Kept stable so toggling a value doesn't reshuffle the list + /// under the user's cursor; only `is_applied`/`hidden_by`/counts are + /// refreshed in place after a toggle. + rows: Vec, + /// Number of available (non-hidden) rows, shown in the search placeholder. + available_count: usize, + string_candidates: Arc>, + filtered: Vec, + selected_index: usize, + query: String, + foreground: ForegroundExecutor, + background: BackgroundExecutor, + cancel: Option>, +} + +impl ColumnFilterDelegate { + fn new( + col: AnyColumn, + view: Entity, + sort_order: FilterSortOrder, + column_filters: Arc>, + cx: &mut Context>, + ) -> Self { + let mut available: Vec<(FilterEntry, bool)> = Vec::new(); + let mut hidden: Vec<(FilterEntry, AnyColumn)> = Vec::new(); + for (entry, state) in column_filters.iter() { + match state { + FilterEntryState::Available { is_applied } => { + available.push((entry.clone(), *is_applied)) + } + FilterEntryState::Unavailable { blocked_by } => { + hidden.push((entry.clone(), *blocked_by)) + } + } + } + + match sort_order { + FilterSortOrder::AlphaThenCount => available.sort_by(|(a, a_app), (b, b_app)| { + b_app + .cmp(a_app) + .then_with(|| a.content.cmp(&b.content)) + .then_with(|| b.occurred_times().cmp(&a.occurred_times())) + }), + FilterSortOrder::CountThenAlpha => available.sort_by(|(a, a_app), (b, b_app)| { + b_app + .cmp(a_app) + .then_with(|| b.occurred_times().cmp(&a.occurred_times())) + .then_with(|| a.content.cmp(&b.content)) + }), + } + + let available_count = available.len(); + + let rows: Vec = available + .into_iter() + .map(|(entry, is_applied)| ColumnFilterRow { + entry, + is_applied, + hidden_by: None, + }) + .chain( + hidden + .into_iter() + .map(|(entry, blocked_by)| ColumnFilterRow { + entry, + is_applied: false, + hidden_by: Some(blocked_by), + }), + ) + .collect(); + + let string_candidates = Arc::new( + rows.iter() + .enumerate() + .map(|(index, row)| { + StringMatchCandidate::new(index, &Self::display_text(&row.entry)) + }) + .collect::>(), + ); + + let filtered = Self::build_entries( + &rows, + rows.iter() + .enumerate() + .map(|(index, _)| (index, Vec::new())) + .collect(), + ); + + Self { + col, + view, + rows, + available_count, + string_candidates, + filtered, + selected_index: 0, + query: String::new(), + foreground: cx.foreground_executor().clone(), + background: cx.background_executor().clone(), + cancel: None, + } + } + + fn display_text(entry: &FilterEntry) -> String { + match &entry.content { + Some(s) => s.as_ref().to_owned(), + None => "".to_owned(), + } + } + + /// Assembles the flat list shown to the user from matched + /// `(row_index, positions)` pairs (which must be sorted ascending by + /// `row_index`, matching `rows`' available-then-hidden order), inserting a + /// "Hidden by other filters" header before the first hidden row. + fn build_entries( + rows: &[ColumnFilterRow], + matches: Vec<(usize, Vec)>, + ) -> Vec { + let mut entries = Vec::with_capacity(matches.len() + 1); + let mut header_inserted = false; + for (row_index, positions) in matches { + if rows[row_index].hidden_by.is_some() && !header_inserted { + entries.push(ColumnFilterListEntry::Header( + "Hidden by other filters".into(), + )); + header_inserted = true; + } + entries.push(ColumnFilterListEntry::Row { + row_index, + positions, + }); + } + entries + } + + fn first_selectable_index(&self) -> usize { + self.filtered + .iter() + .position(|entry| { + matches!(entry, ColumnFilterListEntry::Row { row_index, .. } + if self.rows[*row_index].hidden_by.is_none()) + }) + .unwrap_or(0) + } + + fn matches_for_query(&self, query: &str) -> Vec<(usize, Vec)> { + if query.is_empty() { + return self + .rows + .iter() + .enumerate() + .map(|(index, _)| (index, Vec::new())) + .collect(); + } + + let cancel_flag = AtomicBool::new(false); + let mut matches: Vec = self.foreground.block_on(match_strings( + self.string_candidates.as_ref(), + query, + false, + true, + usize::MAX, + &cancel_flag, + self.background.clone(), + )); + matches.sort_by_key(|m| m.candidate_id); + matches + .into_iter() + .map(|m| (m.candidate_id, m.positions)) + .collect() + } + + /// Re-fetches this column's filter entries from the engine (e.g. after a + /// toggle changes counts/availability across the cascade) while keeping + /// `rows`' frozen order, then re-applies the current search query. + fn refresh_rows(&mut self, cx: &mut Context>) { + let column_filters = match self.view.read(cx).engine.get_filters_for_column(self.col) { + Ok(filters) => filters, + Err(err) => { + log::error!("Failed to get filters for column: {err}"); + return; + } + }; + + let mut lookup: HashMap, (FilterEntry, FilterEntryState)> = + column_filters + .iter() + .map(|(entry, state)| (entry.content.clone(), (entry.clone(), *state))) + .collect(); + + for row in &mut self.rows { + let Some((entry, state)) = lookup.remove(&row.entry.content) else { + continue; + }; + row.entry = entry; + match state { + FilterEntryState::Available { is_applied } => { + row.is_applied = is_applied; + row.hidden_by = None; + } + FilterEntryState::Unavailable { blocked_by } => { + row.is_applied = false; + row.hidden_by = Some(blocked_by); + } + } + } + + self.filtered = Self::build_entries(&self.rows, self.matches_for_query(&self.query)); + self.selected_index = self + .selected_index + .min(self.filtered.len().saturating_sub(1)); + } +} + +impl PickerDelegate for ColumnFilterDelegate { + type ListItem = AnyElement; + + fn name() -> &'static str { + "csv column filter" + } + + fn match_count(&self) -> usize { + self.filtered.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context>, + ) { + self.selected_index = ix.min(self.filtered.len().saturating_sub(1)); + cx.notify(); + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + match self.filtered.get(ix) { + Some(ColumnFilterListEntry::Row { row_index, .. }) => { + self.rows[*row_index].hidden_by.is_none() + } + _ => false, + } + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + format!("Search {} unique values…", self.available_count).into() + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + self.query = query.clone(); + + if query.is_empty() { + self.filtered = Self::build_entries(&self.rows, self.matches_for_query(&query)); + self.selected_index = self.first_selectable_index(); + cx.notify(); + return Task::ready(()); + } + + if let Some(prev) = &self.cancel { + prev.store(true, Ordering::Relaxed); + } + let cancel = Arc::new(AtomicBool::new(false)); + self.cancel = Some(cancel.clone()); + + let string_candidates = self.string_candidates.clone(); + let background = self.background.clone(); + + cx.spawn_in(window, async move |this, cx| { + let mut matches = match_strings( + string_candidates.as_ref(), + &query, + false, + true, + usize::MAX, + cancel.as_ref(), + background, + ) + .await; + matches.sort_by_key(|m| m.candidate_id); + + this.update_in(cx, |this, _, cx| { + if this.delegate.query != query { + return; + } + let rows = &this.delegate.rows; + let matches = matches + .into_iter() + .map(|m| (m.candidate_id, m.positions)) + .collect(); + this.delegate.filtered = ColumnFilterDelegate::build_entries(rows, matches); + this.delegate.selected_index = this.delegate.first_selectable_index(); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { + let Some(ColumnFilterListEntry::Row { row_index, .. }) = + self.filtered.get(self.selected_index) + else { + return; + }; + let row_index = *row_index; + if self.rows[row_index].hidden_by.is_some() { + return; + } + let col = self.col; + let value = self.rows[row_index].entry.content.clone(); + self.view + .update(cx, |view, cx| view.toggle_filter(col, value, cx)); + self.refresh_rows(cx); + cx.notify(); + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.filtered.get(ix)? { + ColumnFilterListEntry::Header(label) => Some( + div() + .px_2() + .pt_2() + .pb_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new(label.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + ), + ColumnFilterListEntry::Row { + row_index, + positions, + } => { + let row = &self.rows[*row_index]; + let value_text: SharedString = match &row.entry.content { + Some(s) => s.clone(), + None => "".into(), + }; + let count_text = SharedString::from(row.entry.occurred_times().to_string()); + let label = HighlightedLabel::new(value_text.clone(), positions.clone()) + .size(LabelSize::Small) + .single_line() + .truncate(); + + if row.hidden_by.is_some() { + return Some( + ListItem::new(("csv-filter-hidden", ix)) + .disabled(true) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child(label.color(Color::Disabled)) + .end_slot( + Label::new(count_text) + .size(LabelSize::Small) + .color(Color::Disabled), + ) + .into_any_element(), + ); + } + + Some( + ListItem::new(("csv-filter-value", ix)) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .start_slot( + h_flex() + .flex_none() + .when(!row.is_applied, |el| el.invisible()) + .child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Accent), + ), + ) + .child(label) + .end_slot( + Label::new(count_text) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .tooltip(Tooltip::text(value_text)) + .into_any_element(), + ) + } + } + } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let selected_rows: usize = self + .rows + .iter() + .filter(|row| row.hidden_by.is_none() && row.is_applied) + .map(|row| row.entry.occurred_times()) + .sum(); + let total_rows: usize = self + .rows + .iter() + .filter(|row| row.hidden_by.is_none()) + .map(|row| row.entry.occurred_times()) + .sum(); + if selected_rows == 0 { + return None; + } + + let col = self.col; + Some( + h_flex() + .w_full() + .px_2() + .py_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .items_center() + .child( + Label::new(format!("{selected_rows} / {total_rows} rows selected")) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + div() + .id("csv-filter-clear-all") + .cursor_pointer() + .child( + Label::new("Clear all") + .size(LabelSize::Small) + .color(Color::Accent), + ) + .on_click(cx.listener(move |picker, _, _, cx| { + picker + .delegate + .view + .update(cx, |view, cx| view.clear_filters(col, cx)); + picker.delegate.refresh_rows(cx); + cx.notify(); + cx.emit(DismissEvent); + })), + ) + .into_any(), + ) + } +} + impl CsvPreviewView { /// Create header for data, which is orderable with text on the left and sort button on the right pub(crate) fn create_header_element_with_sort_button( @@ -15,14 +522,60 @@ impl CsvPreviewView { cx: &mut Context<'_, CsvPreviewView>, col_idx: AnyColumn, ) -> AnyElement { - // CSV data columns: text + filter/sort buttons + let has_active_filter = self.engine.has_active_filters(col_idx); + let has_active_sort = self + .engine + .applied_sorting + .is_some_and(|o| o.col_idx == col_idx); + let always_show_buttons = has_active_filter || has_active_sort; + let group_name = SharedString::from(format!("csv-col-header-{}", col_idx.get())); + + let colors = cx.theme().colors(); + let base_bg = colors.editor_background; + let grad_width_hovered = px(100.); + let grad_width = if always_show_buttons { + grad_width_hovered + } else { + px(20.) + }; h_flex() - .justify_between() - .items_center() + .group(group_name.clone()) + .relative() + .overflow_hidden() .w_full() + .items_center() .font_buffer(cx) - .child(div().child(header_text)) - .child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx))) + .text_buffer(cx) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .child(header_text), + ) + .child( + GradientFade::new(base_bg, base_bg, base_bg) + .width(grad_width) + .width_hovered(grad_width_hovered) + .right(px(0.)) + .gradient_stop(0.8) + .group_name(group_name.clone()), + ) + .child( + h_flex() + .absolute() + .right_0() + .top_0() + .h_full() + .items_center() + .gap_1() + .when(!always_show_buttons, |this| { + this.visible_on_hover(group_name) + }) + .child(self.create_filter_button(cx, col_idx)) + .child(self.create_sort_button(cx, col_idx)), + ) .into_any_element() } @@ -31,14 +584,14 @@ impl CsvPreviewView { cx: &mut Context<'_, CsvPreviewView>, col_idx: AnyColumn, ) -> Button { - let sort_btn = Button::new( + Button::new( ElementId::NamedInteger("sort-button".into(), col_idx.get() as u64), match self.engine.applied_sorting { Some(ordering) if ordering.col_idx == col_idx => match ordering.direction { SortDirection::Asc => "↓", SortDirection::Desc => "↑", }, - _ => "↕", // Unsorted/available for sorting + _ => "↕", }, ) .size(ButtonSize::Compact) @@ -62,29 +615,78 @@ impl CsvPreviewView { })) .on_click(cx.listener(move |this, _event, _window, cx| { let new_sorting = match this.engine.applied_sorting { - Some(ordering) if ordering.col_idx == col_idx => { - // Same column clicked - cycle through states - match ordering.direction { - SortDirection::Asc => Some(AppliedSorting { - col_idx, - direction: SortDirection::Desc, - }), - SortDirection::Desc => None, // Clear sorting - } - } - _ => { - // Different column or no sorting - start with ascending - Some(AppliedSorting { + Some(ordering) if ordering.col_idx == col_idx => match ordering.direction { + SortDirection::Asc => Some(AppliedSorting { col_idx, - direction: SortDirection::Asc, - }) - } + direction: SortDirection::Desc, + }), + SortDirection::Desc => None, + }, + _ => Some(AppliedSorting { + col_idx, + direction: SortDirection::Asc, + }), }; - this.engine.applied_sorting = new_sorting; - this.apply_sort(); + this.apply_sort(cx); cx.notify(); - })); - sort_btn + })) + } + + fn create_filter_button( + &self, + cx: &mut Context<'_, CsvPreviewView>, + col: AnyColumn, + ) -> PopoverMenu> { + let has_active_filters = self.engine.has_active_filters(col); + let sort_order = self.settings.filter_sort_order; + + PopoverMenu::new(ElementId::NamedInteger( + "filter-menu".into(), + col.get() as u64, + )) + .trigger_with_tooltip( + IconButton::new( + ElementId::NamedInteger("filter-button".into(), col.get() as u64), + IconName::Filter, + ) + .icon_size(IconSize::Small) + .style(if has_active_filters { + ButtonStyle::Filled + } else { + ButtonStyle::Subtle + }) + .toggle_state(has_active_filters), + Tooltip::text(if has_active_filters { + "Column has active filters. Click to manage" + } else { + "No filters applied. Click to add filters" + }), + ) + .menu({ + let view_entity = cx.entity(); + move |window, cx| { + let view = view_entity.read(cx); + let column_filters = match view.engine.get_filters_for_column(col) { + Ok(filters) => filters, + Err(err) => { + log::error!("Failed to get filters for column: {err}"); + return None; + } + }; + let view_entity = view_entity.clone(); + + let picker = cx.new(|cx| { + let delegate = + ColumnFilterDelegate::new(col, view_entity, sort_order, column_filters, cx); + Picker::list(delegate, window, cx) + .popover() + .initial_width(rems(18.75)) + .show_scrollbar(true) + }); + picker.focus_handle(cx).focus(window, cx); + Some(picker) + } + }) } } diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs index 215d681c28fd7f..dc871fa4b38789 100644 --- a/crates/csv_preview/src/settings.rs +++ b/crates/csv_preview/src/settings.rs @@ -1,10 +1,10 @@ #[derive(Default, Clone, Copy, PartialEq)] pub enum RowRenderMechanism { /// More correct for multiline content, but slower. - #[allow(dead_code)] // Will be used when settings ui is added + #[default] VariableList, /// Default behaviour for now while resizable columns are being stabilized. - #[default] + #[allow(dead_code)] // Will be used when settings ui is added UniformList, } @@ -26,13 +26,32 @@ pub enum RowIdentifiers { RowNum, } +#[derive(Default, Clone, Copy, PartialEq)] +pub enum FilterSortOrder { + /// Sort alphabetically (A→Z), then by number of occurrences descending within ties + #[default] + AlphaThenCount, + /// Sort by number of occurrences descending, then alphabetically within ties + CountThenAlpha, +} + #[derive(Clone, Default)] pub(crate) struct CsvPreviewSettings { pub(crate) rendering_with: RowRenderMechanism, pub(crate) vertical_alignment: VerticalAlignment, pub(crate) numbering_type: RowIdentifiers, + pub(crate) filter_sort_order: FilterSortOrder, pub(crate) show_debug_info: bool, #[cfg(feature = "dev-tools")] pub(crate) show_perf_metrics_overlay: bool, pub(crate) multiline_cells_enabled: bool, } + +impl CsvPreviewSettings { + /// `multiline_cells_enabled` only makes sense with `VariableList`, which + /// supports per-row heights; `UniformList` requires every row to share one + /// height, so multiline is never honored there regardless of the setting. + pub(crate) fn multiline_cells_effectively_enabled(&self) -> bool { + self.multiline_cells_enabled && self.rendering_with == RowRenderMechanism::VariableList + } +} diff --git a/crates/csv_preview/src/table_data_engine.rs b/crates/csv_preview/src/table_data_engine.rs index 382b41a2850721..f2613215fa5c04 100644 --- a/crates/csv_preview/src/table_data_engine.rs +++ b/crates/csv_preview/src/table_data_engine.rs @@ -5,22 +5,32 @@ //! //! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use ui::table_row::TableRow; use crate::{ - table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows}, - types::{DataRow, DisplayRow, TableCell, TableLikeContent}, + table_data_engine::{ + filtering_by_column::{FilterEntry, FilterStack, calculate_available_filters, retain_rows}, + sorting_by_column::{AppliedSorting, sort_data_rows}, + }, + types::{AnyColumn, DataRow, DisplayRow, TableCell, TableLikeContent}, }; +pub mod filtering_by_column; pub mod sorting_by_column; #[derive(Default)] pub(crate) struct TableDataEngine { + pub filter_stack: FilterStack, + /// Pre-computed unique values per column, used to populate filter menus + all_filters: HashMap>, pub applied_sorting: Option, d2d_mapping: DisplayToDataMapping, - pub contents: TableLikeContent, + pub contents: Arc, } impl TableDataEngine { @@ -28,32 +38,47 @@ impl TableDataEngine { &self.d2d_mapping } - pub(crate) fn apply_sort(&mut self) { - self.d2d_mapping - .apply_sorting(self.applied_sorting, &self.contents.rows); - self.d2d_mapping.merge_mappings(); + pub(crate) fn set_d2d_mapping(&mut self, mapping: DisplayToDataMapping) { + self.d2d_mapping = mapping; } - /// Applies sorting and filtering to the data and produces display to data mapping - pub(crate) fn calculate_d2d_mapping(&mut self) { - self.d2d_mapping - .apply_sorting(self.applied_sorting, &self.contents.rows); - self.d2d_mapping.merge_mappings(); + /// Recomputes the unique filter entries for every column from the current table data. + /// Must be called after content changes (e.g. after parsing). + pub fn calculate_available_filters(&mut self) { + self.all_filters = + calculate_available_filters(&self.contents.rows, self.contents.number_of_cols); } } /// Relation of Display (rendered) rows to Data (src) rows with applied transformations /// Transformations applied: /// - sorting by column +/// - filtering by column values #[derive(Debug, Default)] pub struct DisplayToDataMapping { - /// All rows sorted, regardless of applied filtering. Applied every time sorting changes + /// All rows sorted, regardless of applied filtering. Recomputed every time sorting changes pub sorted_rows: Vec, - /// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows` + /// Rows that survive the active filters. Recomputed every time filters change + pub retained_rows: HashSet, + /// Merged result: sorted rows intersected with retained rows pub mapping: Arc>, } impl DisplayToDataMapping { + /// Computes the full display-to-data mapping from owned inputs. + /// Intended to be called from a background thread. + pub(crate) fn compute( + contents: &Arc, + filter_stack: &FilterStack, + sorting: Option, + ) -> Self { + let mut mapping = Self::default(); + mapping.apply_sorting(sorting, &contents.rows); + mapping.apply_filtering(filter_stack, &contents.rows); + mapping.merge_mappings(); + mapping + } + /// Get the data row for a given display row pub fn get_data_row(&self, display_row: DisplayRow) -> Option { self.mapping.get(&display_row).copied() @@ -77,11 +102,16 @@ impl DisplayToDataMapping { self.sorted_rows = sorted_rows; } - /// Take pre-computed sorting and filtering results, and apply them to the mapping + fn apply_filtering(&mut self, filter_stack: &FilterStack, rows: &[TableRow]) { + self.retained_rows = retain_rows(rows, filter_stack); + } + + /// Merges pre-computed sorting and filtering into the final display mapping fn merge_mappings(&mut self) { self.mapping = Arc::new( self.sorted_rows .iter() + .filter(|data_row| self.retained_rows.contains(data_row)) .enumerate() .map(|(display, data)| (DisplayRow(display), *data)) .collect(), diff --git a/crates/csv_preview/src/table_data_engine/filtering_by_column.rs b/crates/csv_preview/src/table_data_engine/filtering_by_column.rs new file mode 100644 index 00000000000000..b2b8a78c05aea8 --- /dev/null +++ b/crates/csv_preview/src/table_data_engine/filtering_by_column.rs @@ -0,0 +1,250 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use ui::{SharedString, table_row::TableRow}; + +use crate::{ + table_data_engine::TableDataEngine, + types::{AnyColumn, DataRow, TableCell}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FilterEntryState { + Available { is_applied: bool }, + Unavailable { blocked_by: AnyColumn }, +} + +#[derive(Debug, Clone)] +pub struct FilterEntry { + /// Content to display. None if cell is virtual + pub content: Option, + /// List of rows in which this value occurs + pub rows: Vec, +} + +impl FilterEntry { + pub(crate) fn occurred_times(&self) -> usize { + self.rows.len() + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct FilterStack { + /// Columns in the order their first filter was applied, used to compute cascade availability + activation_order: Vec, + /// Which cell values are currently allowed for each filtered column + retention_config: HashMap>>, +} + +impl TableDataEngine { + pub(crate) fn has_active_filters(&self, col: AnyColumn) -> bool { + self.filter_stack.retention_config.contains_key(&col) + } + + /// Get available filters for a specific column with cascade behavior. + /// + /// A filter entry is "unavailable" when all of its rows are hidden by a + /// filter on an earlier-activated column, meaning selecting it would show + /// zero rows. The cascade walk stops at `column` so that the column's own + /// current filter does not affect its own entry availability. + pub(crate) fn get_filters_for_column( + &self, + column: AnyColumn, + ) -> anyhow::Result>> { + let all_column_entries = self + .all_filters + .get(&column) + .ok_or_else(|| anyhow::anyhow!("Expected {column:?} to have filter entries"))?; + + let mut unavailable_entries: HashMap, AnyColumn> = HashMap::new(); + + for &column_applied_previously in &self.filter_stack.activation_order { + if column_applied_previously == column { + break; + } + + let retained_values = self + .filter_stack + .retention_config + .get(&column_applied_previously) + .ok_or_else(|| { + anyhow::anyhow!( + "Expected {column_applied_previously:?} to have retained entries \ + as it is present in the filter stack" + ) + })?; + + // Rows that survive the filter on `column_applied_previously` + let retained_rows: HashSet = self + .contents + .rows + .iter() + .enumerate() + .filter(|(_, row)| { + let cell_value = row + .get(column_applied_previously) + .and_then(|cell| cell.display_value().cloned()); + retained_values.contains(&cell_value) + }) + .map(|(index, _)| DataRow(index)) + .collect(); + + // An entry is unavailable when none of its rows survive the parent filter + for entry in all_column_entries { + if !entry.rows.iter().any(|row| retained_rows.contains(row)) { + unavailable_entries.insert(entry.content.clone(), column_applied_previously); + } + } + } + + let empty = HashSet::new(); + let active_column_filters = self + .filter_stack + .retention_config + .get(&column) + .unwrap_or(&empty); + + Ok(Arc::new( + all_column_entries + .iter() + .map(|entry| { + let state = if let Some(&blocked_by) = unavailable_entries.get(&entry.content) { + FilterEntryState::Unavailable { blocked_by } + } else { + FilterEntryState::Available { + is_applied: active_column_filters.contains(&entry.content), + } + }; + (entry.clone(), state) + }) + .collect(), + )) + } + + pub(crate) fn clear_filters_for_col(&mut self, col: AnyColumn) { + self.filter_stack + .activation_order + .retain(|&entry| entry != col); + self.filter_stack.retention_config.remove(&col); + } + + /// Toggle a filter value for a column. Returns `true` if the filter was + /// added, `false` if it was removed. + pub(crate) fn toggle_filter( + &mut self, + column: AnyColumn, + value: Option, + ) -> anyhow::Result { + let is_currently_applied = self + .filter_stack + .retention_config + .get(&column) + .is_some_and(|filters| filters.contains(&value)); + + if is_currently_applied { + self.remove_filter(column, value)?; + Ok(false) + } else { + self.apply_filter(column, value); + Ok(true) + } + } + + fn remove_filter( + &mut self, + column: AnyColumn, + value: Option, + ) -> anyhow::Result<()> { + let entries = self + .filter_stack + .retention_config + .get_mut(&column) + .ok_or_else(|| { + anyhow::anyhow!("Expected {column:?} to be present in active filters") + })?; + + debug_assert!( + entries.contains(&value), + "Expected value to be present in {column:?} active filters" + ); + + if entries.len() == 1 { + self.filter_stack.retention_config.remove(&column); + self.filter_stack + .activation_order + .retain(|&entry| entry != column); + } else { + entries.remove(&value); + } + Ok(()) + } + + fn apply_filter(&mut self, column: AnyColumn, value: Option) { + // Track the column only on its first activation to preserve cascade order + if !self.filter_stack.activation_order.contains(&column) { + self.filter_stack.activation_order.push(column); + } + self.filter_stack + .retention_config + .entry(column) + .or_default() + .insert(value); + } +} + +/// Calculate available filter entries for each column from the table data. +pub fn calculate_available_filters( + content_rows: &[TableRow], + number_of_cols: usize, +) -> HashMap> { + let mut available_filters = HashMap::new(); + + for col_idx in 0..number_of_cols { + let column = AnyColumn::new(col_idx); + let mut value_to_rows: HashMap, Vec> = HashMap::new(); + + for (row_index, row) in content_rows.iter().enumerate() { + let cell_value = row + .get(column) + .and_then(|cell| cell.display_value().cloned()); + value_to_rows + .entry(cell_value) + .or_default() + .push(DataRow(row_index)); + } + + let filter_entries: Vec = value_to_rows + .into_iter() + .map(|(content, rows)| FilterEntry { content, rows }) + .collect(); + + available_filters.insert(column, filter_entries); + } + + available_filters +} + +/// Returns the set of data rows that survive all active filters in the stack. +pub fn retain_rows( + content_rows: &[TableRow], + filter_stack: &FilterStack, +) -> HashSet { + let config = &filter_stack.retention_config; + if config.is_empty() { + return (0..content_rows.len()).map(DataRow).collect(); + } + + content_rows + .iter() + .enumerate() + .filter(|(_, row)| { + config.iter().all(|(col, allowed_values)| { + let cell_value = row.get(*col).and_then(|cell| cell.display_value().cloned()); + allowed_values.contains(&cell_value) + }) + }) + .map(|(index, _)| DataRow(index)) + .collect() +} diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index 195d0d8df904b4..0a7857152b425c 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -68,8 +68,6 @@ terminal_view.workspace = true text.workspace = true theme.workspace = true theme_settings.workspace = true -tree-sitter-json.workspace = true -tree-sitter.workspace = true ui.workspace = true ui_input.workspace = true unindent = { workspace = true, optional = true } diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 5f07f2a70d2837..cd7705f0e7323e 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -103,7 +103,7 @@ impl AttachModal { window, cx, ) - .modal(modal) + .when(!modal, |picker| picker.embedded()) }); Self { _subscription: cx.subscribe(&picker, |_, _, _, cx| { @@ -119,7 +119,6 @@ impl Render for AttachModal { v_flex() .key_context("AttachModal") .track_focus(&self.focus_handle(cx)) - .w(rems(34.)) .child(self.picker.clone()) } } @@ -137,6 +136,10 @@ impl ModalView for AttachModal {} impl PickerDelegate for AttachModalDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "attach modal" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index c034363bcd9e92..37abc0469482e5 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -14,7 +14,7 @@ use collections::IndexMap; use dap::adapters::DebugAdapterName; use dap::{DapRegistry, StartDebuggingRequestArguments}; use dap::{client::SessionId, debugger_settings::DebuggerSettings}; -use editor::{Editor, MultiBufferOffset, ToPoint}; +use editor::Editor; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag}; use gpui::{ Action, Anchor, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, @@ -29,11 +29,12 @@ use project::{DebugScenarioContext, Fs, ProjectPath, TaskSourceKind, WorktreeId} use project::{Project, debugger::session::ThreadStatus}; use rpc::proto::{self}; use settings::Settings; -use std::sync::{Arc, LazyLock}; +use std::sync::Arc; use task::{DebugScenario, SharedTaskContext}; -use tree_sitter::{Query, StreamingIterator as _}; + use ui::{ - ContextMenu, Divider, PopoverMenu, PopoverMenuHandle, SplitButton, Tab, Tooltip, prelude::*, + ButtonLike, ContextMenu, Divider, ElevationIndex, PopoverMenu, PopoverMenuHandle, SplitButton, + Tab, TintColor, Tooltip, prelude::*, }; use util::redact::redact_command; use util::rel_path::RelPath; @@ -731,11 +732,11 @@ impl DebugPanel { IconName::DebugContinue, ) .icon_size(IconSize::Small) + .disabled(thread_status != ThreadStatus::Stopped) .on_click(window.listener_for( running_state, |this, _, _window, cx| this.continue_thread(cx), )) - .disabled(thread_status != ThreadStatus::Stopped) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { @@ -953,28 +954,26 @@ impl DebugPanel { .map(|session| session.read(cx).running_state()) .cloned(), |this, running_state| { - this.children({ - let threads = - running_state.update(cx, |running_state, cx| { - let session = running_state.session(); - session.read(cx).is_started().then(|| { - session.update(cx, |session, cx| { - session.threads(cx) - }) - }) - }); - - threads.and_then(|threads| { - self.render_thread_dropdown( - &running_state, - threads, - window, - cx, - ) + let threads = running_state.update(cx, |running_state, cx| { + let session = running_state.session(); + session.read(cx).is_started().then(|| { + session.update(cx, |session, cx| session.threads(cx)) + }) + }); + + let thread_dropdown = threads.and_then(|threads| { + self.render_thread_dropdown( + &running_state, + threads, + window, + cx, + ) + }); + + this.when_some(thread_dropdown, |this, dropdown| { + this.child(dropdown).when(!is_side, |this| { + this.gap_0p5().child(Divider::vertical()) }) - }) - .when(!is_side, |this| { - this.gap_0p5().child(Divider::vertical()) }) }, ), @@ -1091,14 +1090,14 @@ impl DebugPanel { directory_in_worktree: dir, .. } => { - let relative_path = if dir.ends_with(RelPath::unix(".vscode").unwrap()) { - dir.join(RelPath::unix("launch.json").unwrap()) + let relative_path = if dir.ends_with(RelPath::from_unix_str(".vscode").unwrap()) { + dir.join(RelPath::from_unix_str("launch.json").unwrap()) } else { - dir.join(RelPath::unix("debug.json").unwrap()) + dir.join(RelPath::from_unix_str("debug.json").unwrap()) }; ProjectPath { worktree_id: id, - path: relative_path, + path: relative_path.into(), } } _ => return self.save_scenario(scenario, worktree_id, window, cx), @@ -1221,76 +1220,7 @@ impl DebugPanel { window: &mut Window, cx: &mut Context, ) -> Result>> { - static LAST_ITEM_QUERY: LazyLock = LazyLock::new(|| { - Query::new( - &tree_sitter_json::LANGUAGE.into(), - "(document (array (object) @object))", // TODO: use "." anchor to only match last object - ) - .expect("Failed to create LAST_ITEM_QUERY") - }); - static EMPTY_ARRAY_QUERY: LazyLock = LazyLock::new(|| { - Query::new( - &tree_sitter_json::LANGUAGE.into(), - "(document (array) @array)", - ) - .expect("Failed to create EMPTY_ARRAY_QUERY") - }); - - let content = editor.text(cx); - let mut parser = tree_sitter::Parser::new(); - parser.set_language(&tree_sitter_json::LANGUAGE.into())?; - let mut cursor = tree_sitter::QueryCursor::new(); - let syntax_tree = parser - .parse(&content, None) - .context("could not parse debug.json")?; - let mut matches = cursor.matches( - &LAST_ITEM_QUERY, - syntax_tree.root_node(), - content.as_bytes(), - ); - - let mut last_offset = None; - while let Some(mat) = matches.next() { - if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end) { - last_offset = Some(MultiBufferOffset(pos)) - } - } - let mut edits = Vec::new(); - let mut cursor_position = MultiBufferOffset(0); - - if let Some(pos) = last_offset { - edits.push((pos..pos, format!(",\n{new_scenario}"))); - cursor_position = pos + ",\n ".len(); - } else { - let mut matches = cursor.matches( - &EMPTY_ARRAY_QUERY, - syntax_tree.root_node(), - content.as_bytes(), - ); - - if let Some(mat) = matches.next() { - if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end - 1) { - edits.push(( - MultiBufferOffset(pos)..MultiBufferOffset(pos), - format!("\n{new_scenario}\n"), - )); - cursor_position = MultiBufferOffset(pos) + "\n ".len(); - } - } else { - edits.push(( - MultiBufferOffset(0)..MultiBufferOffset(0), - format!("[\n{}\n]", new_scenario), - )); - cursor_position = MultiBufferOffset("[\n ".len()); - } - } - editor.transact(window, cx, |editor, window, cx| { - editor.edit(edits, cx); - let snapshot = editor.buffer().read(cx).read(cx); - let point = cursor_position.to_point(&snapshot); - drop(snapshot); - editor.go_to_singleton_buffer_point(point, window, cx); - }); + tasks_ui::insert_task_json_into_editor(editor, new_scenario, window, cx)?; Ok(editor.save(SaveOptions::default(), project, window, cx)) } @@ -1351,9 +1281,14 @@ impl DebugPanel { running_state: &Entity, thread_status: ThreadStatus, window: &mut Window, - ) -> IconButton { - IconButton::new("debug-back-in-history", IconName::HistoryRerun) - .icon_size(IconSize::Small) + ) -> ButtonLike { + ButtonLike::new_rounded_left("debug-back-in-history") + .layer(ElevationIndex::ModalSurface) + .child(Icon::new(IconName::HistoryRerun).size(IconSize::Small)) + .disabled( + thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, + ) + .tooltip(Tooltip::text("Step Back in Session History")) .on_click(window.listener_for(running_state, |this, _, _window, cx| { this.session().update(cx, |session, cx| { let ix = session @@ -1363,9 +1298,6 @@ impl DebugPanel { session.select_historic_snapshot(Some(ix.saturating_sub(1)), cx); }) })) - .disabled( - thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, - ) } fn render_history_toggle_button( @@ -1373,20 +1305,19 @@ impl DebugPanel { thread_status: ThreadStatus, running_state: &Entity, ) -> impl IntoElement { + let chevron_button_size = rems_from_px(20.); 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)), - ) + ButtonLike::new_rounded_right("debug-back-in-history-menu-trigger") + .layer(ElevationIndex::ModalSurface) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .disabled( thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, - ), + ) + .width(chevron_button_size) + .height(chevron_button_size.into()) + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), ) .menu({ let running_state = running_state.clone(); @@ -1417,7 +1348,10 @@ impl DebugPanel { handler(None, running_state.clone(), cx); } }); - context_menu = context_menu.separator(); + + if !history.is_empty() { + context_menu = context_menu.separator(); + } for (ix, _) in history.iter().enumerate().rev() { context_menu = @@ -1935,9 +1869,9 @@ impl Render for DebugPanel { h_flex() .size_full() .child(breakpoint_list) - .child(Divider::vertical()) + .child(Divider::vertical().h_full()) .child(welcome_experience) - .child(Divider::vertical()), + .child(Divider::vertical().h_full()), ) } else { this.child( diff --git a/crates/debugger_ui/src/dropdown_menus.rs b/crates/debugger_ui/src/dropdown_menus.rs index 0e07cb8841b08c..1fb7f901540438 100644 --- a/crates/debugger_ui/src/dropdown_menus.rs +++ b/crates/debugger_ui/src/dropdown_menus.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use collections::HashMap; use gpui::{Anchor, Entity, WeakEntity}; use project::debugger::session::{ThreadId, ThreadStatus}; -use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*}; +use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, Indicator, Tooltip, prelude::*}; use util::{maybe, truncate_and_trailoff}; use crate::{ @@ -132,7 +132,7 @@ impl DebugPanel { let session_state_indicator = if is_terminated { Indicator::dot().color(Color::Error).into_any_element() } else if !is_started { - Icon::new(IconName::ArrowCircle) + Icon::new(IconName::LoadCircle) .size(IconSize::Small) .color(Color::Muted) .with_rotate_animation(2) @@ -147,7 +147,6 @@ impl DebugPanel { let trigger = h_flex() .gap_2() .child(session_state_indicator) - .justify_between() .child( DebugPanel::dropdown_label(trigger_label) .when(is_terminated, |this| this.strikethrough()), @@ -212,8 +211,8 @@ impl DebugPanel { }), ) .attach(Anchor::BottomLeft) - .style(DropdownStyle::Ghost) - .handle(self.session_picker_menu_handle.clone()); + .handle(self.session_picker_menu_handle.clone()) + .trigger_tooltip(Tooltip::text("Select a Debug Session")); Some(menu) } @@ -325,7 +324,6 @@ impl DebugPanel { ) .attach(Anchor::BottomLeft) .disabled(session_terminated) - .style(DropdownStyle::Ghost) .handle(self.thread_picker_menu_handle.clone()), ) } else { diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 9b523c04c66d2e..bb72a8dd172d42 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -106,7 +106,7 @@ impl NewProcessModal { let delegate = DebugDelegate::new(debug_panel.downgrade(), task_store.clone()); Picker::list(delegate, window, cx) - .modal(false) + .embedded() .list_measure_all() }); @@ -1069,10 +1069,10 @@ impl DebugDelegate { match path.components().next_back() { Some(".zed") => { - path.push(RelPath::unix("debug.json").unwrap()); + path.push(RelPath::from_unix_str("debug.json").unwrap()); } Some(".vscode") => { - path.push(RelPath::unix("launch.json").unwrap()); + path.push(RelPath::from_unix_str("launch.json").unwrap()); } _ => {} } @@ -1165,7 +1165,7 @@ impl DebugDelegate { id: _, directory_in_worktree: dir, id_base: _, - } => dir.ends_with(RelPath::unix(".zed").unwrap()), + } => dir.ends_with(RelPath::from_unix_str(".zed").unwrap()), _ => false, }); @@ -1186,7 +1186,8 @@ impl DebugDelegate { id_base: _, } => { !(hide_vscode - && dir.ends_with(RelPath::unix(".vscode").unwrap())) + && dir + .ends_with(RelPath::from_unix_str(".vscode").unwrap())) } _ => true, }) @@ -1207,6 +1208,10 @@ impl DebugDelegate { impl PickerDelegate for DebugDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "debug scenario picker" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/debugger_ui/src/persistence.rs b/crates/debugger_ui/src/persistence.rs index 7282e5160d709a..6e68dfe5ba8647 100644 --- a/crates/debugger_ui/src/persistence.rs +++ b/crates/debugger_ui/src/persistence.rs @@ -70,23 +70,23 @@ impl DebuggerPaneItem { pub(crate) fn tab_tooltip(self) -> SharedString { let tooltip = match self { DebuggerPaneItem::Console => { - "Displays program output and allows manual input of debugger commands." + "Displays program output and allows manual input of debugger commands" } DebuggerPaneItem::Variables => { - "Shows current values of local and global variables in the current stack frame." + "Shows current values of local and global variables in the current stack frame" } - DebuggerPaneItem::BreakpointList => "Lists all active breakpoints set in the code.", + DebuggerPaneItem::BreakpointList => "Lists all active breakpoints set in the code", DebuggerPaneItem::Frames => { - "Displays the call stack, letting you navigate between function calls." + "Displays the call stack, letting you navigate between function calls" } - DebuggerPaneItem::Modules => "Shows all modules or libraries loaded by the program.", + DebuggerPaneItem::Modules => "Shows all modules or libraries loaded by the program", DebuggerPaneItem::LoadedSources => { - "Lists all source files currently loaded and used by the debugger." + "Lists all source files currently loaded and used by the debugger" } DebuggerPaneItem::Terminal => { - "Provides an interactive terminal session within the debugging environment." + "Provides an interactive terminal session within the debugging environment" } - DebuggerPaneItem::MemoryView => "Allows inspection of memory contents.", + DebuggerPaneItem::MemoryView => "Allows inspection of memory contents", }; SharedString::new_static(tooltip) } diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index a964eb389f610e..1226055eb3fe7b 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -115,6 +115,7 @@ impl Render for RunningState { } else if let Some(active) = active { self.panes .render( + None, None, &ActivePaneDecorator::new(active, &self.workspace), window, @@ -382,20 +383,232 @@ impl Render for SubView { "subview-container-{}", self.kind.to_shared_string() )) - .on_hover(cx.listener(|this, hovered, _, cx| { - this.hovered = *hovered; - cx.notify(); - })) .size_full() - // Add border unconditionally to prevent layout shifts on focus changes. .border_1() .when(self.item_focus_handle.contains_focused(window, cx), |el| { el.border_color(cx.theme().colors().pane_focused_border) }) .child(self.inner.clone()) + .on_hover(cx.listener(|this, hovered, _, cx| { + this.hovered = *hovered; + cx.notify(); + })) + } +} + +struct DraggedTabPreview { + label: SharedString, +} + +impl Render for DraggedTabPreview { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font = theme_settings::ThemeSettings::get_global(cx) + .ui_font + .clone(); + let colors = cx.theme().colors(); + + h_flex() + .font(ui_font) + .h_6() + .px_1() + .rounded_sm() + .shadow_md() + .border_1() + .border_color(colors.border) + .bg(colors.elevated_surface_background) + .child(Label::new(self.label.clone()).size(LabelSize::Small)) } } +fn render_debugger_tab( + ix: usize, + item: &dyn ItemHandle, + selected: bool, + deemphasized: bool, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement + use<> { + let item_ = item.boxed_clone(); + let colors = cx.theme().colors(); + + div() + .border_l_2() + .border_color(gpui::transparent_black()) + .drag_over::(|wrapper, _, _, cx| wrapper.border_color(cx.theme().colors().text)) + .child( + div() + .cursor_pointer() + .id(format!("debugger_tab_{}", item.item_id().as_u64())) + .p_1() + .rounded_sm() + .map(|s| { + if selected { + s.bg(colors.text_accent.opacity(0.08)) + .hover(|s| s.bg(colors.text_accent.opacity(0.25))) + .text_color(colors.text_accent) + } else { + s.hover(|s| s.bg(colors.element_hover)) + } + }) + .when(deemphasized, |s| s.opacity(0.8)) + .child(item.tab_content( + TabContentParams { + selected, + deemphasized, + ..Default::default() + }, + window, + cx, + )) + .when_some(item.tab_tooltip_text(cx), |this, tooltip| { + this.tooltip(Tooltip::text(tooltip)) + }) + .on_click(cx.listener(move |this, _, window, cx| { + let index = this.index_for_item(&*item_); + if let Some(index) = index { + this.activate_item(index, true, true, window, cx); + } + })) + .on_drop( + cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| { + if dragged_tab.item.downcast::().is_none() { + return; + } + this.drag_split_direction = None; + this.handle_tab_drop(dragged_tab, ix, false, window, cx) + }), + ) + .on_drag( + DraggedTab { + item: item.boxed_clone(), + pane: cx.entity(), + detail: 0, + is_active: selected, + ix, + }, + |tab, _, _, cx| { + let label = tab.item.tab_content_text(0, cx); + cx.new(|_| DraggedTabPreview { label }) + }, + ), + ) +} + +fn render_debugger_tab_bar( + pane: &mut Pane, + focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut Context, +) -> gpui::AnyElement { + let active_pane_item = pane.active_item(); + let pane_group_id: SharedString = format!("pane-zoom-button-hover-{}", cx.entity_id()).into(); + let as_subview = active_pane_item + .as_ref() + .and_then(|item| item.downcast::()); + + let is_hovered = as_subview + .as_ref() + .is_some_and(|item| item.read(cx).hovered); + let deemphasized = !pane.has_focus(window, cx); + + let tabs = pane + .items() + .enumerate() + .map(|(ix, item)| { + let selected = active_pane_item + .as_ref() + .is_some_and(|active| active.item_id() == item.item_id()); + render_debugger_tab(ix, item.as_ref(), selected, deemphasized, window, cx) + }) + .collect::>(); + + h_flex() + .track_focus(focus_handle) + .group(pane_group_id.clone()) + .on_action(|_: &menu::Cancel, window, cx| { + if cx.stop_active_drag(window) { + } else { + cx.propagate(); + } + }) + .pl_1p5() + .pr_1() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().tab_bar_background) + .child( + h_flex() + .w_full() + .gap_1() + .h(Tab::container_height(cx)) + .children(tabs) + .on_drop( + cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| { + if dragged_tab.item.downcast::().is_none() { + return; + } + this.drag_split_direction = None; + this.handle_tab_drop(dragged_tab, this.items_len(), false, window, cx) + }), + ) + .child( + div() + .flex_1() + .h_6() + .border_l_2() + .border_color(gpui::transparent_black()) + .drag_over::(|spacer, _, _, cx| { + spacer.border_color(cx.theme().colors().text) + }), + ), + ) + .child({ + let zoomed = pane.is_zoomed(); + + h_flex() + .visible_on_hover(pane_group_id) + .when(is_hovered, |this| this.visible()) + .when_some(as_subview.as_ref(), |this, subview| { + subview.update(cx, |view, cx| { + let Some(additional_actions) = view.actions.as_mut() else { + return this; + }; + this.child(additional_actions(window, cx)) + }) + }) + .child( + IconButton::new( + format!("debug-toggle-zoom-{}", cx.entity_id()), + if zoomed { + IconName::Minimize + } else { + IconName::Maximize + }, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(move |pane, _, _, cx| { + let is_zoomed = pane.is_zoomed(); + pane.set_zoomed(!is_zoomed, cx); + cx.notify(); + })) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + let zoomed_text = if zoomed { "Minimize" } else { "Expand" }; + Tooltip::for_action_in( + zoomed_text, + &ToggleExpandItem, + &focus_handle, + cx, + ) + } + }), + ) + }) + .into_any_element() +} + pub(crate) fn new_debugger_pane( workspace: WeakEntity, project: Entity, @@ -456,169 +669,7 @@ pub(crate) fn new_debugger_pane( pane.set_should_display_tab_bar(|_, _| true); pane.set_render_tab_bar_buttons(cx, |_, _, _| (None, None)); pane.set_render_tab_bar(cx, { - move |pane, window, cx| { - let active_pane_item = pane.active_item(); - let pane_group_id: SharedString = - format!("pane-zoom-button-hover-{}", cx.entity_id()).into(); - let as_subview = active_pane_item - .as_ref() - .and_then(|item| item.downcast::()); - let is_hovered = as_subview - .as_ref() - .is_some_and(|item| item.read(cx).hovered); - - h_flex() - .track_focus(&focus_handle) - .group(pane_group_id.clone()) - .pl_1p5() - .pr_1() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().tab_bar_background) - .on_action(|_: &menu::Cancel, window, cx| { - if cx.stop_active_drag(window) { - } else { - cx.propagate(); - } - }) - .child( - h_flex() - .w_full() - .gap_1() - .h(Tab::container_height(cx)) - .drag_over::(|bar, _, _, cx| { - bar.bg(cx.theme().colors().drop_target_background) - }) - .on_drop(cx.listener( - move |this, dragged_tab: &DraggedTab, window, cx| { - if dragged_tab.item.downcast::().is_none() { - return; - } - this.drag_split_direction = None; - this.handle_tab_drop( - dragged_tab, - this.items_len(), - false, - window, - cx, - ) - }, - )) - .children(pane.items().enumerate().map(|(ix, item)| { - let selected = active_pane_item - .as_ref() - .is_some_and(|active| active.item_id() == item.item_id()); - let deemphasized = !pane.has_focus(window, cx); - let item_ = item.boxed_clone(); - div() - .id(format!("debugger_tab_{}", item.item_id().as_u64())) - .p_1() - .rounded_md() - .cursor_pointer() - .when_some(item.tab_tooltip_text(cx), |this, tooltip| { - this.tooltip(Tooltip::text(tooltip)) - }) - .map(|this| { - let theme = cx.theme(); - if selected { - let color = theme.colors().tab_active_background; - let color = if deemphasized { - color.opacity(0.5) - } else { - color - }; - this.bg(color) - } else { - let hover_color = theme.colors().element_hover; - this.hover(|style| style.bg(hover_color)) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - let index = this.index_for_item(&*item_); - if let Some(index) = index { - this.activate_item(index, true, true, window, cx); - } - })) - .child(item.tab_content( - TabContentParams { - selected, - deemphasized, - ..Default::default() - }, - window, - cx, - )) - .on_drop(cx.listener( - move |this, dragged_tab: &DraggedTab, window, cx| { - if dragged_tab.item.downcast::().is_none() { - return; - } - this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, ix, false, window, cx) - }, - )) - .on_drag( - DraggedTab { - item: item.boxed_clone(), - pane: cx.entity(), - detail: 0, - is_active: selected, - ix, - }, - |tab, _, _, cx| cx.new(|_| tab.clone()), - ) - })), - ) - .child({ - let zoomed = pane.is_zoomed(); - - h_flex() - .visible_on_hover(pane_group_id) - .when(is_hovered, |this| this.visible()) - .when_some(as_subview.as_ref(), |this, subview| { - subview.update(cx, |view, cx| { - let Some(additional_actions) = view.actions.as_mut() else { - return this; - }; - this.child(additional_actions(window, cx)) - }) - }) - .child( - IconButton::new( - SharedString::from(format!( - "debug-toggle-zoom-{}", - cx.entity_id() - )), - if zoomed { - IconName::Minimize - } else { - IconName::Maximize - }, - ) - .icon_size(IconSize::Small) - .on_click(cx.listener(move |pane, _, _, cx| { - let is_zoomed = pane.is_zoomed(); - pane.set_zoomed(!is_zoomed, cx); - cx.notify(); - })) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - let zoomed_text = - if zoomed { "Minimize" } else { "Expand" }; - Tooltip::for_action_in( - zoomed_text, - &ToggleExpandItem, - &focus_handle, - cx, - ) - } - }), - ) - }) - .into_any_element() - } + move |pane, window, cx| render_debugger_tab_bar(pane, &focus_handle, window, cx) }); pane }) diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index a344a92eadd826..90fd5964d00a6a 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -19,8 +19,8 @@ use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session: use settings::Settings; use theme_settings::ThemeSettings; use ui::{ - ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render, - ScrollableHandle, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, + ContextMenu, Divider, DropdownMenu, PopoverMenuHandle, ScrollableHandle, + StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, }; use workspace::Workspace; @@ -368,7 +368,9 @@ impl MemoryView { this }), ) + .style(ui::DropdownStyle::Outlined) .handle(self.width_picker_handle.clone()) + .attach(gpui::Anchor::BottomLeft) } fn page_down(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { @@ -846,19 +848,16 @@ fn render_single_memory_view_line( } impl Render for MemoryView { - fn render( - &mut self, - window: &mut ui::Window, - cx: &mut ui::Context, - ) -> impl ui::IntoElement { + fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement { let (icon, tooltip_text) = if self.is_writing_memory { - (IconName::Pencil, "Edit memory at a selected address") + (IconName::Pencil, "Edit Memory at a Selected Address") } else { ( IconName::LocationEdit, - "Change address of currently viewed memory", + "Change Address of Currently Viewed Memory", ) }; + v_flex() .id("Memory-view") .on_action(cx.listener(Self::cancel)) @@ -873,31 +872,31 @@ impl Render for MemoryView { .child( h_flex() .w_full() - .mb_0p5() + .mb_1() .gap_1() .child( h_flex() + .px_1() + .h_6() .w_full() - .rounded_md() + .rounded_sm() + .gap_1() .border_1() - .gap_x_2() - .px_2() - .py_0p5() - .mb_0p5() - .bg(cx.theme().colors().editor_background) .when_else( self.query_editor .focus_handle(cx) .contains_focused(window, cx), |this| this.border_color(cx.theme().colors().border_focused), - |this| this.border_color(cx.theme().colors().border_transparent), + |this| this.border_color(cx.theme().colors().border_variant), ) + .bg(cx.theme().colors().editor_background) .child( div() .id("memory-view-editor-icon") .child(Icon::new(icon).size(ui::IconSize::XSmall)) .tooltip(Tooltip::text(tooltip_text)), ) + .child(Divider::vertical()) .child(self.render_query_bar(cx)), ) .child(self.render_width_picker(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 982fc0f8567bc1..aa8f3483a68bd1 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -395,7 +395,10 @@ impl StackFrameList { let stack_frame_id = stack_frame.id; self.opened_stack_frame_id = Some(stack_frame_id); let Some(abs_path) = Self::abs_path_from_stack_frame(&stack_frame) else { - return Task::ready(Err(anyhow!("Project path not found"))); + return Task::ready(Err(anyhow!( + "no absolute source path in stack frame {stack_frame_id}, source: {:?}", + stack_frame.source + ))); }; let row = stack_frame.line.saturating_sub(1) as u32; cx.emit(StackFrameListEvent::SelectedStackFrameChanged( @@ -521,7 +524,7 @@ impl StackFrameList { .filter(|path| { // Since we do not know if we are debugging on the host or (a remote/WSL) target, // we need to check if either the path is absolute as Posix or Windows. - is_absolute(path, PathStyle::Posix) || is_absolute(path, PathStyle::Windows) + is_absolute(path, PathStyle::Unix) || is_absolute(path, PathStyle::Windows) }) .map(|path| Arc::::from(Path::new(path))) }) @@ -911,7 +914,7 @@ impl StackFrameList { .child( IconButton::new( "filter-by-visible-worktree-stack-frame-list", - IconName::ListFilter, + IconName::Filter, ) .tooltip(move |_window, cx| { Tooltip::for_action(tooltip_title, &ToggleUserFrames, cx) diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index cdb5b8122a39f8..56aed0bb1c7de8 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -298,9 +298,10 @@ impl VariableList { contains_local_scope = true; } - self.session.update(cx, |session, cx| { - !session.variables(scope.variables_reference, cx).is_empty() - }) + scope.expensive + || self.session.update(cx, |session, cx| { + !session.variables(scope.variables_reference, cx).is_empty() + }) }) .map(|scope| { ( @@ -347,12 +348,13 @@ impl VariableList { .or_insert(EntryState { depth: path.indices.len(), is_expanded: dap_kind.as_scope().is_some_and(|scope| { - (scopes_count == 1 && !contains_local_scope) - || scope - .presentation_hint - .as_ref() - .map(|hint| *hint == ScopePresentationHint::Locals) - .unwrap_or(scope.name.to_lowercase().starts_with("local")) + !scope.expensive + && ((scopes_count == 1 && !contains_local_scope) + || scope + .presentation_hint + .as_ref() + .map(|hint| *hint == ScopePresentationHint::Locals) + .unwrap_or(scope.name.to_lowercase().starts_with("local"))) }), parent_reference: container_reference, has_children: variables_reference != 0, diff --git a/crates/debugger_ui/src/tests/inline_values.rs b/crates/debugger_ui/src/tests/inline_values.rs index c82276d824349c..713d2930ef6aa2 100644 --- a/crates/debugger_ui/src/tests/inline_values.rs +++ b/crates/debugger_ui/src/tests/inline_values.rs @@ -1831,10 +1831,11 @@ fn python_lang() -> Language { Language::new( LanguageConfig { name: "Python".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["py".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_python::LANGUAGE.into()), @@ -1849,10 +1850,11 @@ fn go_lang() -> Arc { Language::new( LanguageConfig { name: "Go".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["go".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_go::LANGUAGE.into()), @@ -2268,10 +2270,11 @@ fn javascript_lang() -> Arc { Language::new( LanguageConfig { name: "JavaScript".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["js".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), @@ -2287,10 +2290,11 @@ fn typescript_lang() -> Arc { Language::new( LanguageConfig { name: "TypeScript".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), @@ -2306,10 +2310,11 @@ fn tsx_lang() -> Arc { Language::new( LanguageConfig { name: "TSX".into(), - matcher: LanguageMatcher { + matcher: (LanguageMatcher { path_suffixes: vec!["tsx".to_string()], ..Default::default() - }, + }) + .into(), ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index 8e6c1259921b45..5b88637306e0a2 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -478,6 +478,257 @@ async fn test_fetch_variables_for_multiple_scopes( }); } +/// A scope marked `expensive: true` by the DAP adapter (e.g. a JavaScript "Global" +/// scope) must not have its variables fetched automatically, since resolving it can +/// hang indefinitely. It should render collapsed, and only be resolved once the user +/// explicitly expands it. +#[gpui::test] +async fn test_expensive_scope_is_not_eagerly_fetched( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const local = 1; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + workspace + .update(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client.on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }); + + client.on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }); + + client.on_request::(move |_, _| Ok(())); + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".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, + }]; + + client.on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }); + + let scopes = vec![ + Scope { + name: "Local".into(), + presentation_hint: Some(dap::ScopePresentationHint::Locals), + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + Scope { + name: "Global".into(), + presentation_hint: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + expensive: true, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + ]; + + client.on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }); + + let fetched_expensive_scope = Arc::new(AtomicBool::new(false)); + + client.on_request::({ + let fetched_expensive_scope = fetched_expensive_scope.clone(); + move |_, args| match args.variables_reference { + 2 => Ok(dap::VariablesResponse { + variables: vec![Variable { + name: "localVar".into(), + value: "1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }], + }), + 3 => { + fetched_expensive_scope.store(true, Ordering::SeqCst); + Ok(dap::VariablesResponse { + variables: vec![Variable { + name: "globalVar".into(), + value: "expensive".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }], + }) + } + id => unreachable!("unexpected variables reference {id}"), + } + }); + + client + .fake_event(dap::messages::Events::Stopped(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, + })) + .await; + + cx.run_until_parked(); + + let running_state = + active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { + cx.focus_self(window); + let running = item.running_state().clone(); + + let variable_list = running.update(cx, |state, cx| { + // have to do this because the variable list pane should be shown/active + // for testing keyboard navigation + state.activate_item(DebuggerPaneItem::Variables, window, cx); + + state.variable_list().clone() + }); + variable_list.update(cx, |_, cx| cx.focus_self(window)); + running + }); + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + running_state + .variable_list() + .update(cx, |variable_list, _| { + // The expensive "Global" scope is visible but collapsed; its variables + // must not have been fetched yet. + variable_list.assert_visual_entries(vec!["v Local", " > localVar", "> Global"]); + }); + }); + + assert!( + !fetched_expensive_scope.load(Ordering::SeqCst), + "Zed must not eagerly fetch variables for a scope marked `expensive: true`" + ); + + // Select and expand the Global scope: only now should its variables be resolved. + cx.dispatch_action(SelectFirst); + cx.dispatch_action(SelectFirst); + cx.run_until_parked(); + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + running_state + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Local", + " > localVar", + "v Global <=== selected", + " > globalVar", + ]); + }); + }); + + assert!( + fetched_expensive_scope.load(Ordering::SeqCst), + "Expanding the Global scope should fetch its variables" + ); +} + // tests that toggling a variable will fetch its children and shows it #[gpui::test] async fn test_keyboard_navigation(executor: BackgroundExecutor, cx: &mut TestAppContext) { diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index a5ba5edd6f85d8..acd5a70b115ffc 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -58,6 +58,8 @@ pub(crate) struct DevContainerUp { pub(crate) extension_ids: Vec, #[serde(default)] pub(crate) remote_env: HashMap, + #[serde(default)] + pub(crate) started_at: Option, } #[derive(Debug)] @@ -172,13 +174,13 @@ pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec Vec { let mut configs = Vec::new(); - let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path"); + let devcontainer_dir_path = RelPath::from_unix_str(".devcontainer").expect("valid path"); if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) { if devcontainer_entry.is_dir() { log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory"); let devcontainer_json_path = - RelPath::unix(".devcontainer/devcontainer.json").expect("valid path"); + RelPath::from_unix_str(".devcontainer/devcontainer.json").expect("valid path"); for entry in snapshot.child_entries(devcontainer_dir_path) { log::debug!( "find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}", @@ -199,7 +201,7 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str()); - if let Ok(rel_config_path) = RelPath::unix(&config_json_path) { + if let Ok(rel_config_path) = RelPath::from_unix_str(&config_json_path) { if snapshot.entry_for_path(rel_config_path).is_some() { log::debug!( "find_configs_in_snapshot: Found config in subfolder: {}", @@ -223,7 +225,7 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec // Always include `.devcontainer.json` so the user can pick it from the UI // even when `.devcontainer/devcontainer.json` also exists. - let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path"); + let root_config_path = RelPath::from_unix_str(".devcontainer.json").expect("valid path"); if snapshot .entry_for_path(root_config_path) .is_some_and(|entry| entry.is_file()) @@ -400,7 +402,7 @@ pub(crate) async fn apply_devcontainer_template( log::error!("Can't create relative path: {e}"); DevContainerError::FilesystemError })?; - let rel_path = RelPath::unix(relative_path) + let rel_path = RelPath::from_unix_str(relative_path) .map_err(|e| { log::error!("Can't create relative path: {e}"); DevContainerError::FilesystemError diff --git a/crates/dev_container/src/devcontainer_json.rs b/crates/dev_container/src/devcontainer_json.rs index c8573b6b9c8520..38588241bdf9ad 100644 --- a/crates/dev_container/src/devcontainer_json.rs +++ b/crates/dev_container/src/devcontainer_json.rs @@ -213,10 +213,10 @@ pub(crate) struct DevContainer { user_env_probe: Option, pub(crate) override_command: Option, shutdown_action: Option, - init: Option, + pub(crate) init: Option, pub(crate) privileged: Option, - cap_add: Option>, - security_opt: Option>, + pub(crate) cap_add: Option>, + pub(crate) security_opt: Option>, #[serde(default, deserialize_with = "deserialize_mount_definitions")] pub(crate) mounts: Option>, pub(crate) features: Option>, diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index fb2e4f6cd6b31c..d3ee79cf888510 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -36,6 +36,11 @@ enum ConfigStatus { VariableParsed(DevContainer), } +enum ComposeUpBehavior { + Resume, + Create, +} + #[derive(Debug, Clone, Eq, PartialEq, Default)] pub(crate) struct DockerComposeResources { files: Vec, @@ -122,11 +127,11 @@ impl DevContainerManifest { let labels = vec![ ( "devcontainer.local_folder", - (self.local_project_directory.display()).to_string(), + normalize_label_path(&self.local_project_directory.display().to_string()), ), ( "devcontainer.config_file", - (self.config_file().display()).to_string(), + normalize_label_path(&self.config_file().display().to_string()), ), ]; labels @@ -137,7 +142,15 @@ impl DevContainerManifest { content: &str, ) -> Result { let mut value = deserialize_devcontainer_json_to_value(content)?; - let mut to_visit = vec![&mut value]; + self.substitute_nonremote_vars_in_value(&mut value)?; + Ok(value) + } + + fn substitute_nonremote_vars_in_value( + &self, + value: &mut serde_json_lenient::Value, + ) -> Result<(), DevContainerError> { + let mut to_visit = vec![value]; while let Some(value) = to_visit.pop() { use serde_json_lenient::Value; @@ -180,7 +193,7 @@ impl DevContainerManifest { } } - Ok(value) + Ok(()) } fn parse_nonremote_vars(&mut self) -> Result<(), DevContainerError> { @@ -595,7 +608,12 @@ impl DevContainerManifest { DevContainerError::ResourceFetchFailed })?; - let feature_manifest = FeatureManifest::new(consecutive_id, feature_dir, feature_json); + let feature_manifest = FeatureManifest::new( + consecutive_id, + feature_ref.to_string(), + feature_dir, + feature_json, + ); log::debug!("Prepared feature content for '{}'", feature_ref); @@ -775,6 +793,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true fn build_merged_resources( &self, base_image: DockerInspect, + image_tag: &str, ) -> Result { let dev_container = match &self.config { ConfigStatus::Deserialized(_) => { @@ -785,14 +804,78 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true } ConfigStatus::VariableParsed(dev_container) => dev_container, }; - let mut mounts = dev_container.mounts.clone().unwrap_or(Vec::new()); + let mut mounts = Vec::new(); + let mut cap_add = Vec::new(); + let mut security_opt = Vec::new(); + let mut privileged = false; + let mut init = false; + let mut metadata_entries = base_image + .config + .labels + .metadata + .clone() + .unwrap_or_default(); + for entry in &mut metadata_entries { + for value in entry.values_mut() { + self.substitute_nonremote_vars_in_value(value)?; + } + } - let mut feature_mounts = self.features.iter().flat_map(|f| f.mounts()).collect(); + for entry in &metadata_entries { + if let Some(serde_json_lenient::Value::Array(metadata_mounts)) = entry.get("mounts") { + for mount in metadata_mounts { + if let Some(mount) = mount_definition_from_metadata(mount) { + append_mount_with_target_override(&mut mounts, mount); + } + } + } + if entry.get("privileged").and_then(|value| value.as_bool()) == Some(true) { + privileged = true; + } + if entry.get("init").and_then(|value| value.as_bool()) == Some(true) { + init = true; + } + if let Some(serde_json_lenient::Value::Array(caps)) = entry.get("capAdd") { + for cap in caps.iter().filter_map(|cap| cap.as_str()) { + push_unique_string(&mut cap_add, cap); + } + } + if let Some(serde_json_lenient::Value::Array(opts)) = entry.get("securityOpt") { + for opt in opts.iter().filter_map(|opt| opt.as_str()) { + push_unique_string(&mut security_opt, opt); + } + } + } - mounts.append(&mut feature_mounts); + for feature in &self.features { + for mount in feature.mounts() { + append_mount_with_target_override(&mut mounts, mount); + } + if feature.privileged() { + privileged = true; + } + if feature.init() { + init = true; + } + for cap in feature.cap_add() { + push_unique_string(&mut cap_add, &cap); + } + for opt in feature.security_opt() { + push_unique_string(&mut security_opt, &opt); + } + } - let privileged = dev_container.privileged.unwrap_or(false) - || self.features.iter().any(|f| f.privileged()); + for mount in dev_container.mounts.clone().unwrap_or_default() { + append_mount_with_target_override(&mut mounts, mount); + } + privileged |= dev_container.privileged.unwrap_or(false); + init |= dev_container.init.unwrap_or(false); + for cap in dev_container.cap_add.clone().unwrap_or_default() { + push_unique_string(&mut cap_add, &cap); + } + for opt in dev_container.security_opt.clone().unwrap_or_default() { + push_unique_string(&mut security_opt, &opt); + } let entrypoint_script = if dev_container.override_command == Some(false) { None @@ -813,10 +896,31 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true Some(entrypoint_script_lines.join("\n").trim().to_string()) }; + let mut container_env = HashMap::new(); + for entry in &metadata_entries { + if let Some(serde_json_lenient::Value::Object(env_map)) = entry.get("containerEnv") { + for (k, v) in env_map { + if let Some(s) = v.as_str() { + container_env.insert(k.clone(), s.to_string()); + } + } + } + } + if let Some(config_env) = &dev_container.container_env { + for (k, v) in config_env { + container_env.insert(k.clone(), v.clone()); + } + } + Ok(DockerBuildResources { image: base_image, + image_tag: image_tag.to_string(), additional_mounts: mounts, + container_env, privileged, + init, + cap_add, + security_opt, entrypoint_script, }) } @@ -832,12 +936,32 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true match dev_container.build_type() { DevContainerBuildType::Image(base_image) => { let built_docker_image = self.build_docker_image().await?; + let has_features = dev_container + .features + .as_ref() + .is_some_and(|features| !features.is_empty()); + let features_image_tag = self + .features_build_info + .as_ref() + .map(|info| info.image_tag.as_str()); + let uid_base_image = if has_features { + features_image_tag.unwrap_or(&base_image) + } else { + &base_image + }; + let will_update_remote_user_uid = + self.should_update_remote_user_uid(&built_docker_image)?; let built_docker_image = self - .update_remote_user_uid(built_docker_image, &base_image) + .update_remote_user_uid(built_docker_image, uid_base_image) .await?; - let resources = self.build_merged_resources(built_docker_image)?; + let image_tag = if has_features || will_update_remote_user_uid { + features_image_tag.unwrap_or(&base_image) + } else { + &base_image + }; + let resources = self.build_merged_resources(built_docker_image, image_tag)?; Ok(DevContainerBuildResources::Docker(resources)) } DevContainerBuildType::Dockerfile(_) => { @@ -852,7 +976,8 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true .update_remote_user_uid(built_docker_image, &features_build_info.image_tag) .await?; - let resources = self.build_merged_resources(built_docker_image)?; + let resources = self + .build_merged_resources(built_docker_image, &features_build_info.image_tag)?; Ok(DevContainerBuildResources::Docker(resources)) } DevContainerBuildType::DockerCompose => { @@ -894,6 +1019,10 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true let remote_env = self.runtime_remote_env(&running_container.config.env_as_map()?)?; Ok(DevContainerUp { + started_at: running_container + .state + .as_ref() + .and_then(|state| state.started_at.clone()), container_id: running_container.id, remote_user, remote_workspace_folder: remote_workspace_folder.display().to_string(), @@ -1057,11 +1186,13 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true docker_compose_resources.files.push(config_location); let project_name = self.project_name().await?; + let compose_services = + compose_service_list(&main_service_name, dev_container.run_services.as_ref()); self.docker_client .docker_compose_build( &docker_compose_resources.files, &project_name, - dev_container.run_services.as_ref(), + compose_services.as_ref(), ) .await?; ( @@ -1154,11 +1285,13 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true docker_compose_resources.files.push(config_location); let project_name = self.project_name().await?; + let compose_services = + compose_service_list(&main_service_name, dev_container.run_services.as_ref()); self.docker_client .docker_compose_build( &docker_compose_resources.files, &project_name, - dev_container.run_services.as_ref(), + compose_services.as_ref(), ) .await?; @@ -1178,7 +1311,8 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true .update_remote_user_uid(built_service_image, built_service_image_tag) .await?; - let resources = self.build_merged_resources(built_service_image)?; + let resources = + self.build_merged_resources(built_service_image, built_service_image_tag)?; let network_mode = main_service.network_mode.as_ref(); let network_mode_service = network_mode.and_then(|mode| mode.strip_prefix("service:")); @@ -1226,17 +1360,45 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true ) -> Result { let mut runtime_labels = HashMap::new(); - if let Some(metadata) = &resources.image.config.labels.metadata { - let serialized_metadata = serde_json_lenient::to_string(metadata).map_err(|e| { - log::error!("Error serializing docker image metadata: {e}"); - DevContainerError::ContainerNotValid(resources.image.id.clone()) - })?; + { + let mut metadata_entries: Vec = Vec::new(); + + if let Some(image_metadata) = &resources.image.config.labels.metadata { + for entry in image_metadata { + if let Ok(val) = serde_json_lenient::to_value(entry) { + metadata_entries.push(val); + } + } + } + + for feature in &self.features { + let entry = feature.build_metadata_entry(); + if !entry.is_empty() { + metadata_entries.push(serde_json_lenient::Value::Object(entry)); + } + } + + if let Ok(substituted_json) = self.parse_nonremote_vars_for_content(&self.raw_config) { + let config_entry = build_devcontainer_metadata_entry(&substituted_json); + if !config_entry.is_empty() { + metadata_entries.push(serde_json_lenient::Value::Object(config_entry)); + } + } - runtime_labels.insert("devcontainer.metadata".to_string(), serialized_metadata); + if !metadata_entries.is_empty() { + let serialized = serde_json_lenient::to_string(&metadata_entries).map_err(|e| { + log::error!("Error serializing docker image metadata: {e}"); + DevContainerError::ContainerNotValid(resources.image.id.clone()) + })?; + runtime_labels.insert( + "devcontainer.metadata".to_string(), + escape_compose_interpolation(&serialized), + ); + } } for (k, v) in self.identifying_labels() { - runtime_labels.insert(k.to_string(), v.to_string()); + runtime_labels.insert(k.to_string(), escape_compose_interpolation(&v)); } let config_volumes: HashMap = resources @@ -1278,13 +1440,40 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true ] }); + let cap_add = if resources.cap_add.is_empty() { + None + } else { + Some(resources.cap_add) + }; + let security_opt = if resources.security_opt.is_empty() { + None + } else { + Some(resources.security_opt) + }; + + let environment = if resources.container_env.is_empty() { + None + } else { + Some( + resources + .container_env + .into_iter() + .map(|(key, value)| (key, escape_compose_interpolation(&value))) + .collect(), + ) + }; + let privileged = resources.privileged.then_some(true); + let init = resources.init.then_some(true); + let mut main_service = DockerComposeService { entrypoint, - cap_add: Some(vec!["SYS_PTRACE".to_string()]), - security_opt: Some(vec!["seccomp=unconfined".to_string()]), + cap_add, + security_opt, labels: Some(runtime_labels), volumes, - privileged: Some(resources.privileged), + privileged, + init, + environment, ..Default::default() }; // let mut extra_service_port_declarations: Vec<(String, DockerComposeService)> = Vec::new(); @@ -1452,6 +1641,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true Ok(image) } + #[cfg(target_os = "windows")] + fn should_update_remote_user_uid( + &self, + _image: &DockerInspect, + ) -> Result { + Ok(false) + } + #[cfg(target_os = "windows")] async fn update_remote_user_uid( &self, @@ -1460,27 +1657,37 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true ) -> Result { Ok(image) } + + #[cfg(not(target_os = "windows"))] + fn should_update_remote_user_uid( + &self, + image: &DockerInspect, + ) -> Result { + if self.features_build_info.is_none() { + return Ok(false); + } + if self.dev_container().update_remote_user_uid == Some(false) { + return Ok(false); + } + let remote_user = get_remote_user_from_config(image, self)?; + Ok(remote_user != "root" && !remote_user.chars().all(|c| c.is_ascii_digit())) + } + #[cfg(not(target_os = "windows"))] async fn update_remote_user_uid( &self, image: DockerInspect, base_image: &str, ) -> Result { - let dev_container = self.dev_container(); - let Some(features_build_info) = &self.features_build_info else { return Ok(image); }; - // updateRemoteUserUID defaults to true per the devcontainers spec - if dev_container.update_remote_user_uid == Some(false) { + if !self.should_update_remote_user_uid(&image)? { return Ok(image); } let remote_user = get_remote_user_from_config(&image, self)?; - if remote_user == "root" || remote_user.chars().all(|c| c.is_ascii_digit()) { - return Ok(image); - } let image_user = image .config @@ -1800,15 +2007,43 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true &self, resources: DockerComposeResources, ) -> Result { + self.start_docker_compose_services(&resources, ComposeUpBehavior::Create) + .await?; + + if let Some(docker_ps) = self.check_for_existing_container().await? { + log::debug!("Found newly created dev container"); + return self.docker_client.inspect(&docker_ps.id).await; + } + + log::error!("Could not find existing container after docker compose up"); + + Err(DevContainerError::DevContainerParseFailed) + } + + async fn start_docker_compose_services( + &self, + resources: &DockerComposeResources, + behavior: ComposeUpBehavior, + ) -> Result<(), DevContainerError> { let mut command = Command::new(self.docker_client.docker_cli()); let project_name = self.project_name().await?; + let compose_services = match self.dev_container().run_services.as_ref() { + Some(run_services) => { + let (main_service_name, _) = find_primary_service(&resources, self)?; + compose_service_list(&main_service_name, Some(run_services)) + } + None => None, + }; command.args(&["compose", "--project-name", &project_name]); - for docker_compose_file in resources.files { + for docker_compose_file in &resources.files { command.args(&["-f", &docker_compose_file.display().to_string()]); } command.args(&["up", "-d"]); - if let Some(run_services) = self.dev_container().run_services.as_ref() { - command.args(run_services); + if matches!(behavior, ComposeUpBehavior::Resume) { + command.arg("--no-recreate"); + } + if let Some(services) = compose_services.as_ref() { + command.args(services); } let output = self @@ -1828,14 +2063,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true )); } - if let Some(docker_ps) = self.check_for_existing_container().await? { - log::debug!("Found newly created dev container"); - return self.docker_client.inspect(&docker_ps.id).await; - } - - log::error!("Could not find existing container after docker compose up"); - - Err(DevContainerError::DevContainerParseFailed) + Ok(()) } async fn run_docker_image( @@ -1942,6 +2170,9 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true if build_resources.privileged { command.arg("--privileged"); } + if build_resources.init { + command.arg("--init"); + } let run_args = match &self.dev_container().run_args { Some(run_args) => run_args, @@ -1987,16 +2218,48 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true command.arg(format!("{}={}", key, val)); } - if let Some(metadata) = &build_resources.image.config.labels.metadata { - let serialized_metadata = serde_json_lenient::to_string(metadata).map_err(|e| { - log::error!("Problem serializing image metadata: {e}"); - DevContainerError::ContainerNotValid(build_resources.image.id.clone()) - })?; - command.arg("-l"); - command.arg(format!( - "{}={}", - "devcontainer.metadata", serialized_metadata - )); + { + let mut metadata_entries: Vec = Vec::new(); + + if let Some(image_metadata) = &build_resources.image.config.labels.metadata { + for entry in image_metadata { + if let Ok(val) = serde_json_lenient::to_value(entry) { + metadata_entries.push(val); + } + } + } + + for feature in &self.features { + let entry = feature.build_metadata_entry(); + if !entry.is_empty() { + metadata_entries.push(serde_json_lenient::Value::Object(entry)); + } + } + + if let Ok(substituted_json) = self.parse_nonremote_vars_for_content(&self.raw_config) { + let config_entry = build_devcontainer_metadata_entry(&substituted_json); + if !config_entry.is_empty() { + metadata_entries.push(serde_json_lenient::Value::Object(config_entry)); + } + } + + if !metadata_entries.is_empty() { + let serialized = serde_json_lenient::to_string(&metadata_entries).map_err(|e| { + log::error!("Problem serializing metadata: {e}"); + DevContainerError::ContainerNotValid(build_resources.image.id.clone()) + })?; + command.arg("-l"); + command.arg(format!("devcontainer.metadata={serialized}")); + } + } + + for cap in &build_resources.cap_add { + command.arg("--cap-add"); + command.arg(cap); + } + for opt in &build_resources.security_opt { + command.arg("--security-opt"); + command.arg(opt); } if let Some(forward_ports) = &self.dev_container().forward_ports { @@ -2012,15 +2275,20 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true command.arg(app_port); } + for (key, value) in &build_resources.container_env { + command.arg("-e"); + command.arg(format!("{key}={value}")); + } + if let Some(entrypoint_script) = build_resources.entrypoint_script { command.arg("--entrypoint"); command.arg("/bin/sh"); - command.arg(&build_resources.image.id); + command.arg(&build_resources.image_tag); command.arg("-c"); command.arg(entrypoint_script); command.arg("-"); } else { - command.arg(&build_resources.image.id); + command.arg(&build_resources.image_tag); } Ok(command) @@ -2045,7 +2313,8 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true let devcontainer_up = self.run_dev_container(build_resources).await?; - self.run_remote_scripts(&devcontainer_up, true).await?; + self.run_remote_scripts(&devcontainer_up, true, true) + .await?; Ok(devcontainer_up) } @@ -2054,6 +2323,7 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true &self, devcontainer_up: &DevContainerUp, new_container: bool, + container_started: bool, ) -> Result<(), DevContainerError> { let ConfigStatus::VariableParsed(config) = &self.config else { log::error!("Config not yet parsed, cannot proceed with remote scripts"); @@ -2105,8 +2375,27 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true .await?; } } - if let Some(post_start_command) = &config.post_start_command { - for (command_name, command) in post_start_command.script_commands() { + } + if let Some(post_start_command) = &config.post_start_command { + let script_commands = post_start_command.script_commands(); + if let Some(started_at) = &devcontainer_up.started_at { + if !script_commands.is_empty() { + for command_name in script_commands.keys() { + log::debug!("Running post start command {command_name}"); + } + let script = post_start_marker_script(started_at, script_commands); + self.docker_client + .run_docker_exec( + &devcontainer_up.container_id, + &remote_folder, + &devcontainer_up.remote_user, + &devcontainer_up.remote_env, + Command::new(script), + ) + .await?; + } + } else if container_started { + for (command_name, command) in script_commands { log::debug!("Running post start command {command_name}"); self.docker_client .run_docker_exec( @@ -2161,11 +2450,20 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true if let Some(docker_ps) = self.check_for_existing_container().await? { log::debug!("Dev container already found. Proceeding with it"); - let docker_inspect = self.docker_client.inspect(&docker_ps.id).await?; + let mut docker_inspect = self.docker_client.inspect(&docker_ps.id).await?; - if !docker_inspect.is_running() { + let container_started = !docker_inspect.is_running(); + if container_started { log::debug!("Container not running. Will attempt to start, and then proceed"); - self.docker_client.start_container(&docker_ps.id).await?; + match self.dev_container().build_type() { + DevContainerBuildType::DockerCompose => { + let resources = self.docker_compose_manifest().await?; + self.start_docker_compose_services(&resources, ComposeUpBehavior::Resume) + .await? + } + _ => self.docker_client.start_container(&docker_ps.id).await?, + } + docker_inspect = self.docker_client.inspect(&docker_ps.id).await?; } let remote_user = get_remote_user_from_config(&docker_inspect, self)?; @@ -2175,6 +2473,10 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true let remote_env = self.runtime_remote_env(&docker_inspect.config.env_as_map()?)?; let dev_container_up = DevContainerUp { + started_at: docker_inspect + .state + .as_ref() + .and_then(|state| state.started_at.clone()), container_id: docker_ps.id, remote_user: remote_user, remote_workspace_folder: remote_folder.display().to_string(), @@ -2182,7 +2484,8 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true remote_env, }; - self.run_remote_scripts(&dev_container_up, false).await?; + self.run_remote_scripts(&dev_container_up, false, container_started) + .await?; Ok(Some(dev_container_up)) } else { @@ -2445,8 +2748,13 @@ pub(crate) async fn spawn_dev_container( #[derive(Debug)] struct DockerBuildResources { image: DockerInspect, + image_tag: String, additional_mounts: Vec, + container_env: HashMap, privileged: bool, + init: bool, + cap_add: Vec, + security_opt: Vec, entrypoint_script: Option, } @@ -2494,6 +2802,24 @@ fn find_primary_service( } } +/// The compose service list for `build` and `up`. Per the devcontainer spec +/// `runServices` are *additional*, so the primary `service` is always prepended. +/// `None` (no `runServices`) lets compose operate every service. +fn compose_service_list( + main_service_name: &str, + run_services: Option<&Vec>, +) -> Option> { + let run_services = run_services?; + let mut services = Vec::with_capacity(run_services.len() + 1); + services.push(main_service_name.to_string()); + for service in run_services { + if service != main_service_name { + services.push(service.clone()); + } + } + Some(services) +} + /// Resolves a compose service's dockerfile path according to the Docker Compose spec: /// `dockerfile` is relative to the build `context`, and `context` is relative to /// the compose file's directory. @@ -2793,54 +3119,77 @@ chmod +x ./install.sh Ok(script) } +struct ParsedFromLine<'a> { + image: &'a str, + alias: Option<&'a str>, +} + +/// Parses a `FROM` instruction into its image and optional stage alias, +/// skipping flags like `--platform=...`. Returns `None` for non-`FROM` lines. +fn parse_from_line(line: &str) -> Option> { + let mut tokens = line.split_whitespace(); + if !tokens.next()?.eq_ignore_ascii_case("FROM") { + return None; + } + let image = tokens.find(|token| !token.starts_with("--"))?; + let alias = match (tokens.next(), tokens.next()) { + (Some(keyword), Some(alias)) if keyword.eq_ignore_ascii_case("as") => Some(alias), + _ => None, + }; + Some(ParsedFromLine { image, alias }) +} + fn dockerfile_inject_alias( dockerfile_content: &str, alias: &str, build_target: Option, ) -> String { - let from_lines: Vec<(usize, &str)> = dockerfile_content + let from_lines: Vec<(usize, ParsedFromLine)> = dockerfile_content .lines() .enumerate() - .filter(|(_, line)| line.starts_with("FROM")) + .filter_map(|(index, line)| parse_from_line(line).map(|parsed| (index, parsed))) .collect(); let target_entry = match &build_target { - Some(target) => from_lines.iter().rfind(|(_, line)| { - let parts: Vec<&str> = line.split_whitespace().collect(); - parts.len() >= 3 - && parts - .get(parts.len() - 2) - .map_or(false, |p| p.eq_ignore_ascii_case("as")) - && parts - .last() - .map_or(false, |p| p.eq_ignore_ascii_case(target)) + Some(target) => from_lines.iter().rfind(|(_, parsed)| { + parsed + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(target)) }), None => from_lines.last(), }; - let Some(&(line_idx, from_line)) = target_entry else { + let Some((line_idx, parsed)) = target_entry else { + match &build_target { + Some(target) => log::warn!( + "Build target stage {target:?} not found in Dockerfile; leaving it unmodified" + ), + None => log::warn!("No FROM instruction found in Dockerfile; leaving it unmodified"), + } return dockerfile_content.to_string(); }; - let parts: Vec<&str> = from_line.split_whitespace().collect(); - let has_alias = parts.len() >= 3 - && parts - .get(parts.len() - 2) - .map_or(false, |p| p.eq_ignore_ascii_case("as")); - - if has_alias { - let Some(existing_alias) = parts.last() else { - return dockerfile_content.to_string(); - }; + if let Some(existing_alias) = parsed.alias { format!("{dockerfile_content}\nFROM {existing_alias} AS {alias}") } else { let lines: Vec<&str> = dockerfile_content.lines().collect(); + // Appending ` AS {alias}` to a line ending in a `\` continuation would + // corrupt the instruction, so leave the Dockerfile unmodified. + if lines + .get(*line_idx) + .is_some_and(|line| line.trim_end().ends_with('\\')) + { + log::warn!( + "FROM instruction spans multiple lines via `\\` continuation; cannot inject stage alias, leaving Dockerfile unmodified" + ); + return dockerfile_content.to_string(); + } let mut result = String::new(); for (i, line) in lines.iter().enumerate() { if i > 0 { result.push('\n'); } - if i == line_idx { + if i == *line_idx { result.push_str(&format!("{line} AS {alias}")); } else { result.push_str(line); @@ -2854,29 +3203,36 @@ fn dockerfile_inject_alias( } fn image_from_dockerfile(dockerfile_contents: String, target: &Option) -> Option { - dockerfile_contents + let stages: Vec = dockerfile_contents .lines() - .filter(|line| line.starts_with("FROM")) - .rfind(|from_line| match &target { - Some(target) => { - let parts = from_line.split(' ').collect::>(); - if parts.len() >= 3 - && parts.get(parts.len() - 2).unwrap_or(&"").to_lowercase() == "as" - { - parts.last().unwrap_or(&"").to_lowercase() == target.to_lowercase() - } else { - false - } - } - None => true, - }) - .and_then(|from_line| { - from_line - .split(' ') - .collect::>() - .get(1) - .map(|s| s.to_string()) - }) + .filter_map(parse_from_line) + .collect(); + + let start_index = match target { + Some(target) => stages.iter().rposition(|stage| { + stage + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(target)) + })?, + None => stages.len().checked_sub(1)?, + }; + + // Follow alias chains (`FROM base AS development`) to a concrete image. + // Docker only resolves names to stages defined earlier in the file, so + // resolving strictly backwards is correct and cannot cycle. + let mut index = start_index; + loop { + let image = stages.get(index)?.image; + let previous_stage = stages.get(..index)?.iter().rposition(|stage| { + stage + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(image)) + }); + match previous_stage { + Some(previous_index) => index = previous_index, + None => return Some(image.to_string()), + } + } } fn get_remote_user_from_config( @@ -2931,6 +3287,180 @@ fn get_container_user_from_config( Ok("root".to_string()) } +fn push_unique_string(values: &mut Vec, value: &str) { + if !values.iter().any(|existing| existing == value) { + values.push(value.to_string()); + } +} + +fn append_mount_with_target_override(mounts: &mut Vec, mount: MountDefinition) { + mounts.retain(|existing| existing.target != mount.target); + mounts.push(mount); +} + +fn mount_definition_from_metadata(value: &serde_json_lenient::Value) -> Option { + match value { + serde_json_lenient::Value::String(value) => parse_mount_definition_string(value), + serde_json_lenient::Value::Object(object) => { + let source = object + .get("source") + .or_else(|| object.get("src")) + .and_then(|value| value.as_str()) + .map(str::to_string); + let target = object + .get("target") + .or_else(|| object.get("dst")) + .or_else(|| object.get("destination")) + .and_then(|value| value.as_str())? + .to_string(); + let mount_type = object + .get("type") + .and_then(|value| value.as_str()) + .map(str::to_string); + Some(MountDefinition { + source, + target, + mount_type, + }) + } + _ => None, + } +} + +fn parse_mount_definition_string(value: &str) -> Option { + let mut source = None; + let mut target = None; + let mut mount_type = None; + + for part in value.split(',') { + let Some((key, value)) = part.trim().split_once('=') else { + continue; + }; + match key.trim() { + "source" | "src" => source = Some(value.trim().to_string()), + "target" | "dst" | "destination" => target = Some(value.trim().to_string()), + "type" => mount_type = Some(value.trim().to_string()), + _ => {} + } + } + + target.map(|target| MountDefinition { + source, + target, + mount_type, + }) +} + +fn escape_compose_interpolation(value: &str) -> String { + value.replace('$', "$$") +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn command_to_shell_string(command: &Command) -> String { + let mut command_parts = vec![command.get_program().display().to_string()]; + command_parts.extend(command.get_args().map(|arg| arg.display().to_string())); + command_parts.join(" ") +} + +fn post_start_marker_script(started_at: &str, script_commands: HashMap) -> String { + let started_at = shell_single_quote(started_at); + let mut script = format!( + r#"user_id="$(id -u)" +home_directory="${{HOME:-}}" +if [ -z "$home_directory" ]; then + home_directory="$( (command -v getent >/dev/null 2>&1 && getent passwd "$user_id" || grep -E "^[^:]*:[^:]*:${{user_id}}:" /etc/passwd || true) | head -n 1 | cut -d: -f6)" +fi +if [ -z "$home_directory" ]; then + home_directory=/root +fi +marker_directory="$home_directory/.devcontainer" +marker="$marker_directory/.postStartCommandMarker" +started_at={started_at} +marker_available=false +if mkdir -p "$marker_directory"; then + previous_started_at="$(cat "$marker" 2>/dev/null || true)" + if [ "$previous_started_at" = "$started_at" ]; then + exit 0 + fi + marker_available=true +fi +"#, + ); + for command in script_commands.into_values() { + script.push_str(&command_to_shell_string(&command)); + script.push_str("\ncommand_status=$?\n"); + script.push_str("[ \"$command_status\" -eq 0 ] || exit \"$command_status\"\n"); + } + script.push_str( + r#"if [ "$marker_available" = "true" ]; then + printf '%s' "$started_at" > "$marker" || exit $? +fi"#, + ); + script +} + +fn build_devcontainer_metadata_entry( + raw_json: &serde_json_lenient::Value, +) -> serde_json_lenient::Map { + use serde_json_lenient::Value; + static METADATA_PROPERTIES: &[&str] = &[ + "onCreateCommand", + "updateContentCommand", + "postCreateCommand", + "postStartCommand", + "postAttachCommand", + "waitFor", + "customizations", + "mounts", + "containerEnv", + "containerUser", + "init", + "privileged", + "capAdd", + "securityOpt", + "remoteUser", + "userEnvProbe", + "remoteEnv", + "overrideCommand", + "portsAttributes", + "otherPortsAttributes", + "forwardPorts", + "shutdownAction", + "updateRemoteUserUID", + "hostRequirements", + ]; + + let Value::Object(full) = raw_json else { + return serde_json_lenient::Map::new(); + }; + + full.iter() + .filter(|(key, value)| METADATA_PROPERTIES.contains(&key.as_str()) && !value.is_null()) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +fn normalize_label_path(path: &str) -> String { + #[cfg(not(target_os = "windows"))] + { + path.to_string() + } + #[cfg(target_os = "windows")] + { + let normalized = path.replace('/', "\\"); + if normalized.len() >= 2 && normalized.as_bytes()[1] == b':' { + let mut result = normalized[..1].to_lowercase(); + result.push_str(&normalized[1..]); + result + } else { + normalized + } + } +} + #[cfg(test)] mod test { use std::{ @@ -2941,10 +3471,17 @@ mod test { sync::{Arc, Mutex}, }; + #[cfg(not(target_os = "windows"))] + use std::{ + fs as std_fs, + time::{SystemTime, UNIX_EPOCH}, + }; + use async_trait::async_trait; use fs::{FakeFs, Fs}; use gpui::{AppContext, TestAppContext}; use http_client::{AsyncBody, FakeHttpClient, HttpClient}; + use indoc::indoc; use project::{ ProjectEnvironment, worktree_store::{WorktreeIdCounter, WorktreeStore}, @@ -2952,16 +3489,15 @@ mod test { use serde_json_lenient::Value; use util::{command::Command, paths::SanitizedPath}; - #[cfg(not(target_os = "windows"))] - use crate::docker::DockerComposeServicePort; use crate::{ DevContainerConfig, DevContainerContext, command_json::CommandRunner, - devcontainer_api::DevContainerError, + devcontainer_api::{DevContainerError, DevContainerUp}, devcontainer_json::MountDefinition, devcontainer_manifest::{ ConfigStatus, DevContainerManifest, DockerBuildResources, DockerComposeResources, - DockerInspect, extract_feature_id, find_primary_service, get_remote_user_from_config, + DockerInspect, dockerfile_inject_alias, escape_compose_interpolation, + extract_feature_id, find_primary_service, get_remote_user_from_config, image_from_dockerfile, is_local_feature_ref, resolve_compose_dockerfile, }, docker::{ @@ -3227,8 +3763,13 @@ mod test { mounts: None, state: None, }, + image_tag: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(), additional_mounts: vec![], + container_env: HashMap::new(), privileged: false, + init: false, + cap_add: vec!["SYS_PTRACE".to_string()], + security_opt: vec!["seccomp=unconfined".to_string()], entrypoint_script: Some("echo Container started\n trap \"exit 0\" 15\n exec \"$@\"\n while sleep 1 & wait $!; do :; done".to_string()), }; let docker_run_command = devcontainer_manifest.create_docker_run_command(build_resources); @@ -3237,10 +3778,17 @@ mod test { let docker_run_command = docker_run_command.expect("ok"); assert_eq!(docker_run_command.get_program(), "docker"); - let expected_config_file_label = PathBuf::from(TEST_PROJECT_PATH) + let expected_local_folder_label = format!( + "devcontainer.local_folder={}", + super::normalize_label_path(TEST_PROJECT_PATH) + ); + let expected_config_file_path = PathBuf::from(TEST_PROJECT_PATH) .join(".devcontainer") .join("devcontainer.json"); - let expected_config_file_label = expected_config_file_label.display(); + let expected_config_file_label = format!( + "devcontainer.config_file={}", + super::normalize_label_path(&expected_config_file_path.display().to_string()) + ); assert_eq!( docker_run_command.get_args().collect::>(), vec![ @@ -3252,11 +3800,13 @@ mod test { "type=bind,source={TEST_PROJECT_PATH},target=/workspaces/project,consistency=cached" )), OsStr::new("-l"), - OsStr::new(&format!("devcontainer.local_folder={TEST_PROJECT_PATH}")), + OsStr::new(&expected_local_folder_label), OsStr::new("-l"), - OsStr::new(&format!( - "devcontainer.config_file={expected_config_file_label}" - )), + OsStr::new(&expected_config_file_label), + OsStr::new("--cap-add"), + OsStr::new("SYS_PTRACE"), + OsStr::new("--security-opt"), + OsStr::new("seccomp=unconfined"), OsStr::new("--entrypoint"), OsStr::new("/bin/sh"), OsStr::new("mcr.microsoft.com/devcontainers/base:ubuntu"), @@ -3304,7 +3854,7 @@ mod test { }; let resources = devcontainer_manifest - .build_merged_resources(base_image) + .build_merged_resources(base_image, "mcr.microsoft.com/devcontainers/base:ubuntu") .unwrap(); assert!( resources.entrypoint_script.is_none(), @@ -3326,14 +3876,310 @@ mod test { } #[gpui::test] - async fn should_find_primary_service_in_docker_compose(cx: &mut TestAppContext) { - // State where service not defined in dev container - let (_, given_dev_container) = init_default_devcontainer_manifest(cx, "{}").await.unwrap(); - let given_docker_compose_config = DockerComposeResources { - config: DockerComposeConfig { - name: Some("devcontainers".to_string()), - services: HashMap::new(), - ..Default::default() + async fn should_substitute_nonremote_variables_in_image_metadata(cx: &mut TestAppContext) { + let (_, mut devcontainer_manifest) = init_default_devcontainer_manifest( + cx, + r#"{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu" + }"#, + ) + .await + .unwrap(); + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let metadata = serde_json_lenient::from_str( + r#"[ + { + "id": "ghcr.io/devcontainers/features/docker-in-docker:2", + "mounts": [ + { + "source": "dind-var-lib-docker-${devcontainerId}", + "target": "/var/lib/docker", + "type": "volume" + } + ] + }, + { + "containerEnv": { + "PATH": "/custom/bin:${PATH}" + } + } + ]"#, + ) + .unwrap(); + let base_image = DockerInspect { + id: "base-image-id".to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(metadata), + }, + image_user: None, + env: vec!["PATH=/usr/local/bin:/usr/bin".to_string()], + }, + mounts: None, + state: None, + }; + + let resources = devcontainer_manifest + .build_merged_resources(base_image, "mcr.microsoft.com/devcontainers/base:ubuntu") + .unwrap(); + + assert_eq!( + resources.additional_mounts, + vec![MountDefinition { + source: Some(format!( + "dind-var-lib-docker-{}", + devcontainer_manifest.devcontainer_id() + )), + target: "/var/lib/docker".to_string(), + mount_type: Some("volume".to_string()), + }] + ); + assert_eq!( + resources.container_env.get("PATH").map(String::as_str), + Some("/custom/bin:${PATH}") + ); + let persisted_mount_source = resources + .image + .config + .labels + .metadata + .as_ref() + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("mounts")) + .and_then(|mounts| mounts.as_array()) + .and_then(|mounts| mounts.first()) + .and_then(|mount| mount.get("source")) + .and_then(|source| source.as_str()); + assert_eq!( + persisted_mount_source, + Some("dind-var-lib-docker-${devcontainerId}") + ); + } + + #[gpui::test] + async fn should_use_devcontainer_cli_post_start_marker_for_started_container( + cx: &mut TestAppContext, + ) { + let (test_dependencies, mut devcontainer_manifest) = init_default_devcontainer_manifest( + cx, + r#"{ + "image": "test_image:latest", + "postStartCommand": "echo post-start", + "postAttachCommand": "echo post-attach" + }"#, + ) + .await + .unwrap(); + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let devcontainer_up = DevContainerUp { + started_at: Some("2026-06-23T10:00:00Z".to_string()), + container_id: "container".to_string(), + remote_user: "root".to_string(), + remote_workspace_folder: "/workspaces/project".to_string(), + extension_ids: Vec::new(), + remote_env: HashMap::new(), + }; + + devcontainer_manifest + .run_remote_scripts(&devcontainer_up, false, false) + .await + .unwrap(); + + let docker_exec_commands = test_dependencies + .docker + .exec_commands_recorded + .lock() + .unwrap(); + assert_eq!(docker_exec_commands.len(), 2); + let post_start_script = docker_exec_commands[0] + ._inner_command + .get_program() + .to_string_lossy(); + assert!(post_start_script.contains("marker_directory=\"$home_directory/.devcontainer\"")); + assert!(post_start_script.contains("marker=\"$marker_directory/.postStartCommandMarker\"")); + assert!(!post_start_script.contains("/tmp/zed-devcontainer")); + assert!(post_start_script.contains( + "echo post-start\ncommand_status=$?\n[ \"$command_status\" -eq 0 ] || exit \"$command_status\"" + )); + assert!( + post_start_script.find("echo post-start").unwrap() + < post_start_script.find("printf '%s'").unwrap(), + "postStartCommand marker must be written only after the command succeeds" + ); + assert_eq!(docker_exec_commands[1]._inner_command.get_program(), "echo"); + assert_eq!( + docker_exec_commands[1] + ._inner_command + .get_args() + .collect::>(), + vec![OsStr::new("post-attach")] + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn post_start_marker_script_runs_command_when_marker_directory_cannot_be_created() { + let mut command = Command::new("echo"); + command.arg("post-start"); + let script = super::post_start_marker_script( + "2026-06-23T10:00:00Z", + HashMap::from([("default".to_string(), command)]), + ); + + let output = run_shell_script(&script, Path::new("/dev/null")); + + assert!( + output.status.success(), + "script should not fail when marker directory cannot be created: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "post-start\n"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn post_start_marker_script_accepts_background_command() { + let home_directory = temporary_home_directory("post-start-background"); + let started_at = "2026-06-23T10:00:00Z"; + let marker = home_directory + .join(".devcontainer") + .join(".postStartCommandMarker"); + let mut command = Command::new("true"); + command.arg("&"); + let script = super::post_start_marker_script( + started_at, + HashMap::from([("default".to_string(), command)]), + ); + + let output = run_shell_script(&script, &home_directory); + + assert!( + output.status.success(), + "background command should be accepted: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + std_fs::read_to_string(&marker).expect("marker should be written"), + started_at + ); + + std_fs::remove_dir_all(home_directory).expect("temporary home should be removed"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn post_start_marker_script_marks_only_after_success() { + let home_directory = temporary_home_directory("post-start-success"); + let started_at = "2026-06-23T10:00:00Z"; + let marker = home_directory + .join(".devcontainer") + .join(".postStartCommandMarker"); + let mut command = Command::new("echo"); + command.arg("post-start"); + let script = super::post_start_marker_script( + started_at, + HashMap::from([("default".to_string(), command)]), + ); + + let output = run_shell_script(&script, &home_directory); + assert!( + output.status.success(), + "script should run successfully: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "post-start\n"); + assert_eq!( + std_fs::read_to_string(&marker).expect("marker should be written"), + started_at + ); + + let output = run_shell_script(&script, &home_directory); + assert!( + output.status.success(), + "script should skip successfully: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), ""); + + std_fs::remove_dir_all(home_directory).expect("temporary home should be removed"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn post_start_marker_script_surfaces_marker_write_failure() { + let home_directory = temporary_home_directory("post-start-marker-write-failure"); + let marker = home_directory + .join(".devcontainer") + .join(".postStartCommandMarker"); + std_fs::create_dir_all(&marker).expect("marker path should be a directory"); + let mut command = Command::new("echo"); + command.arg("post-start"); + let script = super::post_start_marker_script( + "2026-06-23T10:00:00Z", + HashMap::from([("default".to_string(), command)]), + ); + + let output = run_shell_script(&script, &home_directory); + + assert!(!output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "post-start\n"); + assert!(marker.is_dir()); + + std_fs::remove_dir_all(home_directory).expect("temporary home should be removed"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn post_start_marker_script_does_not_mark_after_failure() { + let home_directory = temporary_home_directory("post-start-failure"); + let marker = home_directory + .join(".devcontainer") + .join(".postStartCommandMarker"); + let script = super::post_start_marker_script( + "2026-06-23T10:00:00Z", + HashMap::from([("default".to_string(), Command::new("false"))]), + ); + + let output = run_shell_script(&script, &home_directory); + + assert!(!output.status.success()); + assert!(!marker.exists()); + + std_fs::remove_dir_all(home_directory).expect("temporary home should be removed"); + } + + #[cfg(not(target_os = "windows"))] + fn run_shell_script(script: &str, home_directory: &Path) -> Output { + let mut command = Command::new("sh"); + command.arg("-c").arg(script).env("HOME", home_directory); + gpui::block_on(command.output()).expect("shell should run postStartCommand marker script") + } + + #[cfg(not(target_os = "windows"))] + fn temporary_home_directory(prefix: &str) -> PathBuf { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "zed-devcontainer-{prefix}-{}-{now}", + std::process::id() + )); + std_fs::create_dir_all(&path).expect("temporary home should be created"); + path + } + + #[gpui::test] + async fn should_find_primary_service_in_docker_compose(cx: &mut TestAppContext) { + // State where service not defined in dev container + let (_, given_dev_container) = init_default_devcontainer_manifest(cx, "{}").await.unwrap(); + let given_docker_compose_config = DockerComposeResources { + config: DockerComposeConfig { + name: Some("devcontainers".to_string()), + services: HashMap::new(), + ..Default::default() }, ..Default::default() }; @@ -3515,6 +4361,15 @@ mod test { ); } + #[test] + fn escape_compose_interpolation_uses_compose_dollar_escape() { + assert_eq!(escape_compose_interpolation("pa$word"), "pa$$word"); + assert_eq!( + escape_compose_interpolation("${containerEnv:PATH}"), + "$${containerEnv:PATH}" + ); + } + #[test] fn test_replace_environment_variables() { let replaced = DevContainerManifest::replace_environment_variables( @@ -3945,42 +4800,52 @@ chmod +x ./install.sh .find(|c| c.args.get(0).is_some_and(|a| a == "run")) .expect("found"); + let args = &docker_run_command.args; + + assert!(args.contains(&"--cap-add".to_string())); + assert!(args.contains(&"SYS_PTRACE".to_string())); + assert!(args.contains(&"--security-opt".to_string())); + assert!(args.contains(&"seccomp=unconfined".to_string())); + + let metadata_arg = args + .iter() + .find(|a| a.starts_with("devcontainer.metadata=")) + .unwrap(); + let metadata_json = &metadata_arg["devcontainer.metadata=".len()..]; + let metadata: Vec = + serde_json_lenient::from_str(metadata_json).unwrap(); + assert_eq!(metadata.len(), 4); + assert_eq!(metadata[0]["remoteUser"], "node"); assert_eq!( - docker_run_command.args, - vec![ - "run".to_string(), - "--privileged".to_string(), - "--cap-add=SYS_PTRACE".to_string(), - "--sig-proxy=true".to_string(), - "-d".to_string(), - "--mount".to_string(), - "type=bind,source=/path/to/local/project,target=/workspace2,consistency=cached".to_string(), - "--mount".to_string(), - "type=volume,source=dev-containers-cli-bashhistory,target=/home/node/commandhistory,consistency=cached".to_string(), - "--mount".to_string(), - "type=volume,source=dind-var-lib-docker-42dad4b4ca7b8ced,target=/var/lib/docker,consistency=cached".to_string(), - "-l".to_string(), - "devcontainer.local_folder=/path/to/local/project".to_string(), - "-l".to_string(), - "devcontainer.config_file=/path/to/local/project/.devcontainer/devcontainer.json".to_string(), - "-l".to_string(), - "devcontainer.metadata=[{\"remoteUser\":\"node\"}]".to_string(), - "-p".to_string(), - "8082:8082".to_string(), - "-p".to_string(), - "8083:8083".to_string(), - "-p".to_string(), - "8084:8084".to_string(), - "-p".to_string(), - "8085:8086".to_string(), - "--entrypoint".to_string(), - "/bin/sh".to_string(), - "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc105".to_string(), - "-c".to_string(), - "echo Container started\ntrap \"exit 0\" 15\n/usr/local/share/docker-init.sh\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done".to_string(), - "-".to_string() - ] + metadata[1]["id"], + "ghcr.io/devcontainers/features/docker-in-docker:2" + ); + assert_eq!(metadata[1]["privileged"], true); + assert!(metadata[1].get("containerEnv").is_none()); + assert_eq!(metadata[2]["id"], "ghcr.io/devcontainers/features/go:1"); + assert!(metadata[2].get("containerEnv").is_none()); + assert!( + metadata[2]["capAdd"] + .as_array() + .unwrap() + .contains(&serde_json_lenient::Value::String("SYS_PTRACE".to_string())) ); + assert_eq!(metadata[3]["remoteUser"], "node"); + assert!(metadata[3].get("onCreateCommand").is_some()); + + let env_args: Vec<&String> = args + .iter() + .enumerate() + .filter(|(_, a)| *a == "-e") + .filter_map(|(i, _)| args.get(i + 1)) + .collect(); + assert!(env_args.contains(&&"VARIABLE_VALUE=value".to_string())); + assert!(!env_args.contains(&&"DOCKER_BUILDKIT=1".to_string())); + assert!(!env_args.contains(&&"GOPATH=/go".to_string())); + assert!(!env_args.contains(&&"GOROOT=/usr/local/go".to_string())); + + let image_arg = args.iter().find(|a| a.contains("-features")).unwrap(); + assert!(image_arg.ends_with("-features")); let docker_exec_commands = test_dependencies .docker @@ -4275,74 +5140,184 @@ ENV DOCKER_BUILDKIT=1 .expect("to be found"); let runtime_override = test_dependencies.fs.load(runtime_override).await.unwrap(); - let expected_runtime_override = DockerComposeConfig { - name: None, - services: HashMap::from([ - ( - "app".to_string(), - DockerComposeService { - entrypoint: Some(vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo Container started\ntrap \"exit 0\" 15\n/usr/local/share/docker-init.sh\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done".to_string(), - "-".to_string(), - ]), - cap_add: Some(vec!["SYS_PTRACE".to_string()]), - security_opt: Some(vec!["seccomp=unconfined".to_string()]), - privileged: Some(true), - labels: Some(HashMap::from([ - ("devcontainer.metadata".to_string(), "[{\"remoteUser\":\"vscode\"}]".to_string()), - ("devcontainer.local_folder".to_string(), "/path/to/local/project".to_string()), - ("devcontainer.config_file".to_string(), "/path/to/local/project/.devcontainer/devcontainer.json".to_string()) - ])), - volumes: vec![ - MountDefinition { - source: Some("dind-var-lib-docker-42dad4b4ca7b8ced".to_string()), - target: "/var/lib/docker".to_string(), - mount_type: Some("volume".to_string()) - } - ], - ..Default::default() - }, - ), - ( - "db".to_string(), - DockerComposeService { - ports: vec![ - DockerComposeServicePort { - target: "8083".to_string(), - published: "8083".to_string(), - ..Default::default() - }, - DockerComposeServicePort { - target: "5432".to_string(), - published: "5432".to_string(), - ..Default::default() - }, - DockerComposeServicePort { - target: "1234".to_string(), - published: "1234".to_string(), - ..Default::default() - }, - ], - ..Default::default() - }, - ), - ]), - volumes: HashMap::from([( - "dind-var-lib-docker-42dad4b4ca7b8ced".to_string(), - DockerComposeVolume { - name: Some("dind-var-lib-docker-42dad4b4ca7b8ced".to_string()), - }, - )]), - }; + let runtime_config: DockerComposeConfig = + serde_json_lenient::from_str(&runtime_override).unwrap(); + let app_service = runtime_config.services.get("app").expect("app service"); + assert_eq!(app_service.cap_add, Some(vec!["SYS_PTRACE".to_string()])); assert_eq!( - serde_json_lenient::from_str::(&runtime_override).unwrap(), - expected_runtime_override + app_service.security_opt, + Some(vec!["seccomp=unconfined".to_string()]) + ); + assert_eq!(app_service.privileged, Some(true)); + assert!(app_service.entrypoint.is_some()); + + let labels = app_service.labels.as_ref().unwrap(); + assert_eq!( + labels.get("devcontainer.local_folder").unwrap(), + "/path/to/local/project" + ); + assert_eq!( + labels.get("devcontainer.config_file").unwrap(), + "/path/to/local/project/.devcontainer/devcontainer.json" + ); + + let metadata_json = labels.get("devcontainer.metadata").unwrap(); + let metadata: Vec = + serde_json_lenient::from_str(metadata_json).unwrap(); + assert!(metadata.len() >= 2); + assert_eq!(metadata[0]["remoteUser"], "vscode"); + let has_dind_feature = metadata.iter().any(|e| { + e.get("id").and_then(|v| v.as_str()) + == Some("ghcr.io/devcontainers/features/docker-in-docker:2") + }); + assert!( + has_dind_feature, + "metadata should include docker-in-docker feature entry" + ); + let has_forward_ports = metadata.iter().any(|e| e.get("forwardPorts").is_some()); + assert!( + has_forward_ports, + "metadata should include devcontainer.json config entry with forwardPorts" + ); + + assert_eq!(app_service.volumes.len(), 1); + assert_eq!(app_service.volumes[0].target, "/var/lib/docker"); + + let db_service = runtime_config.services.get("db").expect("db service"); + assert_eq!(db_service.ports.len(), 3); + + assert!( + runtime_config + .volumes + .contains_key("dind-var-lib-docker-42dad4b4ca7b8ced") ) } + #[gpui::test] + async fn test_compose_build_includes_primary_service_when_run_services_excludes_it( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + // `app` (the primary `service`) is intentionally left out of `runServices`; + // the spec still implies it, so its features image must still be built. + let given_devcontainer_contents = r#" + { + "features": { + "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + }, + "name": "Rust and PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "runServices": ["db"], + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}" + } + "#; + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"), + r#" +volumes: + postgres-data: + +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - ../..:/workspaces:cached + command: sleep infinity + network_mode: service:db + + db: + image: postgres:14.1 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + "# + .trim() + .to_string(), + ) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + r#" +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm +RUN apt-get update + "# + .trim() + .to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + let recorded = test_dependencies.docker.recorded_compose_build_services(); + let build_services = recorded + .iter() + .find_map(|services| services.clone()) + .expect("docker_compose_build should have been invoked with a service list"); + + assert!( + build_services.iter().any(|s| s == "app"), + "compose build must include the primary service `app` even when \ + runServices omits it; got {build_services:?}" + ); + assert_eq!( + build_services.iter().filter(|s| *s == "app").count(), + 1, + "primary service `app` must appear exactly once (de-duplicated); \ + got {build_services:?}" + ); + + // The implied-primary rule applies to `up -d` too, not just the build. + let docker_commands = test_dependencies + .command_runner + .commands_by_program("docker"); + let compose_up = docker_commands + .iter() + .find(|c| { + c.args.first().map(String::as_str) == Some("compose") + && c.args.iter().any(|a| a == "up") + }) + .expect("docker compose up command recorded"); + let up_index = compose_up + .args + .iter() + .position(|a| a == "up") + .expect("up token present"); + let up_service_args = &compose_up.args[up_index + 2..]; + assert!( + up_service_args.iter().any(|s| s == "app"), + "compose up must start the primary service `app` even when \ + runServices omits it; got {:?}", + compose_up.args + ); + assert_eq!( + up_service_args.iter().filter(|s| *s == "app").count(), + 1, + "primary service `app` must appear exactly once in the up service \ + args (de-duplicated); got {:?}", + compose_up.args + ); + } + #[test] fn derive_project_name_env_wins_over_everything() { // CLI precedence rule 1: `COMPOSE_PROJECT_NAME` env var short-circuits @@ -4938,6 +5913,75 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ ); } + #[gpui::test] + async fn test_resumes_only_requested_compose_services_without_recreating( + cx: &mut TestAppContext, + ) { + let devcontainer_contents = r#" + { + "dockerComposeFile": "docker-compose.yml", + "service": "devcontainer", + "runServices": ["devcontainer", "db"], + "workspaceFolder": "/workspaces/project", + "updateRemoteUserUID": false + } + "#; + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"), + indoc! {r#" + services: + app: + image: test_image:latest + devcontainer: + image: test_image:latest + db: + image: postgres:18.4 + "# } + .to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + assert!( + devcontainer_manifest + .check_for_existing_devcontainer() + .await + .unwrap() + .is_some() + ); + + let command = test_dependencies + .command_runner + .commands_by_program("docker") + .into_iter() + .find(|command| { + command.args.first().map(String::as_str) == Some("compose") + && command.args.iter().any(|argument| argument == "up") + }) + .expect("docker compose up command recorded"); + + assert!( + command.args.ends_with(&[ + "up".to_string(), + "-d".to_string(), + "--no-recreate".to_string(), + "devcontainer".to_string(), + "db".to_string(), + ]), + "compose resume should target requested services without recreating them, got: {:?}", + command.args + ); + } + #[cfg(not(target_os = "windows"))] #[gpui::test] async fn test_spawns_devcontainer_with_docker_compose_and_podman(cx: &mut TestAppContext) { @@ -5877,6 +6921,93 @@ FROM ${IMAGE} AS production assert_eq!(base_image, "docker.io/stuff/mybuild:latest".to_string()); } + #[test] + fn test_image_from_dockerfile_resolves_one_hop_alias() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base AS development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_resolves_deep_alias_chain() { + let dockerfile = + "FROM ubuntu:24.04 AS base\nFROM base AS mid\nFROM mid AS development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_no_target_resolves_alias() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &None), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_stage_alias_shadows_external_image() { + // The first `a` is an external image: stage `a` isn't defined yet. + // Target `a` builds from stage `b`, whose base is that external `a`. + let dockerfile = "FROM a AS b\nFROM b AS a".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("a".to_string())), + Some("a".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_only_resolves_earlier_stages() { + // The first `ubuntu` is the external image, not the later stage. + let dockerfile = "FROM ubuntu AS build\nFROM debian AS ubuntu".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("build".to_string())), + Some("ubuntu".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_skips_platform_flag() { + let dockerfile = + "FROM --platform=linux/amd64 ubuntu:24.04 AS base\nFROM base AS development" + .to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_missing_target() { + let dockerfile = "FROM ubuntu:24.04 AS base".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("nonexistent".to_string())), + None + ); + } + + #[test] + fn test_image_from_dockerfile_case_insensitive_alias() { + let dockerfile = "FROM ubuntu:24.04 AS Base\nFROM Base AS Development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_scratch_base() { + let dockerfile = "FROM scratch AS builder\nFROM builder AS final".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("final".to_string())), + Some("scratch".to_string()) + ); + } + #[gpui::test] async fn test_expands_args_in_dockerfile(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -6090,13 +7221,62 @@ RUN echo $RUBY_VERSION2 } #[test] - fn test_aliases_dockerfile_with_pre_existing_aliases_for_build() {} + fn test_aliases_dockerfile_with_pre_existing_aliases_for_build() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base AS development"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + "FROM ubuntu:24.04 AS base\nFROM base AS development\nFROM development AS dev_container_auto_added_stage_label" + ); + } + + #[test] + fn test_aliases_dockerfile_with_no_aliases_for_build() { + let dockerfile = "FROM --platform=linux/amd64 ubuntu:24.04\nRUN echo ok"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + "FROM --platform=linux/amd64 ubuntu:24.04 AS dev_container_auto_added_stage_label\nRUN echo ok" + ); + } + + #[test] + fn test_aliases_dockerfile_with_build_target_specified() { + let dockerfile = "FROM ubuntu:24.04 AS development\nFROM ubuntu:22.04 AS production"; + + assert_eq!( + dockerfile_inject_alias( + dockerfile, + "dev_container_auto_added_stage_label", + Some("development".to_string()) + ), + "FROM ubuntu:24.04 AS development\nFROM ubuntu:22.04 AS production\nFROM development AS dev_container_auto_added_stage_label" + ); + } #[test] - fn test_aliases_dockerfile_with_no_aliases_for_build() {} + fn test_aliases_dockerfile_with_missing_build_target_is_unmodified() { + let dockerfile = "FROM ubuntu:24.04 AS development"; + + assert_eq!( + dockerfile_inject_alias( + dockerfile, + "dev_container_auto_added_stage_label", + Some("nonexistent".to_string()) + ), + dockerfile + ); + } #[test] - fn test_aliases_dockerfile_with_build_target_specified() {} + fn test_aliases_dockerfile_with_line_continuation_is_unmodified() { + let dockerfile = "FROM ubuntu:24.04 \\\n --platform=linux/amd64\nRUN echo ok"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + dockerfile + ); + } pub(crate) struct RecordedExecCommand { pub(crate) _container_id: String, @@ -6114,6 +7294,9 @@ RUN echo $RUBY_VERSION2 /// `MultipleMatchingContainers` with these IDs. Used to exercise the /// duplicate-container error path. duplicate_container_ids: Mutex>>, + /// Records the `services` argument passed to each `docker_compose_build` + /// call so tests can assert which services were built. + compose_build_services: Mutex>>>, } impl FakeDocker { @@ -6123,8 +7306,16 @@ RUN echo $RUBY_VERSION2 has_buildx: true, exec_commands_recorded: Mutex::new(Vec::new()), duplicate_container_ids: Mutex::new(None), + compose_build_services: Mutex::new(Vec::new()), } } + + fn recorded_compose_build_services(&self) -> Vec>> { + self.compose_build_services + .lock() + .expect("should be available") + .clone() + } #[cfg(not(target_os = "windows"))] fn set_podman(&mut self, podman: bool) { self.podman = podman; @@ -6165,10 +7356,24 @@ RUN echo $RUBY_VERSION2 .to_string(), config: DockerInspectConfig { labels: DockerConfigLabels { - metadata: Some(vec![HashMap::from([( - "remoteUser".to_string(), - Value::String("vscode".to_string()), - )])]), + metadata: Some(vec![ + HashMap::from([( + "remoteUser".to_string(), + Value::String("vscode".to_string()), + )]), + HashMap::from([ + ( + "capAdd".to_string(), + Value::Array(vec![Value::String("SYS_PTRACE".to_string())]), + ), + ( + "securityOpt".to_string(), + Value::Array(vec![Value::String( + "seccomp=unconfined".to_string(), + )]), + ), + ]), + ]), }, image_user: Some("root".to_string()), env: Vec::new(), @@ -6222,10 +7427,24 @@ RUN echo $RUBY_VERSION2 .to_string(), config: DockerInspectConfig { labels: DockerConfigLabels { - metadata: Some(vec![HashMap::from([( - "remoteUser".to_string(), - Value::String("vscode".to_string()), - )])]), + metadata: Some(vec![ + HashMap::from([( + "remoteUser".to_string(), + Value::String("vscode".to_string()), + )]), + HashMap::from([ + ( + "capAdd".to_string(), + Value::Array(vec![Value::String("SYS_PTRACE".to_string())]), + ), + ( + "securityOpt".to_string(), + Value::Array(vec![Value::String( + "seccomp=unconfined".to_string(), + )]), + ), + ]), + ]), }, image_user: Some("root".to_string()), env: Vec::new(), @@ -6412,6 +7631,10 @@ RUN echo $RUBY_VERSION2 _project_name: &str, _services: Option<&Vec>, ) -> Result<(), DevContainerError> { + self.compose_build_services + .lock() + .expect("should be available") + .push(_services.cloned()); Ok(()) } async fn run_docker_exec( diff --git a/crates/dev_container/src/docker.rs b/crates/dev_container/src/docker.rs index 6e3e82dc76dbab..089564178d2099 100644 --- a/crates/dev_container/src/docker.rs +++ b/crates/dev_container/src/docker.rs @@ -21,6 +21,8 @@ pub(crate) struct DockerPs { #[serde(rename_all = "PascalCase")] pub(crate) struct DockerState { pub(crate) running: bool, + #[serde(default)] + pub(crate) started_at: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)] @@ -145,6 +147,8 @@ pub(crate) struct DockerComposeService { pub(crate) build: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) privileged: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) init: Option, #[serde( default, skip_serializing_if = "Vec::is_empty", @@ -163,6 +167,12 @@ pub(crate) struct DockerComposeService { deserialize_with = "deserialize_nullable_vec" )] pub(crate) command: Vec, + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "deserialize_environment" + )] + pub(crate) environment: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)] @@ -275,11 +285,17 @@ impl Docker { #[async_trait] impl DockerClient for Docker { async fn inspect(&self, id: &String) -> Result { - // Try to pull the image, continue on failure; Image may be local only, id a reference to a running container + // Always try inspect first — avoid pulling unless necessary. + let command = self.create_docker_inspect(id); + match evaluate_json_command::(command).await { + Ok(Some(docker_inspect)) => return Ok(docker_inspect), + Ok(None) | Err(_) => {} + } + + // Inspect failed — try pulling and retry. self.pull_image(id).await.ok(); let command = self.create_docker_inspect(id); - let Some(docker_inspect): Option = evaluate_json_command(command).await? else { log::error!("Docker inspect produced no deserializable output"); @@ -373,12 +389,13 @@ impl DockerClient for Docker { log::error!("Error running command {e} in container exec"); DevContainerError::ContainerNotValid(container_id.to_string()) })?; + let std_out = String::from_utf8_lossy(&output.stdout); + log::debug!("Command output:\n {std_out}"); if !output.status.success() { let std_err = String::from_utf8_lossy(&output.stderr); log::error!("Command produced a non-successful output. StdErr: {std_err}"); + return Err(DevContainerError::DevContainerScriptsFailed); } - let std_out = String::from_utf8_lossy(&output.stdout); - log::debug!("Command output:\n {std_out}"); Ok(()) } @@ -499,6 +516,41 @@ pub(crate) trait DockerClient { fn docker_cli(&self) -> String; } +fn deserialize_environment<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + + match value { + serde_json_lenient::Value::Object(object) => Ok(Some( + object + .into_iter() + .filter_map(|(key, value)| match value { + serde_json_lenient::Value::Null => None, + serde_json_lenient::Value::String(value) => Some((key, value)), + other => Some((key, other.to_string())), + }) + .collect(), + )), + serde_json_lenient::Value::Array(values) => Ok(Some( + values + .into_iter() + .filter_map(|value| { + let value = value.as_str()?; + let (key, value) = value.split_once('=').unwrap_or((value, "")); + Some((key.to_string(), value.to_string())) + }) + .collect(), + )), + _ => Ok(None), + } +} + fn deserialize_labels<'de, D>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, @@ -716,6 +768,8 @@ mod test { parse_find_process_output, }, }; + #[cfg(not(target_os = "windows"))] + use util::command::Command; #[test] fn use_buildkit_setting_overrides_buildx_detection() { @@ -836,6 +890,28 @@ mod test { ) } + #[cfg(not(target_os = "windows"))] + #[test] + fn docker_exec_returns_error_on_nonzero_exit() { + let docker = Docker { + docker_cli: "false".to_string(), + has_buildx: false, + }; + + let result = gpui::block_on(docker.run_docker_exec( + "container", + "/workspace", + "root", + &HashMap::new(), + Command::new("true"), + )); + + assert!(matches!( + result, + Err(DevContainerError::DevContainerScriptsFailed) + )); + } + #[test] fn should_deserialize_docker_ps_with_filters() { // First, deserializes empty @@ -1098,6 +1174,13 @@ mod test { name: Some("custom port".to_string()), }, ], + environment: Some(HashMap::from([ + ("POSTGRES_DB".to_string(), "postgres".to_string()), + ("POSTGRES_HOSTNAME".to_string(), "localhost".to_string()), + ("POSTGRES_PASSWORD".to_string(), "postgres".to_string()), + ("POSTGRES_PORT".to_string(), "5432".to_string()), + ("POSTGRES_USER".to_string(), "postgres".to_string()), + ])), ..Default::default() }, ), @@ -1110,6 +1193,13 @@ mod test { source: Some("postgres-data".to_string()), target: "/var/lib/postgresql/data".to_string(), }], + environment: Some(HashMap::from([ + ("POSTGRES_DB".to_string(), "postgres".to_string()), + ("POSTGRES_HOSTNAME".to_string(), "localhost".to_string()), + ("POSTGRES_PASSWORD".to_string(), "postgres".to_string()), + ("POSTGRES_PORT".to_string(), "5432".to_string()), + ("POSTGRES_USER".to_string(), "postgres".to_string()), + ])), ..Default::default() }, ), @@ -1181,6 +1271,44 @@ mod test { ); } + #[test] + fn should_deserialize_compose_environment_key_only_entries() { + let given_config = r#" + { + "name": "devcontainer", + "services": { + "array": { + "image": "node:22-alpine", + "environment": ["USER_INPUT", "DEFINED=value"] + }, + "map": { + "image": "node:22-alpine", + "environment": { + "USER_INPUT": null, + "DEFINED": "value" + } + } + } + } + "#; + + let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap(); + assert_eq!( + config.services.get("array").unwrap().environment, + Some(HashMap::from([ + ("DEFINED".to_string(), "value".to_string()), + ("USER_INPUT".to_string(), "".to_string()), + ])) + ); + assert_eq!( + config.services.get("map").unwrap().environment, + Some(HashMap::from([( + "DEFINED".to_string(), + "value".to_string() + )])) + ); + } + #[test] fn should_deserialize_compose_without_volumes() { let given_config = r#" diff --git a/crates/dev_container/src/features.rs b/crates/dev_container/src/features.rs index 5c35b785852735..e3b865b29a5aa0 100644 --- a/crates/dev_container/src/features.rs +++ b/crates/dev_container/src/features.rs @@ -36,9 +36,18 @@ pub(crate) struct DevContainerFeatureJson { #[serde(default)] pub(crate) options: HashMap, pub(crate) mounts: Option>, + pub(crate) init: Option, pub(crate) privileged: Option, pub(crate) entrypoint: Option, pub(crate) container_env: Option>, + pub(crate) cap_add: Option>, + pub(crate) security_opt: Option>, + pub(crate) customizations: Option, + pub(crate) on_create_command: Option, + pub(crate) update_content_command: Option, + pub(crate) post_create_command: Option, + pub(crate) post_start_command: Option, + pub(crate) post_attach_command: Option, } /// A single option definition inside `devcontainer-feature.json`. @@ -62,6 +71,7 @@ impl FeatureOptionDefinition { #[derive(Debug, Eq, PartialEq, Default)] pub(crate) struct FeatureManifest { consecutive_id: String, + user_feature_id: String, file_path: PathBuf, feature_json: DevContainerFeatureJson, } @@ -69,11 +79,13 @@ pub(crate) struct FeatureManifest { impl FeatureManifest { pub(crate) fn new( consecutive_id: String, + user_feature_id: String, file_path: PathBuf, feature_json: DevContainerFeatureJson, ) -> Self { Self { consecutive_id, + user_feature_id, file_path, feature_json, } @@ -188,6 +200,10 @@ RUN chmod -R 0755 {full_dest} \ } } + pub(crate) fn init(&self) -> bool { + self.feature_json.init.unwrap_or(false) + } + pub(crate) fn privileged(&self) -> bool { self.feature_json.privileged.unwrap_or(false) } @@ -196,9 +212,88 @@ RUN chmod -R 0755 {full_dest} \ self.feature_json.entrypoint.clone() } + pub(crate) fn cap_add(&self) -> Vec { + self.feature_json.cap_add.clone().unwrap_or_default() + } + + pub(crate) fn security_opt(&self) -> Vec { + self.feature_json.security_opt.clone().unwrap_or_default() + } + pub(crate) fn file_path(&self) -> PathBuf { self.file_path.clone() } + + pub(crate) fn build_metadata_entry( + &self, + ) -> serde_json_lenient::Map { + use serde_json_lenient::Value; + let mut entry = serde_json_lenient::Map::new(); + entry.insert( + "id".to_string(), + Value::String(self.user_feature_id.clone()), + ); + if let Some(true) = self.feature_json.init { + entry.insert("init".to_string(), Value::Bool(true)); + } + if let Some(true) = self.feature_json.privileged { + entry.insert("privileged".to_string(), Value::Bool(true)); + } + if let Some(caps) = &self.feature_json.cap_add { + if !caps.is_empty() { + entry.insert( + "capAdd".to_string(), + Value::Array(caps.iter().map(|s| Value::String(s.clone())).collect()), + ); + } + } + if let Some(opts) = &self.feature_json.security_opt { + if !opts.is_empty() { + entry.insert( + "securityOpt".to_string(), + Value::Array(opts.iter().map(|s| Value::String(s.clone())).collect()), + ); + } + } + if let Some(ep) = &self.feature_json.entrypoint { + entry.insert("entrypoint".to_string(), Value::String(ep.clone())); + } + if let Some(mounts) = &self.feature_json.mounts { + if !mounts.is_empty() { + entry.insert( + "mounts".to_string(), + Value::Array( + mounts + .iter() + .filter_map(|mount| serde_json_lenient::to_value(mount).ok()) + .collect(), + ), + ); + } + } + if let Some(customizations) = &self.feature_json.customizations { + if !customizations.is_null() { + entry.insert("customizations".to_string(), customizations.clone()); + } + } + for (key, value) in [ + ("onCreateCommand", &self.feature_json.on_create_command), + ( + "updateContentCommand", + &self.feature_json.update_content_command, + ), + ("postCreateCommand", &self.feature_json.post_create_command), + ("postStartCommand", &self.feature_json.post_start_command), + ("postAttachCommand", &self.feature_json.post_attach_command), + ] { + if let Some(value) = value { + if !value.is_null() { + entry.insert(key.to_string(), value.clone()); + } + } + } + entry + } } /// Parses an OCI feature reference string into its components. diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 833caa830fa08a..f34a81bef77ea8 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -303,6 +303,10 @@ impl TemplatePickerDelegate { impl PickerDelegate for TemplatePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "dev container template picker" + } + fn match_count(&self) -> usize { self.matching_indices.len() } @@ -486,6 +490,10 @@ impl FeaturePickerDelegate { impl PickerDelegate for FeaturePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "dev container feature picker" + } + fn match_count(&self) -> usize { self.matching_indices.len() } @@ -1176,8 +1184,7 @@ impl StatefulModal for DevContainerModal { }), ); - let picker = - cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); self.picker = Some(picker); Some(DevContainerState::TemplateQueryReturned(Ok(items))) } else { @@ -1308,8 +1315,7 @@ impl StatefulModal for DevContainerModal { }), ); - let picker = - cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); self.features_picker = Some(picker); Some(DevContainerState::FeaturesQueryReturned(template_entry)) } else { @@ -1573,11 +1579,12 @@ fn dispatch_apply_templates( }; if files.project_files.contains(&Arc::from( - RelPath::unix(".devcontainer/devcontainer.json").unwrap(), + RelPath::from_unix_str(".devcontainer/devcontainer.json").unwrap(), )) { let Some(workspace_task) = workspace .update_in(cx, |workspace, window, cx| { - let Ok(path) = RelPath::unix(".devcontainer/devcontainer.json") else { + let Ok(path) = RelPath::from_unix_str(".devcontainer/devcontainer.json") + else { return Task::ready(Err(anyhow!( "Couldn't create path for .devcontainer/devcontainer.json" ))); diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 613b485c94643f..7bac26fac7068b 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -412,7 +412,6 @@ async fn test_diagnostics_with_folds(cx: &mut TestAppContext) { "§ main.js § ----- ⋯ - tset(); § no method `tset`" } ); diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 9f243c781021cd..724d58e3fc6248 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -74,6 +74,7 @@ impl Render for DiagnosticIndicator { Button::new("diagnostic_message", SharedString::new(message)) .label_size(LabelSize::Small) .truncate(true) + .tab_index(0isize) .tooltip(move |_window, cx| { Tooltip::for_action( tooltip, @@ -89,10 +90,32 @@ impl Render for DiagnosticIndicator { None }; + let diagnostics_label = match (self.summary.error_count, self.summary.warning_count) { + (0, 0) => "Project diagnostics: no problems".to_string(), + (errors, warnings) => { + let mut parts = Vec::new(); + if errors > 0 { + parts.push(format!( + "{errors} error{}", + if errors == 1 { "" } else { "s" } + )); + } + if warnings > 0 { + parts.push(format!( + "{warnings} warning{}", + if warnings == 1 { "" } else { "s" } + )); + } + format!("Project diagnostics: {}", parts.join(", ")) + } + }; + indicator .child( ButtonLike::new("diagnostic-indicator") .child(diagnostic_indicator) + .tab_index(0isize) + .aria_label(diagnostics_label) .tooltip(move |_window, cx| { Tooltip::for_action("Project Diagnostics", &Deploy, cx) }) diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index 87e5110ff52b6e..a422d2811dbb6d 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -27,4 +27,4 @@ workspace = true [[bin]] name = "docs_preprocessor" -path = "src/main.rs" \ No newline at end of file +path = "src/main.rs" diff --git a/crates/docs_preprocessor/src/ai_discovery.rs b/crates/docs_preprocessor/src/ai_discovery.rs new file mode 100644 index 00000000000000..4f3f919582737f --- /dev/null +++ b/crates/docs_preprocessor/src/ai_discovery.rs @@ -0,0 +1,549 @@ +use anyhow::{Context, Result}; +use mdbook::BookItem; +use mdbook::book::Book; +use regex::Regex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::FRONT_MATTER_COMMENT; + +#[derive(Debug)] +pub(crate) struct DocsPage { + section: String, + title: String, + description: Option, + pub(crate) source_path: PathBuf, + content: String, +} + +pub(crate) fn write_ai_discovery_artifacts( + pages: &[DocsPage], + destination: &Path, + site_url: &str, +) -> Result<()> { + copy_markdown_sources(destination, site_url, pages)?; + write_llms_txt(destination, site_url, pages)?; + write_sitemap_xml(destination, site_url, pages)?; + Ok(()) +} + +pub(crate) fn docs_pages(book: &Book) -> Result> { + let mut pages = Vec::new(); + let mut section = "Docs".to_string(); + for item in book.iter() { + let BookItem::Chapter(chapter) = item else { + if let BookItem::PartTitle(part_title) = item { + section.clone_from(part_title); + } + continue; + }; + let Some(source_path) = chapter.source_path.as_ref() else { + continue; + }; + if source_path == Path::new("SUMMARY.md") { + continue; + } + pages.push(DocsPage { + section: section.clone(), + title: chapter.name.clone(), + description: docs_page_description(&chapter.content), + source_path: source_path.clone(), + content: chapter.content.clone(), + }); + } + Ok(pages) +} + +fn copy_markdown_sources(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + for page in pages { + let destination = destination.join(&page.source_path); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create markdown destination {}", parent.display()) + })?; + } + let contents = rewrite_docs_links(&markdown_source_contents(&page.content), site_url); + std::fs::write( + &destination, + add_llms_markdown_directive(&contents, site_url), + ) + .with_context(|| { + format!( + "failed to write markdown page {} to {}", + page.source_path.display(), + destination.display() + ) + })?; + } + let getting_started = destination.join("getting-started.md"); + if getting_started.exists() { + std::fs::copy(&getting_started, destination.join("index.md")) + .context("failed to write index.md markdown alias")?; + } + Ok(()) +} + +fn markdown_source_contents(contents: &str) -> String { + front_matter_comment_regex() + .replace(contents, "") + .trim_start() + .to_string() +} + +fn docs_page_description(contents: &str) -> Option { + docs_page_metadata(contents).and_then(|metadata| { + metadata + .get("description") + .map(|description| { + description + .trim() + .trim_matches('"') + .split_whitespace() + .collect::>() + .join(" ") + }) + .filter(|description| !description.is_empty()) + }) +} + +fn docs_page_metadata(contents: &str) -> Option> { + let captures = front_matter_comment_regex().captures(contents)?; + serde_json::from_str(&captures[1]).ok() +} + +fn front_matter_comment_regex() -> &'static Regex { + static FRONT_MATTER_COMMENT_REGEX: OnceLock = OnceLock::new(); + FRONT_MATTER_COMMENT_REGEX + .get_or_init(|| Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "([^\\n]*)")).unwrap()) +} + +fn write_llms_txt(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("# Zed Docs\n\n"); + contents.push_str( + "> Official Zed documentation index with links to Markdown versions of each docs page.\n\n", + ); + contents.push_str( + "Use these links for concise Markdown copies of Zed documentation pages. Each linked page mirrors the corresponding `/docs/*.html` page without site navigation or styling.\n\n", + ); + let mut current_section = None; + for page in pages { + if current_section != Some(page.section.as_str()) { + if current_section.is_some() { + contents.push('\n'); + } + contents.push_str("## "); + contents.push_str(&markdown_text(&page.section)); + contents.push_str("\n\n"); + current_section = Some(page.section.as_str()); + } + contents.push_str("- ["); + contents.push_str(&markdown_text(&page.title)); + contents.push_str("]("); + contents.push_str(&absolute_docs_url(site_url, &page.source_path)); + contents.push(')'); + if let Some(description) = &page.description { + contents.push_str(": "); + contents.push_str(&markdown_text(description)); + } + contents.push('\n'); + } + std::fs::write(destination.join("llms.txt"), contents).context("failed to write llms.txt")?; + Ok(()) +} + +fn markdown_text(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") +} + +fn write_sitemap_xml(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("\n"); + contents.push_str("\n"); + for page in pages { + contents.push_str(" "); + contents.push_str(&xml_escape(&absolute_docs_url( + site_url, + &page.source_path.with_extension("html"), + ))); + contents.push_str(""); + contents.push_str("\n"); + } + contents.push_str("\n"); + std::fs::write(destination.join("sitemap.xml"), contents) + .context("failed to write sitemap.xml")?; + Ok(()) +} + +pub(crate) fn write_pages_redirects( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + let Some(deploy_root) = destination.parent() else { + return Ok(()); + }; + let mut contents = String::new(); + for (source, destination) in redirects { + write_redirect_line( + &mut contents, + &docs_path("/docs/", source), + &redirect_destination(site_url, destination), + ); + if let Some(extensionless_source) = strip_html_suffix(source) { + write_redirect_line( + &mut contents, + &docs_path("/docs/", &extensionless_source), + &redirect_destination( + site_url, + &strip_html_suffix(destination).unwrap_or_else(|| destination.to_string()), + ), + ); + } + if let Some(markdown_source) = html_path_to_markdown(source) { + if let Some(markdown_destination) = html_path_to_markdown(destination) { + write_redirect_line( + &mut contents, + &docs_path("/docs/", &markdown_source), + &redirect_destination(site_url, &markdown_destination), + ); + } + } + } + std::fs::write(deploy_root.join("_redirects"), contents) + .context("failed to write Cloudflare Pages _redirects")?; + Ok(()) +} + +pub(crate) fn write_markdown_redirect_aliases( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + for (source, redirect_destination_path) in redirects { + let Some(source_markdown) = html_path_to_markdown(source) else { + continue; + }; + let Some(destination_markdown) = html_path_to_markdown(redirect_destination_path) else { + continue; + }; + let source_markdown = destination.join(source_markdown.trim_start_matches('/')); + let destination_markdown = + destination.join(destination_markdown.trim_start_matches("/docs/")); + if !destination_markdown.exists() { + continue; + } + if let Some(parent) = source_markdown.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create markdown alias directory {}", + parent.display() + ) + })?; + } + let contents = format!( + "# Moved\n\n> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\nThis page moved to [the current docs page]({}).\n", + docs_url(site_url, Path::new("llms.txt")), + html_path_to_markdown(redirect_destination_path) + .map(|path| redirect_destination(site_url, &path)) + .unwrap_or_else(|| redirect_destination(site_url, redirect_destination_path)) + ); + std::fs::write(&source_markdown, contents).with_context(|| { + format!( + "failed to write markdown redirect alias from {} to {}", + redirect_destination_path, + source_markdown.display() + ) + })?; + } + Ok(()) +} + +fn write_redirect_line(contents: &mut String, source: &str, destination: &str) { + contents.push_str(source); + contents.push(' '); + contents.push_str(destination); + contents.push_str(" 301\n"); +} + +fn docs_path(site_url: &str, path: &str) -> String { + docs_url(site_url, Path::new(path.trim_start_matches('/'))) +} + +fn redirect_destination(site_url: &str, destination: &str) -> String { + if let Some(path) = destination.strip_prefix("/docs/") { + docs_url(site_url, Path::new(path)) + } else if destination == "/docs" { + docs_url(site_url, Path::new("")) + } else { + destination.to_string() + } +} + +fn strip_html_suffix(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + let path = path.strip_suffix(".html")?; + Some(format!("{path}{fragment}")) +} + +fn html_path_to_markdown(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + if !path.starts_with("/docs/") && path != "/docs" && !path.ends_with(".html") { + return None; + } + let markdown_path = path.strip_suffix(".html").unwrap_or(path); + Some(format!("{markdown_path}.md{fragment}")) +} + +fn split_fragment(path: &str) -> (&str, &str) { + match path.find('#') { + Some(index) => (&path[..index], &path[index..]), + None => (path, ""), + } +} + +pub(crate) fn rewrite_docs_links(contents: &str, site_url: &str) -> String { + const STABLE_DOCS_PREFIX: &str = "https://zed.dev/docs/"; + let channel_docs_prefix = absolute_docs_url(site_url, Path::new("")); + if channel_docs_prefix == STABLE_DOCS_PREFIX { + return contents.to_string(); + } + + let mut output = String::with_capacity(contents.len()); + let mut remaining = contents; + while let Some(index) = remaining.find(STABLE_DOCS_PREFIX) { + output.push_str(&remaining[..index]); + let after_prefix = &remaining[index + STABLE_DOCS_PREFIX.len()..]; + if after_prefix.starts_with("preview/") || after_prefix.starts_with("nightly/") { + output.push_str(STABLE_DOCS_PREFIX); + } else { + output.push_str(&channel_docs_prefix); + } + remaining = after_prefix; + } + output.push_str(remaining); + output +} + +pub(crate) fn add_markdown_alternate_link( + contents: &str, + html_file: &Path, + root_dir: &Path, + site_url: &str, +) -> String { + let Ok(relative_path) = html_file.strip_prefix(root_dir) else { + return contents.to_string(); + }; + let markdown_path = relative_path.with_extension("md"); + if !root_dir.join(&markdown_path).exists() { + return contents.to_string(); + } + let markdown_url = docs_url(site_url, &markdown_path); + let link = format!( + " \n", + markdown_url + ); + contents.replacen("", &(link + " "), 1) +} + +fn add_llms_markdown_directive(contents: &str, site_url: &str) -> String { + let directive = format!( + "> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\n", + docs_url(site_url, Path::new("llms.txt")), + ); + if let Some(rest) = contents.strip_prefix("---\n") { + if let Some(frontmatter_end) = rest.find("\n---\n") { + let split_at = "---\n".len() + frontmatter_end + "\n---\n".len(); + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&contents[..split_at]); + output.push('\n'); + output.push_str(&directive); + output.push_str(&contents[split_at..]); + return output; + } + } + + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&directive); + output.push_str(contents); + output +} + +fn docs_url(site_url: &str, path: &Path) -> String { + let mut url = site_url.to_string(); + if !url.ends_with('/') { + url.push('/'); + } + url.push_str(&path.to_string_lossy().replace('\\', "/")); + url +} + +fn absolute_docs_url(site_url: &str, path: &Path) -> String { + let url = docs_url(site_url, path); + if url.starts_with("http://") || url.starts_with("https://") { + url + } else { + format!("https://zed.dev{}", url) + } +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_llms_markdown_directive_inserts_after_frontmatter() { + let contents = "---\ntitle: Example\n---\n# Example\n"; + let output = add_llms_markdown_directive(contents, "/docs/"); + + assert!(output.starts_with("---\ntitle: Example\n---\n\n")); + assert!(output.contains( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt)." + )); + } + + #[test] + fn test_redirect_destination_uses_channel_site_url_for_docs_paths() { + assert_eq!( + redirect_destination("/docs/preview/", "/docs/ai/overview.html"), + "/docs/preview/ai/overview.html" + ); + assert_eq!( + redirect_destination("/docs/preview/", "/community-links"), + "/community-links" + ); + } + + #[test] + fn test_rewrite_docs_links_uses_channel_site_url() { + assert_eq!( + rewrite_docs_links( + "See [Code Actions](https://zed.dev/docs/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html).", + "/docs/preview/" + ), + "See [Code Actions](https://zed.dev/docs/preview/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html)." + ); + } + + #[test] + fn test_docs_path_uses_channel_site_url() { + assert_eq!( + docs_path("/docs/preview/", "/assistant.md"), + "/docs/preview/assistant.md" + ); + } + + #[test] + fn test_write_pages_redirects_keeps_sources_on_internal_pages_path() -> Result<()> { + let deploy_root = std::env::temp_dir().join(format!( + "docs_preprocessor_pages_redirects_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + let destination = deploy_root.join("docs"); + std::fs::create_dir_all(&destination)?; + let redirects = vec![ + ( + "/assistant.html".to_string(), + "/docs/ai/overview.html".to_string(), + ), + ( + "/community/feedback.html".to_string(), + "/community-links".to_string(), + ), + ]; + + write_pages_redirects(&destination, &redirects, "/docs/preview/")?; + + assert_eq!( + std::fs::read_to_string(deploy_root.join("_redirects"))?, + "/docs/assistant.html /docs/preview/ai/overview.html 301\n\ +/docs/assistant /docs/preview/ai/overview 301\n\ +/docs/assistant.md /docs/preview/ai/overview.md 301\n\ +/docs/community/feedback.html /community-links 301\n\ +/docs/community/feedback /community-links 301\n" + ); + std::fs::remove_dir_all(&deploy_root)?; + Ok(()) + } + + #[test] + fn test_write_ai_discovery_artifacts_generates_agent_facing_metadata() -> Result<()> { + let destination = std::env::temp_dir().join(format!( + "docs_preprocessor_ai_discovery_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&destination)?; + + let pages = vec![ + DocsPage { + section: "Docs".to_string(), + title: "Getting Started".to_string(), + description: Some("Start using Zed.".to_string()), + source_path: PathBuf::from("getting-started.md"), + content: format!( + "{}\n# Getting Started\n", + FRONT_MATTER_COMMENT.replace("{}", r#"{"description":"Start using Zed."}"#) + ), + }, + DocsPage { + section: "AI".to_string(), + title: "MCP".to_string(), + description: Some("Connect model context servers.".to_string()), + source_path: PathBuf::from("ai/mcp.md"), + content: format!( + "{}\n# MCP\n", + FRONT_MATTER_COMMENT + .replace("{}", r#"{"description":"Connect model context servers."}"#) + ), + }, + ]; + + write_ai_discovery_artifacts(&pages, &destination, "/docs/")?; + + let llms_txt = std::fs::read_to_string(destination.join("llms.txt"))?; + assert!(llms_txt.contains("## Docs")); + assert!(llms_txt.contains( + "- [Getting Started](https://zed.dev/docs/getting-started.md): Start using Zed." + )); + assert!(llms_txt.contains("## AI")); + assert!( + llms_txt.contains( + "- [MCP](https://zed.dev/docs/ai/mcp.md): Connect model context servers." + ) + ); + + let sitemap_xml = std::fs::read_to_string(destination.join("sitemap.xml"))?; + assert!(sitemap_xml.contains("https://zed.dev/docs/getting-started.html")); + assert!(sitemap_xml.contains("https://zed.dev/docs/ai/mcp.html")); + + let mcp_markdown = std::fs::read_to_string(destination.join("ai/mcp.md"))?; + assert!(mcp_markdown.starts_with( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt).\n\n# MCP" + )); + assert!(!mcp_markdown.contains("ZED_META")); + + let index_markdown = std::fs::read_to_string(destination.join("index.md"))?; + assert!(index_markdown.contains("# Getting Started")); + + std::fs::remove_dir_all(&destination)?; + Ok(()) + } +} diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 5b860ba16cdd8b..4b0c2ca04bcb90 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -1,4 +1,10 @@ use anyhow::{Context, Result}; +mod ai_discovery; + +use ai_discovery::{ + add_markdown_alternate_link, docs_pages, rewrite_docs_links, write_ai_discovery_artifacts, + write_markdown_redirect_aliases, write_pages_redirects, +}; use mdbook::BookItem; use mdbook::book::{Book, Chapter}; use mdbook::preprocess::CmdPreprocessor; @@ -188,7 +194,7 @@ fn handle_preprocessing() -> Result<()> { template_big_table_of_actions(&mut book); template_and_validate_keybindings(&mut book, &mut errors); template_and_validate_actions(&mut book, &mut errors); - template_and_validate_json_snippets(&mut book, &mut errors); + template_and_validate_json_snippets(&mut book, &mut errors)?; if !errors.is_empty() { const ANSI_RED: &str = "\x1b[31m"; @@ -252,7 +258,7 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -300,7 +306,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#action (.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#action\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -379,7 +385,10 @@ fn find_binding_with_overlay( .or_else(|| find_binding(os, action)) } -fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { +fn template_and_validate_json_snippets( + book: &mut Book, + errors: &mut HashSet, +) -> Result<()> { let params = SettingsJsonSchemaParams { language_names: &[], font_names: &[], @@ -393,12 +402,23 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 {
-                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
-                    snippet_json_fixed.insert(0, '[');
-                    snippet_json_fixed.push_str("\n]");
-                }
+                if let Some(keymap_validator) = &keymap_validator {
+                    if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
+                        snippet_json_fixed.insert(0, '[');
+                        snippet_json_fixed.push_str("\n]");
+                    }
 
-                let value =
-                    settings::parse_json_with_comments::(&snippet_json_fixed)?;
-                let validation_errors: Vec = keymap_validator
-                    .iter_errors(&value)
-                    .map(|err| err.to_string())
-                    .collect();
-                if !validation_errors.is_empty() {
-                    anyhow::bail!("{}", validation_errors.join("\n"));
+                    let value = settings::parse_json_with_comments::(
+                        &snippet_json_fixed,
+                    )?;
+                    let validation_errors: Vec = keymap_validator
+                        .iter_errors(&value)
+                        .map(|err| err.to_string())
+                        .collect();
+                    if !validation_errors.is_empty() {
+                        anyhow::bail!("{}", validation_errors.join("\n"));
+                    }
                 }
             }
             "debug" => {
@@ -554,6 +577,8 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 Result<()> {
         .as_table_mut()
         .expect("output is table");
     let zed_html = output.remove("zed-html").expect("zed-html output defined");
+    let redirects = zed_html
+        .get("redirect")
+        .and_then(|redirects| redirects.as_table())
+        .map(|redirects| {
+            redirects
+                .iter()
+                .filter_map(|(source, destination)| {
+                    destination
+                        .as_str()
+                        .map(|destination| (source.clone(), destination.to_string()))
+                })
+                .collect::>()
+        });
     let default_description = zed_html
         .get("default-description")
         .expect("Default description not found")
@@ -680,6 +718,17 @@ fn handle_postprocessing() -> Result<()> {
     let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
     let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
     let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
+    let site_url = std::env::var("MDBOOK_BOOK__SITE_URL")
+        .ok()
+        .filter(|site_url| !site_url.trim().is_empty())
+        .unwrap_or_else(|| {
+            match docs_channel.as_str() {
+                "nightly" => "/docs/nightly/",
+                "preview" => "/docs/preview/",
+                _ => "/docs/",
+            }
+            .to_string()
+        });
     let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
         ""
     } else {
@@ -719,8 +768,10 @@ fn handle_postprocessing() -> Result<()> {
     }
 
     zlog::info!(logger => "Processing {} `.html` files", files.len());
+    let pages = docs_pages(&ctx.book)?;
+    write_ai_discovery_artifacts(&pages, &root_dir, &site_url)?;
     let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
-    for file in files {
+    for file in &files {
         let contents = std::fs::read_to_string(&file)?;
         let mut meta_description = None;
         let mut meta_title = None;
@@ -756,14 +807,19 @@ fn handle_postprocessing() -> Result<()> {
         let contents = contents.replace("#amplitude_key#", &litude_key);
         let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
         let contents = contents.replace("#noindex#", noindex);
+        let contents = rewrite_docs_links(&contents, &site_url);
+        let contents = add_markdown_alternate_link(&contents, file, &root_dir, &site_url);
         let contents = title_regex()
             .replace(&contents, |_: ®ex::Captures| {
                 format!("{}", meta_title)
             })
             .to_string();
-        // let contents = contents.replace("#title#", &meta_title);
         std::fs::write(file, contents)?;
     }
+    if let Some(redirects) = redirects {
+        write_markdown_redirect_aliases(&root_dir, &redirects, &site_url)?;
+        write_pages_redirects(&root_dir, &redirects, &site_url)?;
+    }
     return Ok(());
 
     fn pretty_path<'a>(
@@ -892,66 +948,4 @@ fn keymap_schema_for_actions(
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use serde_json::json;
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_over_parameterized() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_falls_back_to_parameterized_match() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_regardless_of_order() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
-                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_later_section_overrides_earlier() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            { "bindings": { "ctrl-a": "some::Action" } },
-            { "bindings": { "ctrl-b": "some::Action" } }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "some::Action");
-        assert_eq!(binding.as_deref(), Some("ctrl-b"));
-    }
-}
+mod tests;
diff --git a/crates/docs_preprocessor/src/tests.rs b/crates/docs_preprocessor/src/tests.rs
new file mode 100644
index 00000000000000..43438207d8c600
--- /dev/null
+++ b/crates/docs_preprocessor/src/tests.rs
@@ -0,0 +1,61 @@
+use super::*;
+use serde_json::json;
+
+#[test]
+fn test_find_binding_prefers_exact_match_over_parameterized() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_falls_back_to_parameterized_match() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
+}
+
+#[test]
+fn test_find_binding_prefers_exact_match_regardless_of_order() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
+                "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_later_section_overrides_earlier() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        { "bindings": { "ctrl-a": "some::Action" } },
+        { "bindings": { "ctrl-b": "some::Action" } }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "some::Action");
+    assert_eq!(binding.as_deref(), Some("ctrl-b"));
+}
diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs
deleted file mode 100644
index 4f6e0f34b3018a..00000000000000
--- a/crates/edit_prediction/src/capture_example.rs
+++ /dev/null
@@ -1,565 +0,0 @@
-use crate::{
-    StoredEvent,
-    data_collection::{
-        UncommittedDiffSnapshot, compute_cursor_excerpt, compute_uncommitted_diff,
-        format_cursor_excerpt,
-    },
-    example_spec::{ExampleSpec, RecentFile},
-};
-use anyhow::Result;
-use gpui::{App, Entity, Task};
-use language::Buffer;
-use project::Project;
-use std::{fmt::Write, path::Path, sync::Arc};
-
-pub fn capture_example(
-    project: Entity,
-    buffer: Entity,
-    cursor_anchor: language::Anchor,
-    events: Vec,
-    recently_opened_files: Vec,
-    recently_viewed_files: Vec,
-    uncommitted_diff_snapshot: UncommittedDiffSnapshot,
-    populate_expected_patch: bool,
-    cx: &mut App,
-) -> Option>> {
-    let snapshot = buffer.read(cx).snapshot();
-    let file = snapshot.file()?;
-    let worktree_id = file.worktree_id(cx);
-    let repository = project.read(cx).active_repository(cx)?;
-    let repository_snapshot = repository.read(cx).snapshot();
-    let worktree = project.read(cx).worktree_for_id(worktree_id, cx)?;
-    let root_name = worktree.read(cx).root_name_str().to_owned();
-    let cursor_path: Arc = file.path().as_std_path().into();
-    if worktree.read(cx).abs_path() != repository_snapshot.work_directory_abs_path {
-        return None;
-    }
-
-    let repository_url = repository_snapshot
-        .remote_origin_url
-        .clone()
-        .or_else(|| repository_snapshot.remote_upstream_url.clone())?;
-    let revision = repository_snapshot.head_commit.as_ref()?.sha.to_string();
-
-    Some(cx.spawn(async move |cx| {
-        let uncommitted_diff = cx
-            .background_executor()
-            .spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshot) })
-            .await;
-
-        let line_comment_prefix = snapshot
-            .language()
-            .and_then(|lang| lang.config().line_comments.first())
-            .map(|s| s.to_string())
-            .unwrap_or_default();
-
-        let (cursor_excerpt, cursor_offset_in_excerpt, cursor_excerpt_range) = cx
-            .background_executor()
-            .spawn(async move { compute_cursor_excerpt(&snapshot, cursor_anchor) })
-            .await;
-        let mut edit_history = String::new();
-        for stored_event in &events {
-            write_event_with_relative_paths(&mut edit_history, &stored_event.event, &root_name);
-            if !edit_history.ends_with('\n') {
-                edit_history.push('\n');
-            }
-        }
-        let uncommitted_diff_contains_edit_history = !edit_history.is_empty();
-
-        // Initialize an empty patch with context lines, to make it easy
-        // to write the expected patch by hand.
-        let mut expected_patches = Vec::new();
-        let mut rejected_patch = None;
-        if populate_expected_patch {
-            let mut empty_patch = String::new();
-            let start_row = cursor_excerpt_range.start.row + 1;
-            let row_count = cursor_excerpt_range.end.row - cursor_excerpt_range.start.row + 1;
-            writeln!(&mut empty_patch, "--- a/{}", cursor_path.display()).ok();
-            writeln!(&mut empty_patch, "+++ b/{}", cursor_path.display()).ok();
-            writeln!(
-                &mut empty_patch,
-                "@@ -{},{} +{},{} @@",
-                start_row, row_count, start_row, row_count,
-            )
-            .ok();
-            for line in cursor_excerpt.lines() {
-                writeln!(&mut empty_patch, " {}", line).ok();
-            }
-
-            expected_patches.push(empty_patch.clone());
-            rejected_patch = Some(empty_patch);
-        }
-
-        let spec = ExampleSpec {
-            name: generate_timestamp_name(),
-            repository_url,
-            revision,
-            tags: Vec::new(),
-            reasoning: None,
-            uncommitted_diff,
-            recently_opened_files,
-            recently_viewed_files,
-            uncommitted_diff_contains_edit_history,
-            cursor_path,
-            cursor_position: format_cursor_excerpt(
-                &cursor_excerpt,
-                cursor_offset_in_excerpt,
-                &line_comment_prefix,
-            ),
-            edit_history,
-            expected_patches,
-            rejected_patch,
-            telemetry: None,
-            human_feedback: Vec::new(),
-            rating: None,
-        };
-        Ok(spec)
-    }))
-}
-
-pub(crate) fn write_event_with_relative_paths(
-    output: &mut String,
-    event: &zeta_prompt::Event,
-    root_name: &str,
-) {
-    fn write_relative_path(output: &mut String, path: &Path, root_name: &str) {
-        for component in path.strip_prefix(root_name).unwrap_or(path).components() {
-            output.push('/');
-            write!(output, "{}", component.as_os_str().to_string_lossy()).ok();
-        }
-    }
-
-    let zeta_prompt::Event::BufferChange {
-        path,
-        old_path,
-        diff,
-        ..
-    } = event;
-
-    output.push_str("--- a");
-    write_relative_path(output, old_path.as_ref(), root_name);
-    output.push_str("\n+++ b");
-    write_relative_path(output, path.as_ref(), root_name);
-    output.push('\n');
-    output.push_str(diff);
-}
-
-fn generate_timestamp_name() -> String {
-    let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]");
-    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(),
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::EditPredictionStore;
-    use crate::data_collection::uncommitted_diffs_for_events;
-    use client::RefreshLlmTokenListener;
-    use client::{Client, UserStore};
-    use clock::FakeSystemClock;
-    use gpui::{AppContext as _, TestAppContext, http_client::FakeHttpClient};
-    use indoc::indoc;
-    use language::{Anchor, Point};
-    use project::{FakeFs, Project};
-    use serde_json::json;
-    use settings::SettingsStore;
-    use std::path::Path;
-
-    #[gpui::test]
-    async fn test_capture_example(cx: &mut TestAppContext) {
-        init_test(cx);
-        let fs = FakeFs::new(cx.executor());
-
-        let committed_contents = indoc! {"
-            fn main() {
-                one();
-                two();
-                three();
-                four();
-                five();
-                six();
-                seven();
-                eight();
-                nine();
-            }
-        "};
-
-        let disk_contents = indoc! {"
-            fn main() {
-                // comment 1
-                one();
-                two();
-                three();
-                four();
-                five();
-                six();
-                seven();
-                eight();
-                // comment 2
-                nine();
-            }
-        "};
-
-        fs.insert_tree(
-            "/project",
-            json!({
-                ".git": {},
-                "src": {
-                    "deleted.rs": "pub fn deleted_file() {\n    deleted();\n}\n",
-                    "main.rs": disk_contents,
-                    "new.rs": "pub fn new_file() {\n}\n",
-                }
-            }),
-        )
-        .await;
-
-        // Create an external file outside the main project
-        fs.insert_tree(
-            "/external",
-            json!({
-                "external.rs": "fn external() {}\n",
-            }),
-        )
-        .await;
-
-        fs.set_head_for_repo(
-            Path::new("/project/.git"),
-            &[
-                (
-                    "src/deleted.rs",
-                    "pub fn deleted_file() {\n    deleted();\n}\n".to_string(),
-                ),
-                ("src/main.rs", committed_contents.to_string()),
-            ],
-            "abc123def456",
-        );
-        fs.set_remote_for_repo(
-            Path::new("/project/.git"),
-            "origin",
-            "https://github.com/test/repo.git",
-        );
-
-        let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await;
-
-        let buffer = project
-            .update(cx, |project, cx| {
-                project.open_local_buffer("/project/src/main.rs", cx)
-            })
-            .await
-            .unwrap();
-
-        let ep_store = cx.read(|cx| EditPredictionStore::try_global(cx).unwrap());
-        ep_store.update(cx, |ep_store, cx| {
-            ep_store.register_buffer(&buffer, &project, cx)
-        });
-        cx.run_until_parked();
-
-        let deleted_file_buffer = project
-            .update(cx, |project, cx| {
-                project.open_local_buffer("/project/src/deleted.rs", cx)
-            })
-            .await
-            .unwrap();
-        ep_store.update(cx, |ep_store, cx| {
-            ep_store.register_buffer(&deleted_file_buffer, &project, cx)
-        });
-        cx.run_until_parked();
-        deleted_file_buffer.update(cx, |buffer, cx| {
-            buffer.edit([(0..buffer.len(), "")], None, cx);
-        });
-        cx.run_until_parked();
-
-        buffer.update(cx, |buffer, cx| {
-            let point = Point::new(6, 0);
-            buffer.edit([(point..point, "    // comment 3\n")], None, cx);
-            let point = Point::new(4, 0);
-            buffer.edit([(point..point, "    // comment 4\n")], None, cx);
-
-            pretty_assertions::assert_eq!(
-                buffer.text(),
-                indoc! {"
-                    fn main() {
-                        // comment 1
-                        one();
-                        two();
-                        // comment 4
-                        three();
-                        four();
-                        // comment 3
-                        five();
-                        six();
-                        seven();
-                        eight();
-                        // comment 2
-                        nine();
-                    }
-                "}
-            );
-        });
-        cx.run_until_parked();
-
-        let new_file_buffer = project
-            .update(cx, |project, cx| {
-                project.open_local_buffer("/project/src/new.rs", cx)
-            })
-            .await
-            .unwrap();
-        ep_store.update(cx, |ep_store, cx| {
-            ep_store.register_buffer(&new_file_buffer, &project, cx)
-        });
-        cx.run_until_parked();
-        new_file_buffer.update(cx, |buffer, cx| {
-            let point = Point::new(1, 0);
-            buffer.edit([(point..point, "    created();\n")], None, cx);
-        });
-        cx.run_until_parked();
-
-        // Open and edit an external file (outside the main project's worktree)
-        let external_buffer = project
-            .update(cx, |project, cx| {
-                project.open_local_buffer("/external/external.rs", cx)
-            })
-            .await
-            .unwrap();
-        ep_store.update(cx, |ep_store, cx| {
-            ep_store.register_buffer(&external_buffer, &project, cx)
-        });
-        cx.run_until_parked();
-        external_buffer.update(cx, |buffer, cx| {
-            let point = Point::new(0, 0);
-            buffer.edit([(point..point, "// external edit\n")], None, cx);
-        });
-        cx.run_until_parked();
-
-        // Verify the external edit was recorded in events
-        let events = ep_store.update(cx, |store, cx| store.edit_history_for_project(&project, cx));
-        assert!(
-            matches!(
-                events
-                    .last()
-                    .unwrap()
-                    .event
-                    .as_ref(),
-                zeta_prompt::Event::BufferChange { path, .. } if path.as_ref() == "/external/external.rs"
-            ),
-            "external file edit should be in events"
-        );
-
-        let worktree_id = buffer.read_with(cx, |buffer, cx| buffer.file().unwrap().worktree_id(cx));
-        let unfiltered_uncommitted_diffs = ep_store
-            .update(cx, |_store, cx| {
-                uncommitted_diffs_for_events(project.clone(), worktree_id, events.clone(), cx)
-            })
-            .await
-            .unwrap();
-        assert!(
-            unfiltered_uncommitted_diffs
-                .iter()
-                .all(|(path, _, _)| path.as_ref() != Path::new("external.rs"))
-        );
-
-        let project_events = events
-            .into_iter()
-            .filter(|event| {
-                let zeta_prompt::Event::BufferChange { path, .. } = event.event.as_ref();
-                path.as_ref() != "/external/external.rs"
-            })
-            .collect::>();
-        let uncommitted_diffs_by_path = ep_store
-            .update(cx, |_store, cx| {
-                uncommitted_diffs_for_events(
-                    project.clone(),
-                    worktree_id,
-                    project_events.clone(),
-                    cx,
-                )
-            })
-            .await
-            .unwrap();
-
-        let mut example = cx
-            .update(|cx| {
-                capture_example(
-                    project.clone(),
-                    buffer.clone(),
-                    Anchor::min_for_buffer(buffer.read(cx).remote_id()),
-                    project_events,
-                    Vec::new(),
-                    Vec::new(),
-                    uncommitted_diffs_by_path,
-                    true,
-                    cx,
-                )
-                .unwrap()
-            })
-            .await
-            .unwrap();
-        example.name = "test".to_string();
-
-        pretty_assertions::assert_eq!(
-            example,
-            ExampleSpec {
-                name: "test".to_string(),
-                repository_url: "https://github.com/test/repo.git".to_string(),
-                revision: "abc123def456".to_string(),
-                tags: Vec::new(),
-                reasoning: None,
-                uncommitted_diff: indoc! {"
-                    --- a/src/deleted.rs
-                    +++ b/src/deleted.rs
-                    @@ -1,3 +1,0 @@
-                    -pub fn deleted_file() {
-                    -    deleted();
-                    -}
-                    --- a/src/main.rs
-                    +++ b/src/main.rs
-                    @@ -1,11 +1,15 @@
-                     fn main() {
-                    +    // comment 1
-                         one();
-                         two();
-                    +    // comment 4
-                         three();
-                         four();
-                    +    // comment 3
-                         five();
-                         six();
-                         seven();
-                         eight();
-                    +    // comment 2
-                         nine();
-                     }
-                    --- /dev/null
-                    +++ b/src/new.rs
-                    @@ -0,0 +1,3 @@
-                    +pub fn new_file() {
-                    +    created();
-                    +}
-                "}
-                .to_string(),
-                recently_opened_files: Vec::new(),
-                recently_viewed_files: Vec::new(),
-                uncommitted_diff_contains_edit_history: true,
-                cursor_path: Path::new("src/main.rs").into(),
-                cursor_position: indoc! {"
-                    fn main() {
-                    ^[CURSOR_POSITION]
-                        // comment 1
-                        one();
-                        two();
-                        // comment 4
-                        three();
-                        four();
-                        // comment 3
-                        five();
-                        six();
-                        seven();
-                        eight();
-                        // comment 2
-                        nine();
-                    }
-                "}
-                .to_string(),
-                edit_history: indoc! {"
-                    --- a/src/deleted.rs
-                    +++ b/src/deleted.rs
-                    @@ -1,3 +1,0 @@
-                    -pub fn deleted_file() {
-                    -    deleted();
-                    -}
-                    --- a/src/main.rs
-                    +++ b/src/main.rs
-                    @@ -2,8 +2,10 @@
-                         // comment 1
-                         one();
-                         two();
-                    +    // comment 4
-                         three();
-                         four();
-                    +    // comment 3
-                         five();
-                         six();
-                         seven();
-                    --- a/src/new.rs
-                    +++ b/src/new.rs
-                    @@ -1,2 +1,3 @@
-                     pub fn new_file() {
-                    +    created();
-                     }
-                "}
-                .to_string(),
-                expected_patches: vec![
-                    indoc! {"
-                        --- a/src/main.rs
-                        +++ b/src/main.rs
-                        @@ -1,16 +1,16 @@
-                         fn main() {
-                             // comment 1
-                             one();
-                             two();
-                             // comment 4
-                             three();
-                             four();
-                             // comment 3
-                             five();
-                             six();
-                             seven();
-                             eight();
-                             // comment 2
-                             nine();
-                         }
-                    "}
-                    .to_string()
-                ],
-                rejected_patch: Some(
-                    indoc! {"
-                        --- a/src/main.rs
-                        +++ b/src/main.rs
-                        @@ -1,16 +1,16 @@
-                         fn main() {
-                             // comment 1
-                             one();
-                             two();
-                             // comment 4
-                             three();
-                             four();
-                             // comment 3
-                             five();
-                             six();
-                             seven();
-                             eight();
-                             // comment 2
-                             nine();
-                         }
-                    "}
-                    .to_string()
-                ),
-                telemetry: None,
-                human_feedback: Vec::new(),
-                rating: None,
-            }
-        );
-    }
-
-    fn init_test(cx: &mut TestAppContext) {
-        cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
-            cx.set_global(settings_store);
-            zlog::init_test();
-            let http_client = FakeHttpClient::with_404_response();
-            let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx);
-            let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-            language_model::init(cx);
-            RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
-            EditPredictionStore::global(&client, &user_store, cx);
-        })
-    }
-}
diff --git a/crates/edit_prediction/src/data_collection.rs b/crates/edit_prediction/src/data_collection.rs
index b56609a9d96a8b..217124157a74fa 100644
--- a/crates/edit_prediction/src/data_collection.rs
+++ b/crates/edit_prediction/src/data_collection.rs
@@ -1,20 +1,105 @@
-use crate::{EditPredictionStore, StoredEvent};
+use crate::{EditPredictionStore, StoredEvent, zeta};
 
-use anyhow::Context as _;
+use anyhow::{Context as _, Result};
 use buffer_diff::BufferDiffSnapshot;
 use collections::HashMap;
 use gpui::{Context, Entity, Task};
-use language::BufferSnapshot;
+use language::{Buffer, BufferSnapshot, ToPoint as _};
 use project::{Project, ProjectPath, WorktreeId};
 use std::{fmt::Write as _, ops::Range, path::Path, sync::Arc};
-use text::{OffsetRangeExt, Point};
+use text::{OffsetRangeExt as _, Point};
 use util::rel_path::RelPath;
 
+const MAX_UNCOMMITTED_DIFF_SIZE: usize = 64 * 1024;
+
 pub type UncommittedDiffSnapshot = Vec<(Arc, BufferSnapshot, BufferDiffSnapshot)>;
 pub type UncommittedDiffResult = std::result::Result>;
 
 pub use zeta_prompt::udiff::CURSOR_POSITION_MARKER;
 
+pub(crate) struct CapturedPredictionContext {
+    pub(crate) repository_url: Option,
+    pub(crate) revision: Option,
+    pub(crate) uncommitted_diff: Option,
+    pub(crate) buffer_diagnostics: Vec,
+    pub(crate) editable_context: Vec,
+}
+
+pub(crate) fn capture_prediction_context(
+    project: Entity,
+    buffer: Entity,
+    cursor_anchor: language::Anchor,
+    stored_events: Vec,
+    repository_url: Option,
+    revision: Option,
+    editable_context_task: Task>>,
+    cx: &mut Context,
+) -> Option>> {
+    let snapshot = buffer.read(cx).snapshot();
+    let worktree_id = snapshot.file()?.worktree_id(cx);
+    let uncommitted_diff_task =
+        uncommitted_diffs_for_events(project, worktree_id, stored_events, cx);
+
+    Some(cx.spawn(async move |_this, cx| {
+        let uncommitted_diff_snapshot = match uncommitted_diff_task.await {
+            Ok(snapshot) => Some(snapshot),
+            Err(error) => {
+                log::debug!("failed to capture uncommitted diff: {error:?}");
+                None
+            }
+        };
+
+        let uncommitted_diff = if let Some(uncommitted_diff_snapshot) = uncommitted_diff_snapshot {
+            let estimated_uncommitted_diff_size = uncommitted_diff_snapshot
+                .iter()
+                .map(|(_, buffer_snapshot, diff_snapshot)| {
+                    diff_snapshot
+                        .hunks(buffer_snapshot)
+                        .map(|hunk| {
+                            hunk.diff_base_byte_range.len()
+                                + hunk.range.to_offset(buffer_snapshot).len()
+                        })
+                        .sum::()
+                })
+                .sum::();
+
+            if estimated_uncommitted_diff_size <= MAX_UNCOMMITTED_DIFF_SIZE {
+                let uncommitted_diff = cx
+                    .background_executor()
+                    .spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshot) })
+                    .await;
+                (uncommitted_diff.len() <= MAX_UNCOMMITTED_DIFF_SIZE).then_some(uncommitted_diff)
+            } else {
+                None
+            }
+        } else {
+            None
+        };
+
+        let buffer_diagnostics = zeta::active_buffer_diagnostics(
+            &snapshot,
+            Point::new(0, 0)..snapshot.max_point(),
+            cursor_anchor.to_point(&snapshot).row,
+            100,
+        );
+        let editable_context = match editable_context_task.await {
+            Ok(editable_context) => editable_context,
+            Err(error) => {
+                log::debug!("failed to capture editable context: {error:?}");
+                Vec::new()
+            }
+        };
+
+        Ok(CapturedPredictionContext {
+            repository_url,
+            revision,
+            uncommitted_diff,
+            buffer_diagnostics,
+            editable_context,
+        })
+    }))
+}
+
 pub fn uncommitted_diffs_for_events(
     project: Entity,
     worktree_id: WorktreeId,
@@ -234,17 +319,6 @@ pub(crate) fn compute_uncommitted_diff(snapshot: UncommittedDiffSnapshot) -> Str
     uncommitted_diff
 }
 
-pub(crate) fn estimate_uncommitted_diff_byte_size(snapshot: &UncommittedDiffSnapshot) -> usize {
-    let mut size = 0;
-    for (_, buffer_snapshot, diff_snapshot) in snapshot {
-        for hunk in diff_snapshot.hunks(buffer_snapshot) {
-            size += hunk.diff_base_byte_range.len();
-            size += hunk.range.to_offset(buffer_snapshot).len();
-        }
-    }
-    size
-}
-
 fn row_start_or_max(snapshot: &language::BufferSnapshot, row: u32) -> Point {
     if row >= snapshot.max_point().row {
         snapshot.max_point()
diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs
index ac95aac0dfced4..850851dc694a1f 100644
--- a/crates/edit_prediction/src/edit_prediction.rs
+++ b/crates/edit_prediction/src/edit_prediction.rs
@@ -3,14 +3,17 @@ use buffer_diff::BufferDiff;
 use client::{Client, EditPredictionUsage, UserStore, global_llm_token};
 use cloud_api_client::LlmApiToken;
 use cloud_api_types::{
-    EditPredictionSettledKeptChars, OrganizationId, SubmitEditPredictionFeedbackBody,
-    SubmitEditPredictionSettledBody,
+    EditPredictionRecentFile, EditPredictionSettledKeptChars,
+    MAX_EDIT_PREDICTION_SETTLED_PER_REQUEST, OrganizationId, SettledEditPrediction,
+    SettledEditPredictionSampleData, SubmitEditPredictionFeedbackBody,
+    SubmitEditPredictionSettledBatchBody, SubmitEditPredictionSettledResponse,
 };
 use cloud_llm_client::predict_edits_v3::{
     PREDICT_EDITS_MODE_HEADER_NAME, PREDICT_EDITS_REQUEST_ID_HEADER_NAME,
     PREDICT_EDITS_TRIGGER_HEADER_NAME, PredictEditsMode, PredictEditsV3Request,
     PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse,
 };
+use cloud_llm_client::predict_edits_v4::{PredictEditsV4Request, PredictEditsV4Response};
 use cloud_llm_client::{
     EditPredictionRejectReason, EditPredictionRejection,
     MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, MINIMUM_REQUIRED_VERSION_HEADER_NAME,
@@ -56,9 +59,8 @@ use std::env;
 use std::rc::Rc;
 use text::{AnchorRangeExt, Edit};
 use workspace::{AppState, Workspace};
-#[cfg(feature = "cli-support")]
 use zeta_prompt::ContextSource;
-use zeta_prompt::{ZetaFormat, ZetaPromptInput};
+use zeta_prompt::{Zeta2PromptInput, Zeta3PromptInput, ZetaFormat};
 
 use std::mem;
 use std::ops::Range;
@@ -68,13 +70,12 @@ use std::sync::Arc;
 use std::time::{Duration, Instant};
 
 use thiserror::Error;
-use util::{RangeExt as _, ResultExt as _};
+use util::{ResultExt as _, rel_path::RelPath};
 
 pub mod cursor_excerpt;
 pub mod data_collection;
 pub mod example_spec;
 pub mod fim;
-mod jump_example;
 mod license_detection;
 pub mod mercury;
 pub mod metrics;
@@ -85,7 +86,6 @@ mod prediction;
 
 pub mod udiff;
 
-mod capture_example;
 pub mod open_ai_compatible;
 mod zed_edit_prediction_delegate;
 pub mod zeta;
@@ -94,20 +94,14 @@ pub mod zeta;
 mod edit_prediction_tests;
 
 use crate::cursor_excerpt::expand_context_syntactically_then_linewise;
-use crate::data_collection::uncommitted_diffs_for_events;
-use crate::example_spec::ExampleSpec;
+use crate::data_collection::{CapturedPredictionContext, capture_prediction_context};
 use crate::example_spec::RecentFile;
-use crate::jump_example::{
-    JUMP_EXAMPLE_NAVIGATION_COUNT, JumpExampleTrigger, PendingJumpExampleCapture,
-    PendingJumpExampleCaptureKey,
-};
 use crate::license_detection::LicenseDetectionWatcher;
 use crate::mercury::Mercury;
 pub use crate::metrics::{KeptRateResult, compute_kept_rate};
 use crate::onboarding_modal::ZedPredictModal;
-pub use crate::prediction::EditPrediction;
-pub use crate::prediction::EditPredictionId;
 use crate::prediction::EditPredictionResult;
+pub use crate::prediction::{EditPrediction, EditPredictionId, EditPredictionInputs};
 pub use language_model::ApiKeyState;
 pub use telemetry_events::EditPredictionRating;
 pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate;
@@ -136,16 +130,14 @@ const REQUEST_TIMEOUT_BACKOFF: Duration = Duration::from_secs(10);
 
 const EDIT_PREDICTION_SETTLED_TTL: Duration = Duration::from_secs(60 * 5);
 const EDIT_PREDICTION_SETTLED_QUIESCENCE: Duration = Duration::from_secs(10);
+const EDIT_PREDICTION_CAPTURE_MAX_FUTURE_EVENTS: usize = 4;
+const EDIT_PREDICTION_SETTLED_MAX_EDITABLE_REGION_BYTES: usize = 4 * 1024;
 
 pub struct EditPredictionJumpsFeatureFlag;
 
 impl FeatureFlag for EditPredictionJumpsFeatureFlag {
     const NAME: &'static str = "edit_prediction_jumps";
     type Value = PresenceFlag;
-
-    fn enabled_for_staff() -> bool {
-        false
-    }
 }
 register_feature_flag!(EditPredictionJumpsFeatureFlag);
 
@@ -180,7 +172,7 @@ pub struct EditPredictionStore {
     legacy_data_collection_enabled: bool,
     reject_predictions_tx: mpsc::UnboundedSender,
     settled_predictions_tx: mpsc::UnboundedSender,
-    shown_predictions: VecDeque,
+    rateable_predictions: VecDeque,
     rated_predictions: HashSet,
     #[cfg(test)]
     settled_event_callback: Option>,
@@ -199,21 +191,21 @@ pub enum EditPredictionModel {
     Mercury,
 }
 
-#[derive(Clone)]
 pub struct EditPredictionModelInput {
     project: Entity,
     buffer: Entity,
     snapshot: BufferSnapshot,
     position: Anchor,
     events: Vec>,
-    stored_events: Vec,
     related_files: Vec,
+    editable_context: Option>>>,
     mode: PredictEditsMode,
     trigger: PredictEditsRequestTrigger,
     diagnostic_search_range: Range,
     debug_tx: Option>,
     can_collect_data: bool,
     is_open_source: bool,
+    allow_jump: bool,
 }
 
 #[derive(Debug)]
@@ -358,6 +350,7 @@ fn push_recent_file(files: &mut VecDeque, mut file: RecentFile) {
 struct ProjectState {
     events: VecDeque,
     last_event: Option,
+    next_last_event_seq: u64,
     recently_viewed_files: VecDeque,
     recently_opened_files: VecDeque,
     registered_buffers: HashMap,
@@ -366,11 +359,9 @@ struct ProjectState {
     last_edit_source: Option,
     next_pending_prediction_id: usize,
     pending_predictions: ArrayVec,
-    pending_jump_example_captures: Vec,
-    starting_jump_example_captures: Vec,
+    pending_prediction_captures: Vec,
     debug_tx: Option>,
     last_edit_prediction_refresh: Option<(EntityId, Instant)>,
-    last_jump_prediction_refresh: Option<(EntityId, Instant)>,
     cancelled_predictions: HashSet,
     context: Entity,
     license_detection_watchers: HashMap>,
@@ -471,27 +462,41 @@ impl ProjectState {
     }
 
     fn finalize_last_event(&mut self, cx: &mut Context) {
-        let Some(event) = self.last_event.take() else {
-            return;
-        };
-        let Some(event) = event.finalize(&self.license_detection_watchers, cx) else {
+        let Some(last_event) = self.last_event.take() else {
             return;
         };
+        let event = last_event.finalize(&self.license_detection_watchers, cx);
 
-        for capture in &mut self.pending_jump_example_captures {
-            capture.future_events.push(event.event.clone());
+        for capture in &mut self.pending_prediction_captures {
+            capture.try_record_future_event(
+                &last_event,
+                event.as_ref(),
+                &self.license_detection_watchers,
+                cx,
+            );
         }
-        jump_example::drain_completed_jump_example_captures(self, cx);
+
+        let Some(event) = event else {
+            return;
+        };
         if self.events.len() + 1 >= EVENT_COUNT_MAX {
             self.events.pop_front();
         }
         self.events.push_back(event);
     }
+
+    fn clear_history(&mut self) {
+        self.events.clear();
+        self.last_event.take();
+        for capture in &mut self.pending_prediction_captures {
+            capture.sample_data = None;
+        }
+    }
 }
 
 #[derive(Debug, Clone)]
 struct CurrentEditPrediction {
-    pub requested_by: PredictionRequestedBy,
+    pub requested_by: EntityId,
     pub prediction: EditPrediction,
     pub was_shown: bool,
     pub shown_with: Option,
@@ -518,13 +523,11 @@ impl CurrentEditPrediction {
             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())
+        if self.requested_by == self.prediction.buffer.entity_id()
+            && self.requested_by == old_prediction.prediction.buffer.entity_id()
             && old_edits.len() == 1
             && new_edits.len() == 1
         {
@@ -537,29 +540,8 @@ impl CurrentEditPrediction {
     }
 }
 
-#[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),
-        }
-    }
-}
-
 const DIAGNOSTIC_LINES_RANGE: u32 = 20;
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub enum DiagnosticSearchScope {
-    Local,
-    Global,
-}
-
 #[derive(Debug)]
 struct PendingPrediction {
     id: usize,
@@ -588,9 +570,9 @@ impl std::ops::Deref for BufferEditPrediction<'_> {
     }
 }
 
-#[derive(Clone)]
-struct PendingSettledPrediction {
+struct PendingPredictionCapture {
     request_id: EditPredictionId,
+    edited_buffer_id: EntityId,
     editable_anchor_range: Range,
     editable_region_before_prediction: String,
     predicted_editable_region: String,
@@ -599,23 +581,104 @@ struct PendingSettledPrediction {
     organization_id: Option,
     can_collect_data: bool,
     is_in_open_source_repo: bool,
-    example: Option,
+    sample_data: Option,
     model_version: Option,
     enqueued_at: Instant,
     last_edit_at: Instant,
     e2e_latency: std::time::Duration,
 }
 
+struct PendingPredictionCaptureSampleData {
+    context_task: Task>,
+    editable_path: Arc,
+    editable_offset_range: Range,
+    next_edit_cursor_offset: Option,
+    future_edit_history_events: Vec>,
+    navigation_history: VecDeque,
+    edit_events_before_quiescence: u32,
+    prompt_history_boundary: Option,
+}
+
+/// Marks where the prompt's edit history ended. Sample data may only include
+/// content the user produced after this point.
+struct PromptHistoryBoundary {
+    /// The seq of the first event this capture is expected to observe: the
+    /// event that was pending when the prediction was requested, or the next
+    /// event to be created if none was pending. Observing a later seq first
+    /// means events were lost while the prediction request was in flight.
+    first_event_seq: u64,
+    /// The prompt's end snapshot within the event that was pending when the
+    /// prediction was requested, if any. The first observed event is trimmed
+    /// to its suffix after this snapshot.
+    snapshot: Option,
+}
+
+impl PendingPredictionCapture {
+    /// Records the project's last event (pending or finalizing) into this
+    /// sample's future edit history. Returns false if the sample must be
+    /// dropped because its future history can't be captured accurately.
+    fn try_record_future_event(
+        &mut self,
+        last_event: &LastEvent,
+        finalized_event: Option<&StoredEvent>,
+        license_detection_watchers: &HashMap>,
+        cx: &App,
+    ) {
+        let Some(sample) = &mut self.sample_data else {
+            return;
+        };
+        let boundary = sample.prompt_history_boundary.take();
+        let suffix_snapshot = match &boundary {
+            Some(boundary) => {
+                if last_event.seq != boundary.first_event_seq {
+                    // Events were finalized before this capture was enqueued,
+                    // so events are missing from the future history.
+                    self.sample_data.take();
+                    return;
+                }
+                boundary.snapshot.as_ref()
+            }
+            None => None,
+        };
+
+        let event = match suffix_snapshot {
+            Some(snapshot) => {
+                let suffix = last_event
+                    .suffix_after(snapshot)
+                    .and_then(|suffix| suffix.finalize(license_detection_watchers, cx));
+                let Some(suffix) = suffix else {
+                    return;
+                };
+                suffix.event
+            }
+            None => match finalized_event {
+                Some(event) => event.event.clone(),
+                None => return,
+            },
+        };
+
+        if !event.in_open_source_repo() {
+            self.sample_data.take();
+            return;
+        }
+        sample.edit_events_before_quiescence += 1;
+        if sample.future_edit_history_events.len() < EDIT_PREDICTION_CAPTURE_MAX_FUTURE_EVENTS {
+            sample.future_edit_history_events.push(event);
+        }
+    }
+}
+
 struct RegisteredBuffer {
     file: Option>,
     snapshot: TextBufferSnapshot,
-    pending_predictions: Vec,
     last_position: Option,
     _subscriptions: [gpui::Subscription; 2],
 }
 
 #[derive(Clone)]
 struct LastEvent {
+    /// Project-wide monotonic sequence number identifying this event.
+    seq: u64,
     old_snapshot: TextBufferSnapshot,
     new_snapshot: TextBufferSnapshot,
     old_file: Option>,
@@ -649,7 +712,7 @@ impl LastEvent {
                     })
                 });
 
-        let (diff, edit_range) = compute_diff_between_snapshots_in_range(
+        let (diff, old_range, new_range) = compute_diff_between_snapshots_in_range(
             &self.old_snapshot,
             &self.new_snapshot,
             &self.total_edit_range,
@@ -663,13 +726,15 @@ impl LastEvent {
                     old_path,
                     path,
                     diff,
+                    old_range,
+                    new_range: new_range.clone(),
                     in_open_source_repo,
                     predicted: self.predicted,
                 }),
                 old_snapshot: self.old_snapshot.clone(),
                 new_snapshot_version: self.new_snapshot.version.clone(),
-                total_edit_range: self.new_snapshot.anchor_before(edit_range.start)
-                    ..self.new_snapshot.anchor_before(edit_range.end),
+                total_edit_range: self.new_snapshot.anchor_before(new_range.start)
+                    ..self.new_snapshot.anchor_before(new_range.end),
                 file_context: self.file_context.clone(),
             })
         }
@@ -680,49 +745,40 @@ impl LastEvent {
             return (self.clone(), None);
         };
 
+        let Some(after) = self.suffix_after(boundary_snapshot) else {
+            return (self.clone(), None);
+        };
+
         let total_edit_range_before_pause = self
             .total_edit_range_at_last_pause_boundary
             .clone()
             .unwrap_or_else(|| self.total_edit_range.clone());
 
-        let Some(total_edit_range_after_pause) =
-            compute_total_edit_range_between_snapshots(boundary_snapshot, &self.new_snapshot)
-        else {
-            return (self.clone(), None);
-        };
-
-        let latest_edit_range_before_pause = total_edit_range_before_pause.clone();
-        let latest_edit_range_after_pause = total_edit_range_after_pause.clone();
-
         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(),
-            latest_edit_range: latest_edit_range_before_pause,
+            latest_edit_range: total_edit_range_before_pause.clone(),
             total_edit_range: total_edit_range_before_pause,
             total_edit_range_at_last_pause_boundary: None,
-            predicted: self.predicted,
             snapshot_after_last_editing_pause: None,
-            last_edit_time: self.last_edit_time,
-            file_context: self.file_context.clone(),
+            ..self.clone()
         };
 
-        let after = LastEvent {
+        (before, Some(after))
+    }
+
+    /// The portion of this event that happened after `boundary_snapshot`, or
+    /// None if the buffer hasn't changed since.
+    pub fn suffix_after(&self, boundary_snapshot: &TextBufferSnapshot) -> Option {
+        let total_edit_range =
+            compute_total_edit_range_between_snapshots(boundary_snapshot, &self.new_snapshot)?;
+        Some(LastEvent {
             old_snapshot: boundary_snapshot.clone(),
-            new_snapshot: self.new_snapshot.clone(),
-            old_file: self.old_file.clone(),
-            new_file: self.new_file.clone(),
-            latest_edit_range: latest_edit_range_after_pause,
-            total_edit_range: total_edit_range_after_pause,
+            latest_edit_range: total_edit_range.clone(),
+            total_edit_range,
             total_edit_range_at_last_pause_boundary: None,
-            predicted: self.predicted,
             snapshot_after_last_editing_pause: None,
-            last_edit_time: self.last_edit_time,
-            file_context: self.file_context.clone(),
-        };
-
-        (before, Some(after))
+            ..self.clone()
+        })
     }
 }
 
@@ -791,12 +847,16 @@ fn compute_diff_between_snapshots_in_range(
     old_snapshot: &TextBufferSnapshot,
     new_snapshot: &TextBufferSnapshot,
     total_edit_range: &Range,
-) -> Option<(String, Range)> {
-    let new_start_point = total_edit_range.start.to_point(new_snapshot);
-    let new_end_point = total_edit_range.end.to_point(new_snapshot);
+) -> Option<(String, Range, Range)> {
+    let new_start_offset = total_edit_range.start.to_offset(new_snapshot);
+    let new_end_offset = total_edit_range.end.to_offset(new_snapshot);
+    let new_start_point = new_snapshot.offset_to_point(new_start_offset);
+    let new_end_point = new_snapshot.offset_to_point(new_end_offset);
     let old_range = compute_old_range_for_new_range(old_snapshot, new_snapshot, total_edit_range)?;
     let old_start_point = old_range.start;
     let old_end_point = old_range.end;
+    let old_start_offset = old_snapshot.point_to_offset(old_start_point);
+    let old_end_offset = old_snapshot.point_to_offset(old_end_point);
 
     const CONTEXT_LINES: u32 = 3;
 
@@ -832,7 +892,11 @@ fn compute_diff_between_snapshots_in_range(
         new_context_start_row,
     );
 
-    Some((diff, new_start_point..new_end_point))
+    Some((
+        diff,
+        old_start_offset..old_end_offset,
+        new_start_offset..new_end_offset,
+    ))
 }
 
 pub(crate) fn buffer_path_with_id_fallback(
@@ -840,11 +904,14 @@ pub(crate) fn buffer_path_with_id_fallback(
     snapshot: &TextBufferSnapshot,
     cx: &App,
 ) -> Arc {
-    if let Some(file) = file {
-        file.full_path(cx).into()
-    } else {
-        Path::new(&format!("untitled-{}", snapshot.remote_id())).into()
-    }
+    let Some(file) = file else {
+        return Path::new(&format!("untitled-{}", snapshot.remote_id())).into();
+    };
+    let full_path = file.full_path(cx);
+    let Some(path) = RelPath::new(&full_path, file.path_style(cx)).ok() else {
+        return Path::new(&format!("untitled-{}", snapshot.remote_id())).into();
+    };
+    path.as_std_path().into()
 }
 
 fn predict_edits_request_trigger_from_editor_trigger(
@@ -865,6 +932,17 @@ fn predict_edits_request_trigger_from_editor_trigger(
         EditPredictionRequestTrigger::PredictionPartiallyAccepted => {
             PredictEditsRequestTrigger::PredictionPartiallyAccepted
         }
+        EditPredictionRequestTrigger::EditorCreated => PredictEditsRequestTrigger::EditorCreated,
+        EditPredictionRequestTrigger::ProviderChanged => {
+            PredictEditsRequestTrigger::ProviderChanged
+        }
+        EditPredictionRequestTrigger::UserInfoChanged => {
+            PredictEditsRequestTrigger::UserInfoChanged
+        }
+        EditPredictionRequestTrigger::VimModeChanged => PredictEditsRequestTrigger::VimModeChanged,
+        EditPredictionRequestTrigger::SettingsChanged => {
+            PredictEditsRequestTrigger::SettingsChanged
+        }
         EditPredictionRequestTrigger::Other => PredictEditsRequestTrigger::Other,
     }
 }
@@ -965,7 +1043,7 @@ impl EditPredictionStore {
             reject_predictions_tx: reject_tx,
             settled_predictions_tx,
             rated_predictions: Default::default(),
-            shown_predictions: Default::default(),
+            rateable_predictions: Default::default(),
             #[cfg(test)]
             settled_event_callback: None,
 
@@ -1034,7 +1112,7 @@ impl EditPredictionStore {
 
     pub fn active_experiment(&self) -> Option<&str> {
         self.preferred_experiment.as_deref().or_else(|| {
-            self.shown_predictions
+            self.rateable_predictions
                 .iter()
                 .find_map(|p| p.model_version.as_ref())
                 .and_then(|model_version| model_version.strip_prefix("zeta2:"))
@@ -1045,6 +1123,7 @@ impl EditPredictionStore {
         let client = self.client.clone();
         let llm_token = self.llm_token.clone();
         let app_version = AppVersion::global(cx);
+        let is_jumps_api = cx.has_flag::();
         let organization_id = self
             .user_store
             .read(cx)
@@ -1056,9 +1135,10 @@ impl EditPredictionStore {
                 .background_spawn(async move {
                     let organization_id =
                         organization_id.ok_or_else(|| anyhow!("No organization selected."))?;
-                    let url = client
-                        .http_client()
-                        .build_zed_llm_url("/edit_prediction_experiments", &[])?;
+                    let url = client.http_client().build_zed_llm_url(
+                        "/edit_prediction_experiments",
+                        &[("is_jumps_api", if is_jumps_api { "true" } else { "false" })],
+                    )?;
                     let mut response = client
                         .authenticated_llm_request(&llm_token, organization_id, |token| {
                             Ok(http_client::Request::builder()
@@ -1131,15 +1211,13 @@ impl EditPredictionStore {
 
     pub fn clear_history(&mut self) {
         for project_state in self.projects.values_mut() {
-            project_state.events.clear();
-            project_state.last_event.take();
+            project_state.clear_history();
         }
     }
 
     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();
-            project_state.last_event.take();
+            project_state.clear_history();
         }
     }
 
@@ -1337,6 +1415,7 @@ impl EditPredictionStore {
                 },
                 events: VecDeque::new(),
                 last_event: None,
+                next_last_event_seq: 0,
                 recently_viewed_files: VecDeque::new(),
                 recently_opened_files: VecDeque::new(),
                 debug_tx: None,
@@ -1346,11 +1425,9 @@ impl EditPredictionStore {
                 last_edit_source: None,
                 cancelled_predictions: HashSet::default(),
                 pending_predictions: ArrayVec::new(),
-                pending_jump_example_captures: Vec::new(),
-                starting_jump_example_captures: Vec::new(),
+                pending_prediction_captures: Vec::new(),
                 next_pending_prediction_id: 0,
                 last_edit_prediction_refresh: None,
-                last_jump_prediction_refresh: None,
                 license_detection_watchers: HashMap::default(),
                 _subscriptions: [
                     cx.subscribe(&project, Self::handle_project_event),
@@ -1475,32 +1552,23 @@ impl EditPredictionStore {
                         path: path.path.as_std_path().into(),
                         cursor_position,
                     };
-                    for capture in &mut project_state.pending_jump_example_captures {
-                        capture.navigation_history.push(recent_file.clone());
-                        if capture.navigation_history.len() > JUMP_EXAMPLE_NAVIGATION_COUNT {
-                            capture.navigation_history.remove(0);
+                    let can_collect_navigation = project_state
+                        .license_detection_watchers
+                        .get(&path.worktree_id)
+                        .is_some_and(|watcher| watcher.is_project_open_source());
+                    for capture in &mut project_state.pending_prediction_captures {
+                        if let Some(sample_data) = capture.sample_data.as_mut() {
+                            if can_collect_navigation {
+                                push_recent_file(
+                                    &mut sample_data.navigation_history,
+                                    recent_file.clone(),
+                                );
+                            } else {
+                                capture.sample_data = None;
+                            }
                         }
                     }
                     push_recent_file(&mut project_state.recently_viewed_files, recent_file);
-                    jump_example::drain_completed_jump_example_captures(project_state, cx);
-                }
-            }
-            project::Event::DiagnosticsUpdated { .. } => {
-                if self
-                    .projects
-                    .get(&project.entity_id())
-                    .and_then(|project_state| project_state.last_edit_source)
-                    == Some(BufferEditSource::Agent)
-                {
-                    return;
-                }
-
-                if cx.has_flag::() {
-                    self.refresh_prediction_from_diagnostics(
-                        project,
-                        DiagnosticSearchScope::Global,
-                        cx,
-                    );
                 }
             }
             _ => (),
@@ -1549,7 +1617,6 @@ impl EditPredictionStore {
                     snapshot,
                     file,
                     last_position: None,
-                    pending_predictions: Vec::new(),
                     _subscriptions: [
                         cx.subscribe(buffer, {
                             let project = project.downgrade();
@@ -1617,9 +1684,19 @@ impl EditPredictionStore {
             return;
         };
 
-        for pending_prediction in &mut registered_buffer.pending_predictions {
-            if edit_range.overlaps(&pending_prediction.editable_anchor_range, &new_snapshot) {
-                pending_prediction.last_edit_at = now;
+        for pending_capture in &mut project_state.pending_prediction_captures {
+            if pending_capture.edited_buffer_id == buffer.entity_id()
+                && edit_range.overlaps(&pending_capture.editable_anchor_range, &new_snapshot)
+            {
+                pending_capture.last_edit_at = now;
+                if is_local
+                    && !is_predicted
+                    && let Some(sample_data) = pending_capture.sample_data.as_mut()
+                    && sample_data.next_edit_cursor_offset.is_none()
+                {
+                    sample_data.next_edit_cursor_offset =
+                        Some(edit_range.start.to_offset(&new_snapshot));
+                }
             }
         }
 
@@ -1697,7 +1774,10 @@ impl EditPredictionStore {
             file_context
         });
 
+        let seq = project_state.next_last_event_seq;
+        project_state.next_last_event_seq += 1;
         project_state.last_event = Some(LastEvent {
+            seq,
             old_file,
             new_file,
             old_snapshot,
@@ -1742,19 +1822,10 @@ impl EditPredictionStore {
 
         if prediction.targets_buffer(buffer.read(cx)) {
             Some(BufferEditPrediction::Local { prediction })
+        } else if requested_by == &buffer.entity_id() {
+            Some(BufferEditPrediction::Jump { prediction })
         } else {
-            let show_jump = match requested_by {
-                PredictionRequestedBy::Buffer(requested_by_buffer_id) => {
-                    requested_by_buffer_id == &buffer.entity_id()
-                }
-                PredictionRequestedBy::DiagnosticsUpdate => true,
-            };
-
-            if show_jump {
-                Some(BufferEditPrediction::Jump { prediction })
-            } else {
-                None
-            }
+            None
         }
     }
 
@@ -1900,65 +1971,82 @@ impl EditPredictionStore {
             let mut oldest_edited_at = None;
             let mut ready_predictions = Vec::new();
 
-            this.update(cx, |this, _| {
-                for (_, project_state) in this.projects.iter_mut() {
-                    for (_, registered_buffer) in project_state.registered_buffers.iter_mut() {
-                        let mut pending_index = 0;
-                        while pending_index < registered_buffer.pending_predictions.len() {
-                            let pending_prediction =
-                                ®istered_buffer.pending_predictions[pending_index];
-                            let age = now.saturating_duration_since(pending_prediction.enqueued_at);
-                            if age >= EDIT_PREDICTION_SETTLED_TTL {
-                                registered_buffer.pending_predictions.remove(pending_index);
-                                continue;
-                            }
+            this.update(cx, |this, cx| {
+                for project_state in this.projects.values_mut() {
+                    let ProjectState {
+                        last_event,
+                        registered_buffers,
+                        license_detection_watchers,
+                        pending_prediction_captures,
+                        ..
+                    } = project_state;
+                    let pending_last_event = last_event.as_ref().map(|last_event| {
+                        (
+                            last_event,
+                            last_event.finalize(license_detection_watchers, cx),
+                        )
+                    });
+                    let mut pending_index = 0;
+                    while pending_index < pending_prediction_captures.len() {
+                        let pending_capture = &pending_prediction_captures[pending_index];
+                        let age = now.saturating_duration_since(pending_capture.enqueued_at);
+                        if age >= EDIT_PREDICTION_SETTLED_TTL {
+                            pending_prediction_captures.remove(pending_index);
+                            continue;
+                        }
 
-                            let quiet_for =
-                                now.saturating_duration_since(pending_prediction.last_edit_at);
-                            if quiet_for >= EDIT_PREDICTION_SETTLED_QUIESCENCE {
-                                let pending_prediction =
-                                    registered_buffer.pending_predictions.remove(pending_index);
-                                let settled_editable_region = registered_buffer
-                                    .snapshot
-                                    .text_for_range(
-                                        pending_prediction.editable_anchor_range.clone(),
-                                    )
-                                    .collect::();
-                                ready_predictions
-                                    .push((pending_prediction, settled_editable_region));
+                        let quiet_for = now.saturating_duration_since(pending_capture.last_edit_at);
+                        if quiet_for >= EDIT_PREDICTION_SETTLED_QUIESCENCE {
+                            let Some(registered_buffer) =
+                                registered_buffers.get(&pending_capture.edited_buffer_id)
+                            else {
+                                pending_prediction_captures.remove(pending_index);
+                                continue;
+                            };
+                            let editable_offset_range = pending_capture
+                                .editable_anchor_range
+                                .to_offset(®istered_buffer.snapshot);
+                            if editable_offset_range.len()
+                                > EDIT_PREDICTION_SETTLED_MAX_EDITABLE_REGION_BYTES
+                            {
+                                // The prediction was obliterated by a huge edit;
+                                // kept-rate against it would be meaningless and the
+                                // region would blow the body size cap.
+                                pending_prediction_captures.remove(pending_index);
                                 continue;
                             }
-
-                            if oldest_edited_at
-                                .is_none_or(|time| pending_prediction.last_edit_at < time)
+                            let settled_editable_region = registered_buffer
+                                .snapshot
+                                .text_for_range(editable_offset_range)
+                                .collect::();
+                            let mut pending_capture =
+                                pending_prediction_captures.remove(pending_index);
+                            if let Some((last_event, finalized_event)) = pending_last_event.as_ref()
                             {
-                                oldest_edited_at = Some(pending_prediction.last_edit_at);
+                                pending_capture.try_record_future_event(
+                                    last_event,
+                                    finalized_event.as_ref(),
+                                    license_detection_watchers,
+                                    cx,
+                                );
                             }
-                            pending_index += 1;
+                            ready_predictions.push((pending_capture, settled_editable_region));
+                            continue;
                         }
+
+                        if oldest_edited_at.is_none_or(|time| pending_capture.last_edit_at < time) {
+                            oldest_edited_at = Some(pending_capture.last_edit_at);
+                        }
+                        pending_index += 1;
                     }
                 }
             });
 
-            for (pending_prediction, settled_editable_region) in ready_predictions {
-                let PendingSettledPrediction {
-                    request_id,
-                    editable_region_before_prediction,
-                    predicted_editable_region,
-                    ts_error_count_before_prediction,
-                    ts_error_count_after_prediction,
-                    organization_id,
-                    can_collect_data,
-                    is_in_open_source_repo,
-                    example,
-                    model_version,
-                    e2e_latency,
-                    ..
-                } = pending_prediction;
-                let settled_editable_region_for_metrics = settled_editable_region.clone();
+            let mut ready_predictions_by_organization_id: HashMap<_, Vec<_>> = HashMap::default();
+            for (pending_capture, settled_editable_region) in ready_predictions {
                 #[cfg(test)]
                 {
-                    let request_id = request_id.clone();
+                    let request_id = pending_capture.request_id.clone();
                     let settled_editable_region = settled_editable_region.clone();
                     this.update(cx, |this, _| {
                         if let Some(callback) = &this.settled_event_callback {
@@ -1966,81 +2054,28 @@ impl EditPredictionStore {
                         }
                     });
                 }
-                cx.background_spawn({
-                    let client = client.clone();
-                    let llm_token = llm_token.clone();
-                    let app_version = app_version.clone();
-                    async move {
-                        let kept_rate_result = compute_kept_rate(
-                            &editable_region_before_prediction,
-                            &predicted_editable_region,
-                            &settled_editable_region_for_metrics,
-                        );
-
-                        let result: anyhow::Result<()> = async {
-                            let settled_editable_region =
-                                can_collect_data.then_some(settled_editable_region);
-                            let example = if can_collect_data {
-                                example.map(serde_json::to_value).transpose()?
-                            } else {
-                                None
-                            };
-
-                            let body = SubmitEditPredictionSettledBody {
-                                request_id: request_id.0.to_string(),
-                                settled_editable_region,
-                                ts_error_count_before_prediction,
-                                ts_error_count_after_prediction,
-                                can_collect_data,
-                                is_in_open_source_repo,
-                                kept_chars: EditPredictionSettledKeptChars {
-                                    candidate_new: kept_rate_result.candidate_new_chars,
-                                    reference_new: kept_rate_result.reference_new_chars,
-                                    candidate_deleted: kept_rate_result.candidate_deleted_chars,
-                                    reference_deleted: kept_rate_result.reference_deleted_chars,
-                                    kept: kept_rate_result.kept_chars,
-                                    correctly_deleted: kept_rate_result.correctly_deleted_chars,
-                                    discarded: kept_rate_result.discarded_chars,
-                                    context: kept_rate_result.context_chars,
-                                    kept_rate: kept_rate_result.kept_rate,
-                                    recall_rate: kept_rate_result.recall_rate,
-                                },
-                                example,
-                                model_version,
-                                e2e_latency_ms: e2e_latency.as_millis(),
-                            };
-
-                            let json_bytes = serde_json::to_vec(&body)?;
-                            let compressed = zstd::encode_all(&json_bytes[..], 3)?;
-
-                            let url = client
-                                .http_client()
-                                .build_zed_llm_url("/predict_edits/settled", &[])?;
-                            Self::send_api_request::(
-                                |builder| {
-                                    Ok(builder
-                                        .uri(url.as_ref())
-                                        .header("Content-Encoding", "zstd")
-                                        .body(compressed.clone().into())?)
-                                },
-                                client,
-                                llm_token,
-                                organization_id,
-                                app_version,
-                            )
-                            .await?;
-                            Ok(())
-                        }
-                        .await;
-
-                        if let Err(error) = result {
-                            log::error!("failed to submit edit prediction settled: {error:?}");
-                        }
-                    }
-                })
-                .detach();
+                ready_predictions_by_organization_id
+                    .entry(pending_capture.organization_id.clone())
+                    .or_default()
+                    .push((pending_capture, settled_editable_region));
             }
 
+            cx.background_spawn({
+                let client = client.clone();
+                let llm_token = llm_token.clone();
+                let app_version = app_version.clone();
+                async move {
+                    send_settled_batches(
+                        client,
+                        llm_token,
+                        app_version,
+                        ready_predictions_by_organization_id,
+                    )
+                    .await;
+                }
+            })
+            .detach();
+
             next_wake_time = oldest_edited_at.map(|time| time + EDIT_PREDICTION_SETTLED_QUIESCENCE);
         }
     }
@@ -2053,7 +2088,8 @@ impl EditPredictionStore {
         edited_buffer_snapshot: &BufferSnapshot,
         editable_offset_range: Range,
         edit_preview: &EditPreview,
-        example: Option,
+        context_task: Option>>,
+        prompt_history_boundary: Option,
         model_version: Option,
         e2e_latency: std::time::Duration,
         cx: &mut Context,
@@ -2073,12 +2109,12 @@ impl EditPredictionStore {
             .current_organization()
             .map(|organization| organization.id.clone());
         let project_state = this.get_or_init_project(project, cx);
-        let Some(registered_buffer) = project_state
+        if !project_state
             .registered_buffers
-            .get_mut(&edited_buffer.entity_id())
-        else {
+            .contains_key(&edited_buffer.entity_id())
+        {
             return;
-        };
+        }
 
         let editable_region_before_prediction = edited_buffer_snapshot
             .text_for_range(editable_offset_range.clone())
@@ -2101,12 +2137,30 @@ impl EditPredictionStore {
             ),
         );
         let editable_anchor_range =
-            edited_buffer_snapshot.anchor_range_inside(editable_offset_range);
+            edited_buffer_snapshot.anchor_range_inside(editable_offset_range.clone());
         let now = cx.background_executor().now();
-        registered_buffer
-            .pending_predictions
-            .push(PendingSettledPrediction {
+        let sample_data = if can_collect_data
+            && let Some(context_task) = context_task
+            && let Some(file) = edited_buffer_snapshot.file()
+        {
+            Some(PendingPredictionCaptureSampleData {
+                context_task,
+                editable_path: file.path().as_std_path().into(),
+                editable_offset_range,
+                next_edit_cursor_offset: None,
+                future_edit_history_events: Vec::new(),
+                navigation_history: VecDeque::new(),
+                edit_events_before_quiescence: 0,
+                prompt_history_boundary,
+            })
+        } else {
+            None
+        };
+        project_state
+            .pending_prediction_captures
+            .push(PendingPredictionCapture {
                 request_id,
+                edited_buffer_id: edited_buffer.entity_id(),
                 editable_anchor_range,
                 editable_region_before_prediction,
                 predicted_editable_region,
@@ -2115,7 +2169,7 @@ impl EditPredictionStore {
                 organization_id,
                 can_collect_data,
                 is_in_open_source_repo,
-                example,
+                sample_data,
                 model_version,
                 e2e_latency,
                 enqueued_at: now,
@@ -2174,10 +2228,10 @@ impl EditPredictionStore {
         }
 
         if is_first_non_jump_show {
-            self.shown_predictions
+            self.rateable_predictions
                 .push_front(current_prediction.prediction.clone());
-            if self.shown_predictions.len() > 50 {
-                let completion = self.shown_predictions.pop_back().unwrap();
+            if self.rateable_predictions.len() > 50 {
+                let completion = self.rateable_predictions.pop_back().unwrap();
                 self.rated_predictions.remove(&completion.id);
             }
         }
@@ -2247,196 +2301,169 @@ impl EditPredictionStore {
         trigger: EditPredictionRequestTrigger,
         cx: &mut Context,
     ) {
+        if currently_following(&project, cx) {
+            return;
+        }
+
         let trigger = predict_edits_request_trigger_from_editor_trigger(trigger);
 
-        self.queue_prediction_refresh(
-            project.clone(),
-            trigger,
-            buffer.entity_id(),
-            cx,
-            move |this, cx| {
-                let Some(request_task) = this
-                    .update(cx, |this, cx| {
-                        this.request_prediction_internal(
-                            project.clone(),
-                            buffer.clone(),
-                            position,
-                            trigger,
-                            cx.has_flag::(),
-                            cx,
-                        )
-                    })
-                    .log_err()
-                else {
-                    return Task::ready(anyhow::Ok(None));
-                };
+        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_internal(
+                        project.clone(),
+                        buffer.clone(),
+                        position,
+                        trigger,
+                        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()),
-                            )
-                        })
-                    })
+            cx.spawn(async move |_cx| {
+                request_task.await.map(|prediction_result| {
+                    prediction_result
+                        .map(|prediction_result| (prediction_result, buffer.entity_id()))
                 })
-            },
-        )
+            })
+        })
     }
 
-    pub fn refresh_prediction_from_diagnostics(
-        &mut self,
-        project: Entity,
-        scope: DiagnosticSearchScope,
-        cx: &mut Context,
-    ) {
-        if !is_ep_store_provider(all_language_settings(None, cx).edit_predictions.provider) {
-            return;
-        }
-
-        if currently_following(&project, cx) {
-            return;
-        }
-
-        let Some(project_state) = self.projects.get_mut(&project.entity_id()) else {
-            return;
-        };
-
-        // Prefer predictions from buffer
-        if project_state.current_prediction.is_some() {
-            log::debug!(
-                "edit_prediction: diagnostic refresh skipped, current prediction already exists"
-            );
-            return;
-        }
+    pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300);
+}
 
-        self.queue_prediction_refresh(
-            project.clone(),
-            PredictEditsRequestTrigger::Diagnostics,
-            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;
-                        }
+async fn send_settled_batches(
+    client: Arc,
+    llm_token: LlmApiToken,
+    app_version: Version,
+    ready_predictions_by_organization_id: hash_map::HashMap<
+        Option,
+        Vec<(PendingPredictionCapture, String)>,
+        collections::FxBuildHasher,
+    >,
+) {
+    let Some(url) = client
+        .http_client()
+        .build_zed_llm_url("/predict_edits/settled", &[])
+        .context("failed to build edit predictions settled url")
+        .log_err()
+    else {
+        return;
+    };
 
-                        let cursor_point = position
-                            .map(|pos| pos.to_point(&snapshot))
-                            .unwrap_or_default();
+    for (organization_id, ready_predictions) in ready_predictions_by_organization_id {
+        let mut ready_predictions = ready_predictions.into_iter();
+        loop {
+            let done_batch = ready_predictions
+                .by_ref()
+                .take(MAX_EDIT_PREDICTION_SETTLED_PER_REQUEST);
+            let mut batch = Vec::with_capacity(MAX_EDIT_PREDICTION_SETTLED_PER_REQUEST);
+            for (pending_capture, settled_editable_region) in done_batch {
+                let PendingPredictionCapture {
+                    request_id,
+                    editable_region_before_prediction,
+                    predicted_editable_region,
+                    ts_error_count_before_prediction,
+                    ts_error_count_after_prediction,
+                    can_collect_data,
+                    is_in_open_source_repo,
+                    sample_data,
+                    model_version,
+                    e2e_latency,
+                    ..
+                } = pending_capture;
+                let kept_rate_result = compute_kept_rate(
+                    &editable_region_before_prediction,
+                    &predicted_editable_region,
+                    &settled_editable_region,
+                );
 
-                        Some((buffer, snapshot, cursor_point))
+                let sample_data = if can_collect_data
+                    && let Some(sample_data) = sample_data
+                    && let Ok(context) = sample_data.context_task.await
+                {
+                    Some(SettledEditPredictionSampleData {
+                        repository_url: context.repository_url,
+                        revision: context.revision,
+                        uncommitted_diff: context.uncommitted_diff,
+                        editable_path: sample_data.editable_path,
+                        editable_offset_range: sample_data.editable_offset_range,
+                        buffer_diagnostics: context.buffer_diagnostics,
+                        editable_context: context.editable_context,
+                        future_edit_history_events: sample_data.future_edit_history_events,
+                        navigation_history: sample_data
+                            .navigation_history
+                            .into_iter()
+                            .map(|file| EditPredictionRecentFile {
+                                path: file.path,
+                                cursor_position: file.cursor_position,
+                            })
+                            .collect(),
+                        edit_events_before_quiescence: sample_data.edit_events_before_quiescence,
+                        next_edit_cursor_offset: sample_data.next_edit_cursor_offset,
                     })
-                    .log_err()
-                    .flatten()
-                else {
-                    return Task::ready(anyhow::Ok(None));
+                } else {
+                    None
                 };
 
-                cx.spawn(async move |cx| {
-                    let diagnostic_search_range = match scope {
-                        DiagnosticSearchScope::Local => {
-                            let diagnostic_search_start =
-                                cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE);
-                            let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE;
-                            Point::new(diagnostic_search_start, 0)
-                                ..Point::new(diagnostic_search_end, 0)
-                        }
-                        DiagnosticSearchScope::Global => Default::default(),
-                    };
-
-                    let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location(
-                        active_buffer,
-                        &snapshot,
-                        diagnostic_search_range,
-                        cursor_point,
-                        &project,
-                        cx,
-                    )
-                    .await?
-                    else {
-                        return anyhow::Ok(None);
-                    };
-
-                    let Some(prediction_result) = this
-                        .update(cx, |this, cx| {
-                            this.request_prediction_internal(
-                                project.clone(),
-                                jump_buffer.clone(),
-                                jump_position,
-                                PredictEditsRequestTrigger::Diagnostics,
-                                cx.has_flag::(),
-                                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),
-                                    display_prediction: None,
-                                    model_version: prediction_result.model_version,
-                                    e2e_latency: prediction_result.e2e_latency,
-                                }
-                            },
-                            PredictionRequestedBy::DiagnosticsUpdate,
-                        ))
-                    })
-                })
-            },
-        );
-    }
+                batch.push(SettledEditPrediction {
+                    request_id: request_id.0.to_string(),
+                    settled_editable_region: can_collect_data.then_some(settled_editable_region),
+                    ts_error_count_before_prediction,
+                    ts_error_count_after_prediction,
+                    can_collect_data,
+                    is_in_open_source_repo,
+                    sample_data,
+                    kept_chars: EditPredictionSettledKeptChars {
+                        candidate_new: kept_rate_result.candidate_new_chars,
+                        reference_new: kept_rate_result.reference_new_chars,
+                        candidate_deleted: kept_rate_result.candidate_deleted_chars,
+                        reference_deleted: kept_rate_result.reference_deleted_chars,
+                        kept: kept_rate_result.kept_chars,
+                        correctly_deleted: kept_rate_result.correctly_deleted_chars,
+                        discarded: kept_rate_result.discarded_chars,
+                        context: kept_rate_result.context_chars,
+                        kept_rate: kept_rate_result.kept_rate,
+                        recall_rate: kept_rate_result.recall_rate,
+                    },
+                    example: None,
+                    model_version,
+                    e2e_latency_ms: e2e_latency.as_millis().min(u128::from(u64::MAX)) as u64,
+                });
+            }
 
-    fn predictions_enabled_at(
-        snapshot: &BufferSnapshot,
-        position: Option,
-        cx: &App,
-    ) -> bool {
-        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 batch.is_empty() {
+                break;
+            }
 
-        if let Some(last_position) = position {
-            let settings = snapshot.settings_at(last_position, cx);
+            let result = async {
+                let body = SubmitEditPredictionSettledBatchBody { predictions: batch };
+                let compressed = zstd::encode_all(&serde_json::to_vec(&body)?[..], 3)?;
+                EditPredictionStore::send_api_request::(
+                    |builder| {
+                        Ok(builder
+                            .uri(url.as_ref())
+                            .header("Content-Encoding", "zstd")
+                            .body(compressed.clone().into())?)
+                    },
+                    client.clone(),
+                    llm_token.clone(),
+                    organization_id.clone(),
+                    app_version.clone(),
+                )
+                .await?;
+                anyhow::Ok(())
+            }
+            .await;
 
-            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;
+            if let Err(error) = result {
+                log::error!("failed to submit edit predictions settled: {error:?}");
             }
         }
-
-        true
     }
-
-    pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300);
 }
 
 fn currently_following(project: &Entity, cx: &App) -> bool {
@@ -2474,29 +2501,14 @@ impl EditPredictionStore {
     fn queue_prediction_refresh(
         &mut self,
         project: Entity,
-        request_trigger: PredictEditsRequestTrigger,
         throttle_entity: EntityId,
         cx: &mut Context,
         do_refresh: impl FnOnce(
             WeakEntity,
             &mut AsyncApp,
-        )
-            -> Task>>
+        ) -> Task>>
         + 'static,
     ) {
-        fn select_throttle(
-            project_state: &mut ProjectState,
-            request_trigger: PredictEditsRequestTrigger,
-        ) -> &mut Option<(EntityId, Instant)> {
-            match request_trigger {
-                PredictEditsRequestTrigger::Diagnostics
-                | PredictEditsRequestTrigger::DiagnosticNavigation => {
-                    &mut project_state.last_jump_prediction_refresh
-                }
-                _ => &mut project_state.last_edit_prediction_refresh,
-            }
-        }
-
         let (needs_acceptance_tracking, max_pending_predictions) =
             match all_language_settings(None, cx).edit_predictions.provider {
                 EditPredictionProvider::Zed | EditPredictionProvider::Mercury => (true, 2),
@@ -2515,13 +2527,13 @@ impl EditPredictionStore {
         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 throttle_at_enqueue = *select_throttle(project_state, request_trigger);
+        let throttle_at_enqueue = project_state.last_edit_prediction_refresh;
 
         let task = cx.spawn(async move |this, cx| {
             let throttle_wait = this
                 .update(cx, |this, cx| {
                     let project_state = this.get_or_init_project(&project, cx);
-                    let throttle = *select_throttle(project_state, request_trigger);
+                    let throttle = project_state.last_edit_prediction_refresh;
 
                     let now = cx.background_executor().now();
                     throttle.and_then(|(last_entity, last_timestamp)| {
@@ -2552,12 +2564,12 @@ impl EditPredictionStore {
                 }
 
                 // Another request has been already sent since this was enqueued
-                if *select_throttle(project_state, request_trigger) != throttle_at_enqueue {
+                if project_state.last_edit_prediction_refresh != throttle_at_enqueue {
                     return;
                 }
 
                 let new_refresh = (throttle_entity, cx.background_executor().now());
-                *select_throttle(project_state, request_trigger) = Some(new_refresh);
+                project_state.last_edit_prediction_refresh = Some(new_refresh);
                 is_cancelled = false;
             })
             .ok();
@@ -2566,9 +2578,12 @@ impl EditPredictionStore {
             }
 
             let new_prediction_result = do_refresh(this.clone(), cx).await.log_err().flatten();
-            let new_prediction_metadata = new_prediction_result
-                .as_ref()
-                .map(|(prediction, _)| (prediction.id.clone(), prediction.model_version.clone()));
+            let new_prediction_metadata = new_prediction_result.as_ref().map(|(result, _)| {
+                (
+                    result.prediction.id.clone(),
+                    result.prediction.model_version.clone(),
+                )
+            });
 
             // When a prediction completes, remove it from the pending list, and cancel
             // any pending predictions that were enqueued before it.
@@ -2582,81 +2597,72 @@ impl EditPredictionStore {
                 let new_current_prediction = if !is_cancelled
                     && let Some((prediction_result, requested_by)) = new_prediction_result
                 {
-                    match prediction_result {
-                        EditPredictionResult {
-                            prediction: Ok(prediction),
-                            e2e_latency,
-                            ..
-                        } => {
-                            let new_prediction = CurrentEditPrediction {
-                                requested_by,
-                                prediction,
-                                was_shown: false,
-                                shown_with: None,
-                                e2e_latency,
-                            };
+                    let EditPredictionResult {
+                        prediction,
+                        reject_reason,
+                        e2e_latency,
+                    } = prediction_result;
+
+                    if let Some(reject_reason) = reject_reason {
+                        let should_allow_rating_prediction = matches!(
+                            reject_reason,
+                            EditPredictionRejectReason::Empty
+                                | EditPredictionRejectReason::InterpolatedEmpty
+                        );
+                        let prediction_id = prediction.id.clone();
+                        let model_version = prediction.model_version.clone();
 
-                            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,
-                                        cx,
-                                    );
+                        this.reject_prediction(
+                            prediction_id,
+                            reject_reason,
+                            false,
+                            model_version,
+                            Some(e2e_latency),
+                            cx,
+                        );
 
-                                    Some(new_prediction)
-                                } else {
-                                    this.reject_prediction(
-                                        new_prediction.prediction.id,
-                                        EditPredictionRejectReason::CurrentPreferred,
-                                        false,
-                                        new_prediction.prediction.model_version,
-                                        Some(new_prediction.e2e_latency),
-                                        cx,
-                                    );
-                                    None
-                                }
-                            } else {
-                                Some(new_prediction)
+                        if should_allow_rating_prediction {
+                            this.rateable_predictions.push_front(prediction);
+                            if this.rateable_predictions.len() > 50
+                                && let Some(completion) = this.rateable_predictions.pop_back()
+                            {
+                                this.rated_predictions.remove(&completion.id);
                             }
                         }
-                        EditPredictionResult {
-                            id,
-                            prediction: Err(reject_reason),
-                            display_prediction,
-                            model_version,
+
+                        None
+                    } else {
+                        let new_prediction = CurrentEditPrediction {
+                            requested_by,
+                            prediction,
+                            was_shown: false,
+                            shown_with: None,
                             e2e_latency,
-                        } => {
-                            let should_show_rejected_prediction = matches!(
-                                reject_reason,
-                                EditPredictionRejectReason::Empty
-                                    | EditPredictionRejectReason::InterpolatedEmpty
-                            );
-
-                            this.reject_prediction(
-                                id,
-                                reject_reason,
-                                false,
-                                model_version,
-                                Some(e2e_latency),
-                                cx,
-                            );
+                        };
 
-                            if should_show_rejected_prediction
-                                && let Some(display_prediction) = display_prediction
-                            {
-                                this.shown_predictions.push_front(display_prediction);
-                                if this.shown_predictions.len() > 50
-                                    && let Some(completion) = this.shown_predictions.pop_back()
-                                {
-                                    this.rated_predictions.remove(&completion.id);
-                                }
-                            }
+                        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,
+                                    cx,
+                                );
 
-                            None
+                                Some(new_prediction)
+                            } else {
+                                this.reject_prediction(
+                                    new_prediction.prediction.id,
+                                    EditPredictionRejectReason::CurrentPreferred,
+                                    false,
+                                    new_prediction.prediction.model_version,
+                                    Some(new_prediction.e2e_latency),
+                                    cx,
+                                );
+                                None
+                            }
+                        } else {
+                            Some(new_prediction)
                         }
                     }
                 } else {
@@ -2723,7 +2729,6 @@ impl EditPredictionStore {
             active_buffer.clone(),
             position,
             trigger,
-            cx.has_flag::(),
             cx,
         )
     }
@@ -2734,7 +2739,6 @@ impl EditPredictionStore {
         active_buffer: Entity,
         position: language::Anchor,
         trigger: PredictEditsRequestTrigger,
-        allow_jump: bool,
         cx: &mut Context,
     ) -> Task>> {
         let is_cloud_zeta = matches!(self.edit_prediction_model, EditPredictionModel::Zeta)
@@ -2754,12 +2758,27 @@ impl EditPredictionStore {
         }
 
         self.get_or_init_project(&project, cx);
-        let project_state = self.projects.get(&project.entity_id()).unwrap();
-        let stored_events = project_state.events(cx);
-        let has_events = !stored_events.is_empty();
+        let (stored_events, prompt_history_boundary, debug_tx) = {
+            let project_state = self.projects.get(&project.entity_id()).unwrap();
+            (
+                project_state.events(cx),
+                Some(PromptHistoryBoundary {
+                    first_event_seq: project_state
+                        .last_event
+                        .as_ref()
+                        .map_or(project_state.next_last_event_seq, |last_event| {
+                            last_event.seq
+                        }),
+                    snapshot: project_state
+                        .last_event
+                        .as_ref()
+                        .map(|last_event| last_event.new_snapshot.clone()),
+                }),
+                project_state.debug_tx.clone(),
+            )
+        };
         let events: Vec> =
             stored_events.iter().map(|e| e.event.clone()).collect();
-        let debug_tx = project_state.debug_tx.clone();
 
         let snapshot = active_buffer.read(cx).snapshot();
         let cursor_point = position.to_point(&snapshot);
@@ -2769,21 +2788,35 @@ impl EditPredictionStore {
             Point::new(diagnostic_search_start, 0)..Point::new(diagnostic_search_end, 0);
 
         let related_files = self.context_for_project(&project, cx);
+        let allow_jump = is_cloud_zeta && cx.has_flag::();
         let mode = match all_language_settings(snapshot.file(), cx).edit_predictions_mode() {
             EditPredictionsMode::Eager => PredictEditsMode::Eager,
             EditPredictionsMode::Subtle => PredictEditsMode::Subtle,
         };
 
         let buffer_id = active_buffer.read(cx).remote_id();
-        let repo_url = project
+        let (repository_url, revision) = project
             .read(cx)
             .git_store()
             .read(cx)
             .repository_and_path_for_buffer_id(buffer_id, cx)
-            .and_then(|(repo, _)| repo.read(cx).default_remote_url());
+            .map(|(repository, _)| {
+                let snapshot = repository.read(cx).snapshot();
+                (
+                    snapshot
+                        .remote_origin_url
+                        .clone()
+                        .or_else(|| snapshot.remote_upstream_url.clone()),
+                    snapshot
+                        .head_commit
+                        .as_ref()
+                        .map(|commit| commit.sha.to_string()),
+                )
+            })
+            .unwrap_or_default();
 
         let is_staff_zed_repo = cx.is_staff()
-            && repo_url
+            && repository_url
                 .as_ref()
                 .is_some_and(|url| is_zed_industries_repo(url));
         let is_open_source = is_staff_zed_repo
@@ -2797,60 +2830,65 @@ impl EditPredictionStore {
             && is_open_source
             && self.is_data_collection_enabled(cx)
             && matches!(self.edit_prediction_model, EditPredictionModel::Zeta);
-        let capture_worktree_id = snapshot.file().map(|file| file.worktree_id(cx));
+        let editable_context = allow_jump.then(|| {
+            self.collect_editable_context(
+                project.clone(),
+                active_buffer.clone(),
+                position,
+                Vec::new(),
+                vec![ContextSource::CurrentFile, ContextSource::EditHistory],
+                cx,
+            )
+        });
         let inputs = EditPredictionModelInput {
             project: project.clone(),
             buffer: active_buffer,
             snapshot,
             position,
             events,
-            stored_events: stored_events.clone(),
             related_files,
+            editable_context,
             mode,
             trigger,
             diagnostic_search_range,
             debug_tx,
             can_collect_data,
             is_open_source,
+            allow_jump,
         };
 
         let task = match self.edit_prediction_model {
             EditPredictionModel::Zeta => {
-                let capture_data = if let Some(worktree_id) = capture_worktree_id
-                    && can_collect_data
-                {
-                    let uncommitted_diff_snapshot = uncommitted_diffs_for_events(
-                        project.clone(),
-                        worktree_id,
-                        stored_events.clone(),
-                        cx,
-                    )
-                    .shared();
-                    jump_example::try_start_jump_example_capture(
-                        project_state,
-                        uncommitted_diff_snapshot.clone(),
-                        inputs.project.clone(),
-                        inputs.snapshot.clone(),
-                        inputs.position,
-                        match trigger {
-                            PredictEditsRequestTrigger::Diagnostics
-                            | PredictEditsRequestTrigger::DiagnosticNavigation => {
-                                JumpExampleTrigger::Diagnostic
-                            }
-                            _ => JumpExampleTrigger::Prediction,
-                        },
-                        stored_events,
-                        inputs.diagnostic_search_range.clone(),
-                        can_collect_data,
-                        is_open_source,
-                        cx,
-                    );
-                    rand::random_ratio(1, 10).then(|| uncommitted_diff_snapshot)
-                } else {
-                    None
-                };
-
-                zeta::request_prediction_with_zeta(self, inputs, capture_data, repo_url, cx)
+                let context_task = can_collect_data
+                    .then(|| {
+                        let editable_context_task = self.collect_editable_context(
+                            inputs.project.clone(),
+                            inputs.buffer.clone(),
+                            inputs.position,
+                            Vec::new(),
+                            vec![ContextSource::CurrentFile, ContextSource::EditHistory],
+                            cx,
+                        );
+                        capture_prediction_context(
+                            inputs.project.clone(),
+                            inputs.buffer.clone(),
+                            inputs.position,
+                            stored_events,
+                            repository_url.clone(),
+                            revision,
+                            editable_context_task,
+                            cx,
+                        )
+                    })
+                    .flatten();
+                zeta::request_prediction_with_zeta(
+                    self,
+                    inputs,
+                    context_task,
+                    prompt_history_boundary,
+                    repository_url,
+                    cx,
+                )
             }
             EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx),
             EditPredictionModel::Mercury => {
@@ -2859,148 +2897,7 @@ impl EditPredictionStore {
             }
         };
 
-        cx.spawn(async move |this, cx| {
-            let prediction = task.await?;
-
-            // Only fall back to diagnostics-based prediction if we got a
-            // the model had nothing to suggest for the buffer
-            if prediction.is_none()
-                && allow_jump
-                && has_events
-                && !matches!(
-                    trigger,
-                    PredictEditsRequestTrigger::Diagnostics
-                        | PredictEditsRequestTrigger::DiagnosticNavigation
-                )
-            {
-                this.update(cx, |this, cx| {
-                    this.refresh_prediction_from_diagnostics(
-                        project,
-                        DiagnosticSearchScope::Local,
-                        cx,
-                    );
-                })?;
-                return anyhow::Ok(None);
-            }
-
-            Ok(prediction)
-        })
-    }
-
-    pub(crate) 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)>> {
-        let collaborator_cursor_rows: Vec = active_buffer_snapshot
-            .selections_in_range(
-                Anchor::min_max_range_for_buffer(active_buffer_snapshot.remote_id()),
-                false,
-            )
-            .flat_map(|(_, _, _, selections)| {
-                selections.map(|s| s.head().to_point(active_buffer_snapshot).row)
-            })
-            .collect();
-
-        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) {
-                    return None;
-                }
-                let near_collaborator = collaborator_cursor_rows.iter().any(|&collab_row| {
-                    range.start.row.abs_diff(collab_row) <= DIAGNOSTIC_LINES_RANGE
-                });
-                let near_local = active_buffer_cursor_point.row.abs_diff(range.start.row)
-                    <= DIAGNOSTIC_LINES_RANGE;
-                if near_collaborator && !near_local {
-                    return None;
-                }
-                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 mut candidates: Vec<(ProjectPath, usize)> = project.read_with(cx, |project, cx| {
-                project
-                    .diagnostic_summaries(false, cx)
-                    .filter(|(path, _, _)| Some(path) != active_buffer_path.as_ref())
-                    .map(|(path, _, _)| {
-                        let shared_prefix = path
-                            .path
-                            .components()
-                            .zip(
-                                active_buffer_path
-                                    .as_ref()
-                                    .map(|p| p.path.components())
-                                    .unwrap_or_default(),
-                            )
-                            .take_while(|(a, b)| a == b)
-                            .count();
-                        (path, shared_prefix)
-                    })
-                    .collect()
-            });
-
-            candidates.sort_by_key(|c| std::cmp::Reverse(c.1));
-
-            for (path, _) in candidates {
-                let candidate_buffer = project
-                    .update(cx, |project, cx| project.open_buffer(path, cx))
-                    .await?;
-
-                let (has_collaborators, diagnostic_position) =
-                    candidate_buffer.read_with(cx, |buffer, _cx| {
-                        let snapshot = buffer.snapshot();
-                        let has_collaborators = snapshot
-                            .selections_in_range(
-                                Anchor::min_max_range_for_buffer(snapshot.remote_id()),
-                                false,
-                            )
-                            .next()
-                            .is_some();
-                        let position = buffer
-                            .buffer_diagnostics(None)
-                            .into_iter()
-                            .min_by_key(|entry| entry.diagnostic.severity)
-                            .map(|entry| entry.range.start);
-                        (has_collaborators, position)
-                    });
-
-                if has_collaborators {
-                    continue;
-                }
-
-                if let Some(position) = diagnostic_position {
-                    jump_location = Some((candidate_buffer, position));
-                    break;
-                }
-            }
-        }
-
-        anyhow::Ok(jump_location)
+        task
     }
 
     async fn send_raw_llm_request(
@@ -3035,7 +2932,7 @@ impl EditPredictionStore {
     }
 
     pub(crate) async fn send_v3_request(
-        input: ZetaPromptInput,
+        input: Zeta2PromptInput,
         preferred_experiment: Option,
         client: Arc,
         llm_token: LlmApiToken,
@@ -3044,11 +2941,62 @@ impl EditPredictionStore {
         trigger: PredictEditsRequestTrigger,
         mode: PredictEditsMode,
     ) -> Result<(PredictEditsV3Response, Option)> {
-        let url = client
-            .http_client()
-            .build_zed_llm_url("/predict_edits/v3", &[])?;
-
         let request = PredictEditsV3Request { input };
+        Self::send_predict_edits_request(
+            "/predict_edits/v3",
+            request,
+            preferred_experiment,
+            client,
+            llm_token,
+            organization_id,
+            app_version,
+            trigger,
+            mode,
+        )
+        .await
+    }
+
+    pub(crate) async fn send_v4_request(
+        input: Zeta3PromptInput,
+        preferred_experiment: Option,
+        client: Arc,
+        llm_token: LlmApiToken,
+        organization_id: Option,
+        app_version: Version,
+        trigger: PredictEditsRequestTrigger,
+        mode: PredictEditsMode,
+    ) -> Result<(PredictEditsV4Response, Option)> {
+        let request = PredictEditsV4Request { input };
+        Self::send_predict_edits_request(
+            "/predict_edits/v4",
+            request,
+            preferred_experiment,
+            client,
+            llm_token,
+            organization_id,
+            app_version,
+            trigger,
+            mode,
+        )
+        .await
+    }
+
+    async fn send_predict_edits_request(
+        path: &str,
+        request: Req,
+        preferred_experiment: Option,
+        client: Arc,
+        llm_token: LlmApiToken,
+        organization_id: Option,
+        app_version: Version,
+        trigger: PredictEditsRequestTrigger,
+        mode: PredictEditsMode,
+    ) -> Result<(Res, Option)>
+    where
+        Req: serde::Serialize,
+        Res: serde::de::DeserializeOwned,
+    {
+        let url = client.http_client().build_zed_llm_url(path, &[])?;
         let request_id = uuid::Uuid::new_v4().to_string();
 
         let json_bytes = serde_json::to_vec(&request)?;
@@ -3156,13 +3104,12 @@ impl EditPredictionStore {
             });
     }
 
-    #[cfg(feature = "cli-support")]
     pub fn collect_editable_context(
         &mut self,
         project: Entity,
         buffer: Entity,
         cursor_position: language::Anchor,
-        oracle_paths: Vec>,
+        oracle_targets: Vec,
         context_sources: Vec,
         cx: &mut Context,
     ) -> Task>> {
@@ -3193,7 +3140,7 @@ impl EditPredictionStore {
                 buffer,
                 cursor_position,
                 edit_history,
-                oracle_paths,
+                oracle_targets,
                 context_sources,
                 cx,
             )
@@ -3319,12 +3266,12 @@ impl EditPredictionStore {
             })
     }
 
-    pub fn shown_predictions(&self) -> impl DoubleEndedIterator {
-        self.shown_predictions.iter()
+    pub fn rateable_predictions(&self) -> impl DoubleEndedIterator {
+        self.rateable_predictions.iter()
     }
 
-    pub fn shown_completions_len(&self) -> usize {
-        self.shown_predictions.len()
+    pub fn rateable_predictions_count(&self) -> usize {
+        self.rateable_predictions.len()
     }
 
     pub fn is_prediction_rated(&self, id: &EditPredictionId) -> bool {
@@ -3447,7 +3394,7 @@ fn merge_trailing_events_if_needed(
             merge_anchor_ranges(&merged_edit_range, &event.total_edit_range, latest_snapshot);
     }
 
-    if let Some((diff, edit_range)) = compute_diff_between_snapshots_in_range(
+    if let Some((diff, old_range, new_range)) = compute_diff_between_snapshots_in_range(
         &oldest_snapshot,
         newest_snapshot,
         &merged_edit_range,
@@ -3463,6 +3410,8 @@ fn merge_trailing_events_if_needed(
                     old_path: old_path.clone(),
                     path: path.clone(),
                     diff,
+                    old_range,
+                    new_range: new_range.clone(),
                     in_open_source_repo: *in_open_source_repo,
                     predicted: events.range(merge_start..).all(|event| {
                         matches!(
@@ -3476,8 +3425,8 @@ fn merge_trailing_events_if_needed(
                 }),
                 old_snapshot: oldest_snapshot.clone(),
                 new_snapshot_version: newest_snapshot.version.clone(),
-                total_edit_range: newest_snapshot.anchor_before(edit_range.start)
-                    ..newest_snapshot.anchor_before(edit_range.end),
+                total_edit_range: newest_snapshot.anchor_before(new_range.start)
+                    ..newest_snapshot.anchor_before(new_range.end),
                 file_context: oldest_event.file_context.clone(),
             },
         };
diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs
index 5195393f2f6982..eb0851ffb22dde 100644
--- a/crates/edit_prediction/src/edit_prediction_tests.rs
+++ b/crates/edit_prediction/src/edit_prediction_tests.rs
@@ -3,29 +3,31 @@ use clock::FakeSystemClock;
 use clock::ReplicaId;
 use cloud_api_types::{
     CreateLlmTokenResponse, LlmToken, Organization, OrganizationConfiguration,
-    OrganizationEditPredictionConfiguration, OrganizationId, SubmitEditPredictionSettledBody,
-    SubmitEditPredictionSettledResponse,
+    OrganizationEditPredictionConfiguration, OrganizationId, SettledEditPrediction,
+    SubmitEditPredictionSettledBatchBody, SubmitEditPredictionSettledResponse,
 };
 use cloud_llm_client::{
     EditPredictionRejectReason, EditPredictionRejection, PredictEditsRequestTrigger,
     RejectEditPredictionsBody,
     predict_edits_v3::{PredictEditsV3Request, PredictEditsV3Response},
+    predict_edits_v4::{PredictEditsV4Request, PredictEditsV4Response},
 };
 use db::AppDatabase;
 use edit_prediction_types::EditPredictionRequestTrigger;
+use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _, FeatureFlagsSettings};
 use futures::{
     AsyncReadExt, FutureExt, StreamExt,
     channel::{mpsc, oneshot},
 };
 use gpui::App;
 use gpui::{
-    Entity, TestAppContext,
+    Entity, TestAppContext, UpdateGlobal,
     http_client::{FakeHttpClient, Response},
 };
 use indoc::indoc;
 use language::{
-    Anchor, Buffer, BufferEditSource, Capability, CursorShape, Diagnostic, DiagnosticEntry,
-    DiagnosticSet, DiagnosticSeverity, Operation, Point, Selection, SelectionGoal,
+    Anchor, Buffer, Capability, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity,
+    Point, unified_diff_with_offsets,
 };
 use lsp::LanguageServerId;
 use parking_lot::Mutex;
@@ -41,21 +43,21 @@ use util::{
 };
 use uuid::Uuid;
 use workspace::{AppState, CollaboratorId, MultiWorkspace};
-use zeta_prompt::ZetaPromptInput;
+use zeta_prompt::Zeta2PromptInput;
 
+use crate::prediction::EditPredictionInputs;
 use crate::udiff::apply_diff_to_string;
 use crate::{
     BufferEditPrediction, EDIT_PREDICTION_SETTLED_QUIESCENCE, EditPredictionId,
-    EditPredictionJumpsFeatureFlag, EditPredictionStore, REJECT_REQUEST_DEBOUNCE,
-    REQUEST_TIMEOUT_BACKOFF,
+    EditPredictionStore, REJECT_REQUEST_DEBOUNCE, REQUEST_TIMEOUT_BACKOFF,
 };
 
 use super::*;
 
 #[gpui::test]
 async fn test_current_state(cx: &mut TestAppContext) {
-    enable_edit_prediction_jumps(cx);
     let (ep_store, mut requests) = init_test_with_fake_client(cx);
+    cx.update(|cx| set_jumps_feature_flag_override(cx, "on"));
     let fs = FakeFs::new(cx.executor());
     fs.insert_tree(
         "/root",
@@ -94,12 +96,12 @@ async fn test_current_state(cx: &mut TestAppContext) {
             cx,
         )
     });
-    let (request, respond_tx) = requests.predict.next().await.unwrap();
+    let (_request, respond_tx) = requests.predict_v4.next().await.unwrap();
 
     respond_tx
-        .send(model_response(
-            &request,
-            indoc! {r"
+        .send(PredictEditsV4Response {
+            request_id: Uuid::new_v4().to_string(),
+            patch: indoc! {r"
                 --- a/root/1.txt
                 +++ b/root/1.txt
                 @@ ... @@
@@ -107,8 +109,10 @@ async fn test_current_state(cx: &mut TestAppContext) {
                 -How
                 +How are you?
                  Bye
-            "},
-        ))
+            "}
+            .to_string(),
+            model_version: None,
+        })
         .unwrap();
 
     cx.run_until_parked();
@@ -123,89 +127,16 @@ async fn test_current_state(cx: &mut TestAppContext) {
     ep_store.update(cx, |ep_store, cx| {
         ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project, cx);
     });
-
-    // 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_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppContext) {
-    enable_edit_prediction_jumps(cx);
+async fn test_refresh_prediction_from_buffer_suppressed_while_following(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"
+            "foo.md":  "Hello!\nHow\nBye\n"
         }),
     )
     .await;
@@ -216,7 +147,6 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon
         AppState::set_global(app_state.clone(), cx);
         app_state
     });
-
     let multi_workspace =
         cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
     let workspace = multi_workspace
@@ -225,198 +155,41 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon
     cx.update(|cx| {
         AppState::set_global(workspace.read(cx).app_state().clone(), cx);
     });
-    let _ = app_state;
+    drop(app_state);
 
-    let buffer1 = project
+    let buffer = 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);
+            let path = project.find_project_path(path!("root/foo.md"), cx).unwrap();
             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));
+    let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
+    let position = snapshot.anchor_before(language::Point::new(1, 3));
+
+    multi_workspace
+        .update(cx, |multi_workspace, window, cx| {
+            multi_workspace.workspace().update(cx, |workspace, cx| {
+                workspace.start_following(CollaboratorId::Agent, window, cx);
+            });
+        })
+        .unwrap();
+    cx.run_until_parked();
 
     ep_store.update(cx, |ep_store, cx| {
         ep_store.register_project(&project, cx);
-        ep_store.register_buffer(&buffer1, &project, cx);
+        ep_store.register_buffer(&buffer, &project, cx);
         ep_store.refresh_prediction_from_buffer(
             project.clone(),
-            buffer1.clone(),
+            buffer.clone(),
             position,
             EditPredictionRequestTrigger::Other,
             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| {
-        ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project, cx);
-    });
-
-    let _ = multi_workspace.update(cx, |multi_workspace, window, cx| {
-        multi_workspace.workspace().update(cx, |workspace, cx| {
-            workspace.start_following(CollaboratorId::Agent, window, cx);
-        });
-    });
-    cx.run_until_parked();
-
-    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.clone()],
-                        version: None,
-                    },
-                    None,
-                    language::DiagnosticSourceKind::Pushed,
-                    &[],
-                    cx,
-                )
-                .unwrap();
-        });
-    });
-
-    cx.run_until_parked();
-    assert_no_predict_request_ready(&mut requests.predict);
-
-    let _ = multi_workspace.update(cx, |multi_workspace, window, cx| {
-        multi_workspace.workspace().update(cx, |workspace, cx| {
-            workspace.unfollow(CollaboratorId::Agent, window, cx);
-        });
-    });
-    cx.run_until_parked();
-
-    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"))
-        );
-    });
-}
-
-#[gpui::test]
-async fn test_diagnostics_refresh_suppressed_after_agent_edit(cx: &mut TestAppContext) {
-    enable_edit_prediction_jumps(cx);
-    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 buffer = 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();
-
-    ep_store.update(cx, |ep_store, cx| {
-        ep_store.register_project(&project, cx);
-        ep_store.register_buffer(&buffer, &project, cx);
-    });
-
-    buffer.update(cx, |buffer, cx| {
-        buffer.start_transaction();
-        buffer.edit([(Point::new(1, 3)..Point::new(1, 3), "!")], None, cx);
-        buffer.end_transaction_with_source(BufferEditSource::Agent, cx);
-    });
     cx.run_until_parked();
 
-    update_test_diagnostics(&project, path!("/root/2.txt"), "Sentence is incomplete", cx);
-    cx.run_until_parked();
     assert_no_predict_request_ready(&mut requests.predict);
-
-    buffer.update(cx, |buffer, cx| {
-        buffer.edit([(Point::new(1, 4)..Point::new(1, 4), "?")], None, cx);
-    });
-    cx.run_until_parked();
-
-    update_test_diagnostics(
-        &project,
-        path!("/root/2.txt"),
-        "Sentence is still incomplete",
-        cx,
-    );
-
-    let (_request, respond_tx) = requests.predict.next().await.unwrap();
-    respond_tx.send(empty_response()).unwrap();
-    cx.run_until_parked();
 }
 
 #[gpui::test]
@@ -482,7 +255,7 @@ async fn test_simple_request(cx: &mut TestAppContext) {
         ))
         .unwrap();
 
-    let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap();
+    let prediction = prediction_task.await.unwrap().unwrap().prediction;
 
     assert_eq!(prediction.edits.len(), 1);
     assert_eq!(
@@ -492,6 +265,68 @@ async fn test_simple_request(cx: &mut TestAppContext) {
     assert_eq!(prediction.edits[0].1.as_ref(), " are you?");
 }
 
+#[gpui::test]
+async fn test_zeta_request_sends_settled_body_when_data_collection_is_disabled(
+    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.register_buffer(&buffer, &project, cx);
+    });
+
+    let prediction_task = ep_store.update(cx, |ep_store, cx| {
+        ep_store.request_prediction(
+            &project,
+            &buffer,
+            position,
+            PredictEditsRequestTrigger::Other,
+            cx,
+        )
+    });
+
+    let (request, respond_tx) = requests.predict.next().await.unwrap();
+    assert!(!request.input.can_collect_data);
+    respond_tx
+        .send(model_response(&request, SIMPLE_DIFF))
+        .unwrap();
+
+    prediction_task.await.unwrap().unwrap();
+    cx.run_until_parked();
+    cx.executor()
+        .advance_clock(EDIT_PREDICTION_SETTLED_QUIESCENCE);
+    cx.run_until_parked();
+
+    let settled_request = requests
+        .settled
+        .next()
+        .now_or_never()
+        .flatten()
+        .expect("settled request should be sent");
+    assert!(!settled_request.can_collect_data);
+    assert_eq!(settled_request.settled_editable_region, None);
+    assert_eq!(settled_request.sample_data, None);
+}
+
 #[gpui::test]
 async fn test_request_events(cx: &mut TestAppContext) {
     let (ep_store, mut requests) = init_test_with_fake_client(cx);
@@ -565,7 +400,7 @@ async fn test_request_events(cx: &mut TestAppContext) {
         ))
         .unwrap();
 
-    let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap();
+    let prediction = prediction_task.await.unwrap().unwrap().prediction;
 
     assert_eq!(prediction.edits.len(), 1);
     assert_eq!(prediction.edits[0].1.as_ref(), " are you?");
@@ -1455,6 +1290,7 @@ async fn test_empty_prediction(cx: &mut TestAppContext) {
     let position = snapshot.anchor_before(language::Point::new(1, 3));
 
     ep_store.update(cx, |ep_store, cx| {
+        ep_store.register_buffer(&buffer, &project, cx);
         ep_store.refresh_prediction_from_buffer(
             project.clone(),
             buffer.clone(),
@@ -1478,7 +1314,7 @@ async fn test_empty_prediction(cx: &mut TestAppContext) {
                 .prediction_at(&buffer, None, &project, cx)
                 .is_none()
         );
-        let shown_predictions = ep_store.shown_predictions().collect::>();
+        let shown_predictions = ep_store.rateable_predictions().collect::>();
         assert_eq!(shown_predictions.len(), 1);
         assert_eq!(shown_predictions[0].id.to_string(), id);
         assert!(shown_predictions[0].edits.is_empty());
@@ -1495,13 +1331,26 @@ async fn test_empty_prediction(cx: &mut TestAppContext) {
     assert_eq!(
         &reject_request.rejections,
         &[EditPredictionRejection {
-            request_id: id,
+            request_id: id.clone(),
             reason: EditPredictionRejectReason::Empty,
             was_shown: false,
             model_version: Some("zeta2:test-empty".to_string()),
             e2e_latency_ms: Some(0),
         }]
     );
+    cx.executor()
+        .advance_clock(EDIT_PREDICTION_SETTLED_QUIESCENCE);
+    cx.run_until_parked();
+
+    let settled_request = requests
+        .settled
+        .next()
+        .now_or_never()
+        .flatten()
+        .expect("empty prediction should still send settled request");
+    assert_eq!(settled_request.request_id, id);
+    assert_eq!(settled_request.settled_editable_region, None);
+    assert_eq!(settled_request.sample_data, None);
 }
 
 #[gpui::test]
@@ -1540,7 +1389,7 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) {
     let (request, respond_tx) = requests.predict.next().await.unwrap();
 
     buffer.update(cx, |buffer, cx| {
-        buffer.set_text("Hello!\nHow are you?\nBye", cx);
+        buffer.edit([(10..10, " are you?")], None, cx);
     });
 
     let mut response = model_response(&request, SIMPLE_DIFF);
@@ -1556,14 +1405,13 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) {
                 .prediction_at(&buffer, None, &project, cx)
                 .is_none()
         );
-        let shown_predictions = ep_store.shown_predictions().collect::>();
+        let shown_predictions = ep_store.rateable_predictions().collect::>();
         assert_eq!(shown_predictions.len(), 1);
         assert_eq!(shown_predictions[0].id.to_string(), id);
         assert!(shown_predictions[0].edits.is_empty());
         assert!(shown_predictions[0].editable_range.is_some());
     });
 
-    // prediction is reported as rejected
     let (reject_request, _) = requests.reject.next().await.unwrap();
 
     assert_eq!(
@@ -1578,18 +1426,8 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) {
     );
 }
 
-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) {
+async fn test_interpolate_failed(cx: &mut TestAppContext) {
     let (ep_store, mut requests) = init_test_with_fake_client(cx);
     let fs = FakeFs::new(cx.executor());
     fs.insert_tree(
@@ -1622,9 +1460,88 @@ async fn test_replace_current(cx: &mut TestAppContext) {
     });
 
     let (request, respond_tx) = requests.predict.next().await.unwrap();
-    let first_response = model_response(&request, SIMPLE_DIFF);
-    let first_id = first_response.request_id.clone();
-    respond_tx.send(first_response).unwrap();
+
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit([(10..10, " is it?")], None, cx);
+    });
+
+    let mut response = model_response(&request, SIMPLE_DIFF);
+    response.model_version = Some("zeta2:test-interpolate-failed".to_string());
+    let id = response.request_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()
+        );
+        assert!(ep_store.rateable_predictions().next().is_none());
+    });
+
+    let (reject_request, _) = requests.reject.next().await.unwrap();
+
+    assert_eq!(
+        &reject_request.rejections,
+        &[EditPredictionRejection {
+            request_id: id,
+            reason: EditPredictionRejectReason::InterpolateFailed,
+            was_shown: false,
+            model_version: Some("zeta2:test-interpolate-failed".to_string()),
+            e2e_latency_ms: Some(0),
+        }]
+    );
+}
+
+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,
+            EditPredictionRequestTrigger::Other,
+            cx,
+        );
+    });
+
+    let (request, respond_tx) = requests.predict.next().await.unwrap();
+    let first_response = model_response(&request, SIMPLE_DIFF);
+    let first_id = first_response.request_id.clone();
+    respond_tx.send(first_response).unwrap();
 
     cx.run_until_parked();
 
@@ -1777,7 +1694,7 @@ async fn test_current_preferred(cx: &mut TestAppContext) {
             first_id
         );
         let shown_prediction_ids = ep_store
-            .shown_predictions()
+            .rateable_predictions()
             .map(|prediction| prediction.id.to_string())
             .collect::>();
         assert!(shown_prediction_ids.is_empty());
@@ -2065,120 +1982,6 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) {
     );
 }
 
-#[gpui::test]
-async fn test_jump_and_edit_throttles_are_independent(cx: &mut TestAppContext) {
-    enable_edit_prediction_jumps(cx);
-    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",
-            "bar.md": "Hola!\nComo\nAdios\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.set_active_path(Some(path.clone()), cx);
-            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.register_project(&project, cx);
-        ep_store.register_buffer(&buffer, &project, cx);
-    });
-
-    // First edit request - no prior edit, so not throttled.
-    ep_store.update(cx, |ep_store, cx| {
-        ep_store.refresh_prediction_from_buffer(
-            project.clone(),
-            buffer.clone(),
-            position,
-            EditPredictionRequestTrigger::Other,
-            cx,
-        );
-    });
-    let (_edit_request, edit_response_tx) = requests.predict.next().await.unwrap();
-    edit_response_tx.send(empty_response()).unwrap();
-    cx.run_until_parked();
-
-    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()
-    };
-
-    // First jump request triggered by diagnostic event on buffer - no prior jump, so not throttled (independent from edit).
-    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/bar.md")).unwrap(),
-                        diagnostics: vec![diagnostic],
-                        version: None,
-                    },
-                    None,
-                    language::DiagnosticSourceKind::Pushed,
-                    &[],
-                    cx,
-                )
-                .unwrap();
-        });
-    });
-    let (_jump_request, jump_response_tx) = requests.predict.next().await.unwrap();
-    jump_response_tx.send(empty_response()).unwrap();
-    cx.run_until_parked();
-
-    // Second edit request - should be throttled by the first edit.
-    ep_store.update(cx, |ep_store, cx| {
-        ep_store.refresh_prediction_from_buffer(
-            project.clone(),
-            buffer.clone(),
-            position,
-            EditPredictionRequestTrigger::Other,
-            cx,
-        );
-    });
-    assert_no_predict_request_ready(&mut requests.predict);
-
-    // Second jump request - should be throttled by the first jump.
-    ep_store.update(cx, |ep_store, cx| {
-        ep_store.refresh_prediction_from_diagnostics(
-            project.clone(),
-            DiagnosticSearchScope::Global,
-            cx,
-        );
-    });
-    assert_no_predict_request_ready(&mut requests.predict);
-
-    // Wait for both throttles to expire.
-    cx.background_executor
-        .advance_clock(EditPredictionStore::THROTTLE_TIMEOUT);
-    cx.background_executor.run_until_parked();
-    cx.run_until_parked();
-
-    // Both requests should now go through.
-    let (_request_1, response_tx_1) = requests.predict.next().await.unwrap();
-    response_tx_1.send(empty_response()).unwrap();
-    cx.run_until_parked();
-
-    let (_request_2, response_tx_2) = requests.predict.next().await.unwrap();
-    response_tx_2.send(empty_response()).unwrap();
-    cx.run_until_parked();
-}
-
 #[gpui::test]
 async fn test_cloud_timeout_backs_off_zeta_requests(cx: &mut TestAppContext) {
     let (ep_store, mut requests) = init_test_with_fake_client(cx);
@@ -2686,6 +2489,7 @@ fn test_active_buffer_diagnostics_collection_limits(cx: &mut TestAppContext) {
     let text = (0..300)
         .map(|row| format!("line {row} has some diagnostic context\n"))
         .collect::();
+    let long_message = "diagnostic message ".repeat(1000);
     let buffer = cx.new(|cx| Buffer::local(&text, cx));
 
     buffer.update(cx, |buffer, cx| {
@@ -2695,7 +2499,7 @@ fn test_active_buffer_diagnostics_collection_limits(cx: &mut TestAppContext) {
                 range: text::PointUtf16::new(150, 0)..text::PointUtf16::new(150, 4),
                 diagnostic: Diagnostic {
                     severity: DiagnosticSeverity::ERROR,
-                    message: "long snippet".to_string(),
+                    message: long_message.clone(),
                     group_id: 1,
                     is_primary: true,
                     source_kind: language::DiagnosticSourceKind::Pushed,
@@ -2716,7 +2520,15 @@ fn test_active_buffer_diagnostics_collection_limits(cx: &mut TestAppContext) {
     );
 
     assert_eq!(active_buffer_diagnostics.len(), 1);
-    assert!(active_buffer_diagnostics[0].snippet.len() <= 512 * 3 + 2);
+    assert!(
+        active_buffer_diagnostics[0].message.len()
+            <= crate::zeta::MAX_ACTIVE_BUFFER_DIAGNOSTIC_MESSAGE_TOKENS_TO_COLLECT * 3 + 2
+    );
+    assert!(active_buffer_diagnostics[0].message.len() < long_message.len());
+    assert!(
+        active_buffer_diagnostics[0].snippet.len()
+            <= crate::zeta::MAX_ACTIVE_BUFFER_DIAGNOSTIC_SNIPPET_TOKENS_TO_COLLECT * 3 + 2
+    );
     assert!(active_buffer_diagnostics[0].snippet.len() < text.len());
 }
 
@@ -2763,63 +2575,25 @@ fn prompt_from_request(request: &PredictEditsV3Request) -> String {
         .expect("default zeta prompt formatting should succeed in edit prediction tests")
 }
 
-fn assert_no_predict_request_ready(
-    requests: &mut mpsc::UnboundedReceiver<(
-        PredictEditsV3Request,
-        oneshot::Sender,
-    )>,
+fn assert_no_predict_request_ready(
+    requests: &mut mpsc::UnboundedReceiver<(Request, oneshot::Sender)>,
 ) {
     if requests.next().now_or_never().flatten().is_some() {
         panic!("Unexpected prediction request while throttled.");
     }
 }
 
-fn enable_edit_prediction_jumps(cx: &mut TestAppContext) {
-    cx.update(|cx| {
-        cx.update_flags(true, vec![EditPredictionJumpsFeatureFlag::NAME.to_string()]);
-    });
-}
-
-fn update_test_diagnostics(
-    project: &Entity,
-    path: &str,
-    message: &str,
-    cx: &mut TestAppContext,
-) {
-    let diagnostic = lsp::Diagnostic {
-        range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)),
-        severity: Some(lsp::DiagnosticSeverity::ERROR),
-        message: message.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).unwrap(),
-                        diagnostics: vec![diagnostic],
-                        version: None,
-                    },
-                    None,
-                    language::DiagnosticSourceKind::Pushed,
-                    &[],
-                    cx,
-                )
-                .unwrap();
-        });
-    });
-}
-
 struct RequestChannels {
     predict: mpsc::UnboundedReceiver<(
         PredictEditsV3Request,
         oneshot::Sender,
     )>,
+    predict_v4: mpsc::UnboundedReceiver<(
+        PredictEditsV4Request,
+        oneshot::Sender,
+    )>,
     reject: mpsc::UnboundedReceiver<(RejectEditPredictionsBody, oneshot::Sender<()>)>,
-    settled: mpsc::UnboundedReceiver,
+    settled: mpsc::UnboundedReceiver,
 }
 
 fn init_test_with_fake_client(
@@ -2836,6 +2610,7 @@ fn init_test_with_fake_client_and_legacy_data_collection(
         cx.set_global(AppDatabase::test_new());
         let settings_store = SettingsStore::test(cx);
         cx.set_global(settings_store);
+        disable_jumps_feature_flag(cx);
         zlog::init_test();
 
         if let Some(legacy_data_collection_choice) = legacy_data_collection_choice {
@@ -2850,6 +2625,7 @@ fn init_test_with_fake_client_and_legacy_data_collection(
         }
 
         let (predict_req_tx, predict_req_rx) = mpsc::unbounded();
+        let (predict_v4_req_tx, predict_v4_req_rx) = mpsc::unbounded();
         let (reject_req_tx, reject_req_rx) = mpsc::unbounded();
         let (settled_req_tx, settled_req_rx) = mpsc::unbounded();
 
@@ -2863,6 +2639,7 @@ fn init_test_with_fake_client_and_legacy_data_collection(
                     .map(str::to_owned);
                 let mut body = req.into_body();
                 let predict_req_tx = predict_req_tx.clone();
+                let predict_v4_req_tx = predict_v4_req_tx.clone();
                 let reject_req_tx = reject_req_tx.clone();
                 let settled_req_tx = settled_req_tx.clone();
                 async move {
@@ -2892,6 +2669,27 @@ fn init_test_with_fake_client_and_legacy_data_collection(
                             }
                             serde_json::to_string(&response).unwrap()
                         }
+                        "/predict_edits/v4" => {
+                            let mut buf = Vec::new();
+                            body.read_to_end(&mut buf).await.ok();
+                            let decompressed = zstd::decode_all(&buf[..]).unwrap();
+                            let req = serde_json::from_slice(&decompressed).unwrap();
+
+                            let (res_tx, res_rx) = oneshot::channel::();
+                            predict_v4_req_tx.unbounded_send((req, res_tx)).unwrap();
+                            let response = res_rx.await?;
+                            if response.request_id == REQUEST_TIMEOUT_RESPONSE_ID {
+                                return Ok(Response::builder()
+                                    .status(http_client::http::StatusCode::REQUEST_TIMEOUT)
+                                    .body(
+                                        http_client::http::StatusCode::REQUEST_TIMEOUT
+                                            .as_str()
+                                            .into(),
+                                    )
+                                    .unwrap());
+                            }
+                            serde_json::to_string(&response).unwrap()
+                        }
                         "/predict_edits/reject" => {
                             let mut buf = Vec::new();
                             body.read_to_end(&mut buf).await.ok();
@@ -2909,8 +2707,11 @@ fn init_test_with_fake_client_and_legacy_data_collection(
                             } else {
                                 buf
                             };
-                            let req = serde_json::from_slice(&body).unwrap();
-                            settled_req_tx.unbounded_send(req).unwrap();
+                            let req: SubmitEditPredictionSettledBatchBody =
+                                serde_json::from_slice(&body).unwrap();
+                            for prediction in req.predictions {
+                                settled_req_tx.unbounded_send(prediction).unwrap();
+                            }
                             serde_json::to_string(&SubmitEditPredictionSettledResponse {}).unwrap()
                         }
                         _ => {
@@ -2936,6 +2737,7 @@ fn init_test_with_fake_client_and_legacy_data_collection(
             user_store,
             RequestChannels {
                 predict: predict_req_rx,
+                predict_v4: predict_v4_req_rx,
                 reject: reject_req_rx,
                 settled: settled_req_rx,
             },
@@ -2997,7 +2799,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) {
         buffer: buffer.clone(),
         snapshot: cx.read(|cx| buffer.read(cx).snapshot()),
         id: EditPredictionId("the-id".into()),
-        inputs: ZetaPromptInput {
+        inputs: EditPredictionInputs::V2(Zeta2PromptInput {
             events: Default::default(),
             related_files: Default::default(),
             active_buffer_diagnostics: vec![],
@@ -3010,7 +2812,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) {
             in_open_source_repo: false,
             can_collect_data: false,
             repo_url: None,
-        },
+        }),
         model_version: None,
         trigger: PredictEditsRequestTrigger::Other,
     };
@@ -3165,6 +2967,63 @@ async fn test_edit_prediction_end_of_buffer(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_edit_prediction_v4_end_of_buffer(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/project",
+        json!({
+            "file.txt": "lorem\n"
+        }),
+    )
+    .await;
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let buffer = project
+        .update(cx, |project, cx| {
+            let path = project
+                .find_project_path(path!("/project/file.txt"), cx)
+                .unwrap();
+            project.open_buffer(path, cx)
+        })
+        .await
+        .unwrap();
+    let (ep_store, response) = make_test_ep_store(&project, cx).await;
+    *response.lock() = "lorem\nipsum\n".to_string();
+
+    let position = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0)));
+    ep_store.update(cx, |ep_store, cx| {
+        ep_store.register_project(&project, cx);
+        ep_store.register_buffer(&buffer, &project, cx);
+        ep_store.refresh_prediction_from_buffer(
+            project.clone(),
+            buffer.clone(),
+            position,
+            EditPredictionRequestTrigger::Other,
+            cx,
+        );
+    });
+    cx.run_until_parked();
+
+    let edits = ep_store.update(cx, |ep_store, cx| {
+        let prediction = ep_store
+            .prediction_at(&buffer, None, &project, cx)
+            .expect("should have prediction");
+        let prediction = match prediction {
+            BufferEditPrediction::Local { prediction }
+            | BufferEditPrediction::Jump { prediction } => prediction,
+        };
+        assert!(prediction.editable_range.is_some());
+        prediction.edits.iter().cloned().collect::>()
+    });
+    buffer.update(cx, |buffer, cx| buffer.edit(edits, None, cx));
+
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.text(), "lorem\nipsum\n");
+    });
+}
+
 #[gpui::test]
 async fn test_edit_prediction_no_spurious_trailing_newline(cx: &mut TestAppContext) {
     // Test that zeta2's newline normalization logic doesn't insert spurious newlines.
@@ -3311,6 +3170,26 @@ fn init_test(cx: &mut TestAppContext) {
         cx.set_global(AppDatabase::test_new());
         let settings_store = SettingsStore::test(cx);
         cx.set_global(settings_store);
+        disable_jumps_feature_flag(cx);
+    });
+}
+
+fn disable_jumps_feature_flag(cx: &mut App) {
+    SettingsStore::update_global(cx, |store, _| {
+        store.register_setting::();
+    });
+    set_jumps_feature_flag_override(cx, "off");
+    cx.update_flags(false, vec![]);
+}
+
+fn set_jumps_feature_flag_override(cx: &mut App, value: &str) {
+    SettingsStore::update_global(cx, |store, cx| {
+        store.update_user_settings(cx, |content| {
+            content.feature_flags.get_or_insert_default().insert(
+                EditPredictionJumpsFeatureFlag::NAME.to_string(),
+                value.to_string(),
+            );
+        });
     });
 }
 
@@ -3351,7 +3230,7 @@ async fn run_edit_prediction(
             cx,
         )
     });
-    prediction_task.await.unwrap().unwrap().prediction.unwrap()
+    prediction_task.await.unwrap().unwrap().prediction
 }
 
 async fn make_test_ep_store(
@@ -3403,6 +3282,43 @@ async fn make_test_ep_store(
                             )
                             .unwrap())
                     }
+                    (Method::POST, "/predict_edits/v4") => {
+                        let mut buf = Vec::new();
+                        body.read_to_end(&mut buf).await.ok();
+                        let decompressed = zstd::decode_all(&buf[..]).unwrap();
+                        let req: PredictEditsV4Request =
+                            serde_json::from_slice(&decompressed).unwrap();
+
+                        next_request_id += 1;
+                        let output = completion_response.lock().clone();
+                        let file = req
+                            .input
+                            .editable_context
+                            .iter()
+                            .find(|file| file.path == req.input.cursor_path)
+                            .or_else(|| req.input.editable_context.first())
+                            .expect("V4 requests should include editable context");
+                        let excerpt = file
+                            .excerpts
+                            .first()
+                            .expect("V4 editable context should include an excerpt");
+                        let diff = unified_diff_with_offsets(
+                            &excerpt.text,
+                            &output,
+                            excerpt.row_range.start,
+                            excerpt.row_range.start,
+                        );
+                        let path = file.path.to_string_lossy();
+                        let response = PredictEditsV4Response {
+                            request_id: format!("request-{next_request_id}"),
+                            patch: format!("--- a/{path}\n+++ b/{path}\n{diff}"),
+                            model_version: None,
+                        };
+                        Ok(http_client::Response::builder()
+                            .status(200)
+                            .body(serde_json::to_string(&response).unwrap().into())
+                            .unwrap())
+                    }
                     _ => Ok(http_client::Response::builder()
                         .status(404)
                         .body("Not Found".to_string().into())
@@ -3545,222 +3461,6 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut
     assert_eq!(request_count.load(std::sync::atomic::Ordering::SeqCst), 0);
 }
 
-#[gpui::test]
-async fn test_diagnostic_jump_excludes_collaborator_regions(cx: &mut TestAppContext) {
-    fn set_collaborator_cursor(buffer: &Entity, row: u32, cx: &mut TestAppContext) {
-        let collab_replica = clock::ReplicaId::new(10);
-        let anchor = buffer.read_with(cx, |buffer, _| {
-            buffer.snapshot().anchor_before(Point::new(row, 0))
-        });
-        let selections: Arc<[Selection]> = Arc::new([Selection {
-            id: 1,
-            start: anchor,
-            end: anchor,
-            reversed: false,
-            goal: SelectionGoal::None,
-        }]);
-        buffer.update(cx, |buffer, cx| {
-            buffer.apply_ops(
-                [Operation::UpdateSelections {
-                    selections,
-                    lamport_timestamp: clock::Lamport {
-                        replica_id: collab_replica,
-                        value: 1,
-                    },
-                    line_mode: false,
-                    cursor_shape: CursorShape::Bar,
-                }],
-                cx,
-            );
-        });
-    }
-
-    fn publish_diagnostics(
-        uri_path: &'static str,
-        rows: &[u32],
-        project: &Entity,
-        cx: &mut TestAppContext,
-    ) {
-        let diagnostics: Vec<_> = rows
-            .iter()
-            .map(|&row| lsp::Diagnostic {
-                range: lsp::Range::new(lsp::Position::new(row, 0), lsp::Position::new(row, 5)),
-                severity: Some(lsp::DiagnosticSeverity::ERROR),
-                message: format!("error at row {row}"),
-                ..Default::default()
-            })
-            .collect();
-        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(uri_path).expect("invalid uri"),
-                            diagnostics,
-                            version: None,
-                        },
-                        None,
-                        language::DiagnosticSourceKind::Pushed,
-                        &[],
-                        cx,
-                    )
-                    .expect("failed to update diagnostics");
-            });
-        });
-    }
-
-    init_test(cx);
-
-    let mut lines = String::new();
-    for i in 0..60 {
-        lines.push_str(&format!("line {i}\n"));
-    }
-
-    let fs = FakeFs::new(cx.executor());
-    fs.insert_tree(
-        "/root",
-        json!({
-            "active.txt": lines,
-            "collab_file.txt": "error here\nsecond line\n",
-            "free_file.txt": "another error\nsecond line\n",
-        }),
-    )
-    .await;
-    let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
-
-    let active_buffer = project
-        .update(cx, |project, cx| {
-            let path = project
-                .find_project_path(path!("/root/active.txt"), cx)
-                .expect("active.txt not found");
-            project.set_active_path(Some(path.clone()), cx);
-            project.open_buffer(path, cx)
-        })
-        .await
-        .expect("failed to open active buffer");
-
-    set_collaborator_cursor(&active_buffer, 5, cx);
-
-    publish_diagnostics(path!("/root/active.txt"), &[3, 25, 50], &project, cx);
-
-    cx.run_until_parked();
-
-    let cursor_point = Point::new(25, 0);
-    let empty_search_range: Range = Default::default();
-
-    let snapshot = active_buffer.read_with(cx, |buffer, _| buffer.snapshot());
-    let result = EditPredictionStore::next_diagnostic_location(
-        active_buffer.clone(),
-        &snapshot,
-        empty_search_range.clone(),
-        cursor_point,
-        &project,
-        &mut cx.to_async(),
-    )
-    .await
-    .expect("next_diagnostic_location failed");
-
-    let (result_buffer, result_anchor) = result.expect("expected a diagnostic location");
-    assert_eq!(result_buffer.entity_id(), active_buffer.entity_id());
-    let result_row = result_buffer.read_with(cx, |buffer, _| {
-        result_anchor.to_point(&buffer.snapshot()).row
-    });
-    assert_ne!(
-        result_row, 3,
-        "row 3 is near collaborator (row 5) but far from local cursor (row 25), should be excluded"
-    );
-    assert!(
-        result_row == 25 || result_row == 50,
-        "expected row 25 or 50, got {result_row}"
-    );
-
-    let snapshot_near = active_buffer.read_with(cx, |buffer, _| buffer.snapshot());
-    let near_cursor_point = Point::new(4, 0);
-    let result_near = EditPredictionStore::next_diagnostic_location(
-        active_buffer.clone(),
-        &snapshot_near,
-        empty_search_range.clone(),
-        near_cursor_point,
-        &project,
-        &mut cx.to_async(),
-    )
-    .await
-    .expect("next_diagnostic_location failed");
-
-    let (_, near_anchor) = result_near.expect("expected a diagnostic location when both are near");
-    let near_row =
-        active_buffer.read_with(cx, |buffer, _| near_anchor.to_point(&buffer.snapshot()).row);
-    assert_eq!(
-        near_row, 3,
-        "row 3 should be included when local cursor (row 4) is also near the collaborator"
-    );
-
-    let snapshot_far = active_buffer.read_with(cx, |buffer, _| buffer.snapshot());
-    let far_cursor_point = Point::new(50, 0);
-    let result_far = EditPredictionStore::next_diagnostic_location(
-        active_buffer.clone(),
-        &snapshot_far,
-        empty_search_range.clone(),
-        far_cursor_point,
-        &project,
-        &mut cx.to_async(),
-    )
-    .await
-    .expect("next_diagnostic_location failed");
-
-    let (_, far_anchor) = result_far.expect("expected a diagnostic location");
-    let far_row =
-        active_buffer.read_with(cx, |buffer, _| far_anchor.to_point(&buffer.snapshot()).row);
-    assert_eq!(
-        far_row, 50,
-        "row 50 is near local cursor (row 50) and far from collaborator, should be picked"
-    );
-
-    publish_diagnostics(path!("/root/collab_file.txt"), &[0], &project, cx);
-    publish_diagnostics(path!("/root/free_file.txt"), &[0], &project, cx);
-    cx.run_until_parked();
-
-    let collab_buffer = project
-        .update(cx, |project, cx| {
-            let path = project
-                .find_project_path(path!("/root/collab_file.txt"), cx)
-                .expect("collab_file.txt not found");
-            project.open_buffer(path, cx)
-        })
-        .await
-        .expect("failed to open collab buffer");
-
-    set_collaborator_cursor(&collab_buffer, 0, cx);
-    cx.run_until_parked();
-
-    let no_same_file_search_range = Point::new(0, 0)..Point::new(59, 0);
-    let snapshot_cross = active_buffer.read_with(cx, |buffer, _| buffer.snapshot());
-    let result_cross = EditPredictionStore::next_diagnostic_location(
-        active_buffer.clone(),
-        &snapshot_cross,
-        no_same_file_search_range,
-        Point::new(0, 0),
-        &project,
-        &mut cx.to_async(),
-    )
-    .await
-    .expect("cross-file next_diagnostic_location failed");
-
-    let (cross_buffer, _) = result_cross.expect("expected a cross-file diagnostic location");
-    let cross_path = cross_buffer.read_with(cx, |buffer, cx| {
-        buffer
-            .file()
-            .expect("buffer should have a file")
-            .full_path(cx)
-    });
-    assert_eq!(
-        cross_path,
-        Path::new(path!("root/free_file.txt")),
-        "should skip collab_file.txt (has collaborator) and pick free_file.txt"
-    );
-}
-
 #[gpui::test]
 async fn test_edit_prediction_settled(cx: &mut TestAppContext) {
     let (ep_store, _requests) = init_test_with_fake_client(cx);
@@ -3833,6 +3533,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) {
             &edit_preview_a,
             None,
             None,
+            None,
             Duration::from_secs(0),
             cx,
         );
@@ -3900,6 +3601,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) {
             &edit_preview_b,
             None,
             None,
+            None,
             Duration::from_secs(0),
             cx,
         );
@@ -3949,19 +3651,40 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) {
     }
 }
 
-#[gpui::test]
-async fn test_edit_prediction_settled_omits_body_when_data_collection_is_disabled(
+const MIT_LICENSE: &str = indoc! {r#"
+    MIT License
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE.
+"#};
+
+async fn init_sample_capture_test(
+    tree: serde_json::Value,
     cx: &mut TestAppContext,
+) -> (
+    Entity,
+    RequestChannels,
+    Entity,
+    Entity,
 ) {
-    let (ep_store, mut requests) = init_test_with_fake_client(cx);
+    let (ep_store, requests) = init_test_with_fake_client(cx);
     let fs = FakeFs::new(cx.executor());
-    fs.insert_tree(
-        "/root",
-        json!({
-            "foo.md": "sensitive source\n"
-        }),
-    )
-    .await;
+    fs.insert_tree("/root", tree).await;
     let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
     let buffer = project
         .update(cx, |project, cx| {
@@ -3970,52 +3693,427 @@ async fn test_edit_prediction_settled_omits_body_when_data_collection_is_disable
         })
         .await
         .unwrap();
-
     ep_store.update(cx, |ep_store, cx| {
         ep_store.register_buffer(&buffer, &project, cx);
     });
+    cx.run_until_parked();
+    (ep_store, requests, project, buffer)
+}
 
+/// Enqueues a settled prediction capture with injected sample data, as the
+/// real request path would when data collection is enabled. Returns the
+/// editable offset range.
+async fn enqueue_sample_capture(
+    ep_store: &Entity,
+    project: &Entity,
+    buffer: &Entity,
+    id: &str,
+    editable_point_range: Range,
+    context: CapturedPredictionContext,
+    prompt_history_boundary: Option,
+    navigation_history: VecDeque,
+    cx: &mut TestAppContext,
+) -> Range {
     let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
-    let edits: Arc<[(Range, Arc)]> =
-        cx.update(|cx| to_completion_edits([(0..9, "replacement".into())], &buffer, cx).into());
+    let editable_offset_range = snapshot.point_to_offset(editable_point_range.start)
+        ..snapshot.point_to_offset(editable_point_range.end);
+    let empty_edits: Arc<[(Range, Arc)]> = Vec::new().into();
     let edit_preview = buffer
-        .read_with(cx, |buffer, cx| buffer.preview_edits(edits, cx))
+        .read_with(cx, |buffer, cx| buffer.preview_edits(empty_edits, cx))
         .await;
-
     ep_store.update(cx, |ep_store, cx| {
         ep_store.enqueue_settled_prediction(
-            EditPredictionId("prediction-private".into()),
-            &project,
-            &buffer,
+            EditPredictionId(id.to_string().into()),
+            project,
+            buffer,
             &snapshot,
-            0..snapshot.len(),
+            editable_offset_range.clone(),
             &edit_preview,
-            Some(ExampleSpec {
-                name: "test example".to_string(),
-                repository_url: "https://example.com/repo".to_string(),
-                revision: "rev".to_string(),
-                tags: Vec::new(),
-                reasoning: None,
-                uncommitted_diff: String::new(),
-                recently_opened_files: Vec::new(),
-                recently_viewed_files: Vec::new(),
-                uncommitted_diff_contains_edit_history: false,
-                cursor_path: Path::new("foo.md").into(),
-                cursor_position: "0".to_string(),
-                edit_history: "sensitive edit history".to_string(),
-                expected_patches: vec!["sensitive patch".to_string()],
-                rejected_patch: None,
-                telemetry: None,
-                human_feedback: Vec::new(),
-                rating: None,
-            }),
-            Some("test-model".to_string()),
-            Duration::from_millis(42),
+            None,
+            None,
+            None,
+            Duration::from_secs(0),
             cx,
         );
+        let pending_capture = ep_store
+            .projects
+            .get_mut(&project.entity_id())
+            .unwrap()
+            .pending_prediction_captures
+            .last_mut()
+            .unwrap();
+        pending_capture.can_collect_data = true;
+        pending_capture.sample_data = Some(PendingPredictionCaptureSampleData {
+            context_task: Task::ready(Ok(context)),
+            editable_path: snapshot.file().unwrap().path().as_std_path().into(),
+            editable_offset_range: editable_offset_range.clone(),
+            next_edit_cursor_offset: None,
+            future_edit_history_events: Vec::new(),
+            navigation_history,
+            edit_events_before_quiescence: 0,
+            prompt_history_boundary,
+        });
+    });
+    editable_offset_range
+}
+
+#[gpui::test]
+async fn test_edit_prediction_settled_sends_sample_data_after_quiescence(cx: &mut TestAppContext) {
+    let (ep_store, mut requests, project, buffer) = init_sample_capture_test(
+        json!({
+            "LICENSE": MIT_LICENSE,
+            "foo.md": (0..60).map(|ix| format!("line {ix}\n")).collect::(),
+        }),
+        cx,
+    )
+    .await;
+
+    buffer.update(cx, |buffer, cx| {
+        let offset = Point::new(1, 0).to_offset(buffer);
+        buffer.edit(vec![(offset..offset, "prompted ")], None, cx);
+    });
+    cx.run_until_parked();
+
+    let boundary = ep_store.update(cx, |ep_store, _cx| {
+        let project_state = ep_store.projects.get(&project.entity_id()).unwrap();
+        PromptHistoryBoundary {
+            first_event_seq: project_state
+                .last_event
+                .as_ref()
+                .map_or(project_state.next_last_event_seq, |last_event| {
+                    last_event.seq
+                }),
+            snapshot: project_state
+                .last_event
+                .as_ref()
+                .map(|last_event| last_event.new_snapshot.clone()),
+        }
+    });
+    let editable_offset_range = enqueue_sample_capture(
+        &ep_store,
+        &project,
+        &buffer,
+        "prediction-sample",
+        Point::new(2, 0)..Point::new(3, 0),
+        CapturedPredictionContext {
+            repository_url: Some("https://example.com/repo.git".to_string()),
+            revision: Some("abc123".to_string()),
+            uncommitted_diff: Some("--- a/foo.md\n+++ b/foo.md\n".to_string()),
+            buffer_diagnostics: vec![zeta_prompt::ActiveBufferDiagnostic {
+                severity: Some(1),
+                message: "sample diagnostic".to_string(),
+                snippet: String::new(),
+                snippet_buffer_row_range: 0..0,
+                diagnostic_range_in_snippet: 0..0,
+            }],
+            editable_context: vec![zeta_prompt::RelatedFile {
+                path: Path::new("foo.md").into(),
+                max_row: 60,
+                excerpts: vec![zeta_prompt::RelatedExcerpt {
+                    row_range: 0..2,
+                    text: "line 0\nline 1\n".into(),
+                    order: 0,
+                    context_source: zeta_prompt::ContextSource::CurrentFile,
+                }],
+                in_open_source_repo: true,
+            }],
+        },
+        Some(boundary),
+        VecDeque::from([RecentFile {
+            path: Path::new("foo.md").into(),
+            cursor_position: Some(3),
+        }]),
+        cx,
+    )
+    .await;
+    let expected_next_edit_cursor_offset = editable_offset_range.start;
+
+    // A second capture whose user has data collection disabled; its sample
+    // and settled region must be redacted at send time.
+    let boundary = ep_store.update(cx, |ep_store, _cx| {
+        let project_state = ep_store.projects.get(&project.entity_id()).unwrap();
+        PromptHistoryBoundary {
+            first_event_seq: project_state
+                .last_event
+                .as_ref()
+                .map_or(project_state.next_last_event_seq, |last_event| {
+                    last_event.seq
+                }),
+            snapshot: project_state
+                .last_event
+                .as_ref()
+                .map(|last_event| last_event.new_snapshot.clone()),
+        }
+    });
+    enqueue_sample_capture(
+        &ep_store,
+        &project,
+        &buffer,
+        "prediction-redacted",
+        Point::new(2, 0)..Point::new(3, 0),
+        CapturedPredictionContext {
+            repository_url: None,
+            revision: None,
+            uncommitted_diff: None,
+            buffer_diagnostics: Vec::new(),
+            editable_context: Vec::new(),
+        },
+        Some(boundary),
+        VecDeque::new(),
+        cx,
+    )
+    .await;
+    ep_store.update(cx, |ep_store, _cx| {
+        ep_store
+            .projects
+            .get_mut(&project.entity_id())
+            .unwrap()
+            .pending_prediction_captures
+            .last_mut()
+            .unwrap()
+            .can_collect_data = false;
+    });
+
+    cx.executor().advance_clock(LAST_CHANGE_GROUPING_TIME * 2);
+    cx.run_until_parked();
+
+    for (ix, row) in [2, 20, 30, 40, 50].into_iter().enumerate() {
+        buffer.update(cx, |buffer, cx| {
+            let start = Point::new(row, 0).to_offset(buffer);
+            let end = Point::new(row, 4).to_offset(buffer);
+            buffer.edit(vec![(start..end, format!("future {ix}"))], None, cx);
+        });
+        cx.run_until_parked();
+    }
+
+    cx.executor()
+        .advance_clock(EDIT_PREDICTION_SETTLED_QUIESCENCE);
+    cx.run_until_parked();
+
+    let mut settled_by_id = std::collections::HashMap::new();
+    for _ in 0..2 {
+        let request = requests
+            .settled
+            .next()
+            .await
+            .expect("settled request should be sent");
+        settled_by_id.insert(request.request_id.clone(), request);
+    }
+
+    let redacted_request = settled_by_id.remove("prediction-redacted").unwrap();
+    assert!(!redacted_request.can_collect_data);
+    assert_eq!(redacted_request.settled_editable_region, None);
+    assert_eq!(redacted_request.sample_data, None);
+
+    let settled_request = settled_by_id.remove("prediction-sample").unwrap();
+    let sample_data = settled_request
+        .sample_data
+        .expect("sample data should be sent after quiescence");
+    assert_eq!(
+        sample_data.repository_url.as_deref(),
+        Some("https://example.com/repo.git")
+    );
+    assert_eq!(sample_data.revision.as_deref(), Some("abc123"));
+    assert_eq!(
+        sample_data.uncommitted_diff.as_deref(),
+        Some("--- a/foo.md\n+++ b/foo.md\n")
+    );
+    assert_eq!(sample_data.editable_path.as_ref(), Path::new("foo.md"));
+    assert_eq!(sample_data.editable_offset_range, editable_offset_range);
+    assert_eq!(sample_data.buffer_diagnostics.len(), 1);
+    assert_eq!(sample_data.editable_context.len(), 1);
+    let editable_context = &sample_data.editable_context[0];
+    assert_eq!(editable_context.path.as_ref(), Path::new("foo.md"));
+    assert_eq!(editable_context.excerpts.len(), 1);
+    assert_eq!(
+        editable_context.excerpts[0].context_source,
+        zeta_prompt::ContextSource::CurrentFile
+    );
+    assert_eq!(sample_data.future_edit_history_events.len(), 4);
+    assert_eq!(sample_data.navigation_history.len(), 1);
+    assert_eq!(sample_data.edit_events_before_quiescence, 5);
+    assert_eq!(
+        sample_data.next_edit_cursor_offset,
+        Some(expected_next_edit_cursor_offset)
+    );
+
+    let future_event_diffs = sample_data
+        .future_edit_history_events
+        .iter()
+        .map(|event| match event.as_ref() {
+            zeta_prompt::Event::BufferChange { diff, .. } => diff.as_str(),
+        })
+        .collect::>();
+    assert!(future_event_diffs.iter().all(|diff| {
+        diff.lines()
+            .all(|line| !line.starts_with("+prompted ") && line != "-line 1")
+    }));
+    assert!(
+        future_event_diffs
+            .iter()
+            .any(|diff| diff.contains("future 0"))
+    );
+    assert!(
+        !future_event_diffs
+            .iter()
+            .any(|diff| diff.contains("future 4"))
+    );
+}
+
+#[gpui::test]
+async fn test_edit_prediction_settled_sample_data_requires_observing_all_events_since_request(
+    cx: &mut TestAppContext,
+) {
+    let (ep_store, mut requests, project, buffer) = init_sample_capture_test(
+        json!({
+            "LICENSE": MIT_LICENSE,
+            "foo.md": (0..30).map(|ix| format!("line {ix}\n")).collect::(),
+        }),
+        cx,
+    )
+    .await;
+
+    // Two predictions are requested while no event is pending.
+    let (boundary_observed, boundary_missed) = ep_store.update(cx, |ep_store, _cx| {
+        let project_state = ep_store.projects.get(&project.entity_id()).unwrap();
+        let first_event_seq = project_state
+            .last_event
+            .as_ref()
+            .map_or(project_state.next_last_event_seq, |last_event| {
+                last_event.seq
+            });
+        let snapshot = project_state
+            .last_event
+            .as_ref()
+            .map(|last_event| last_event.new_snapshot.clone());
+        (
+            PromptHistoryBoundary {
+                first_event_seq,
+                snapshot: snapshot.clone(),
+            },
+            PromptHistoryBoundary {
+                first_event_seq,
+                snapshot,
+            },
+        )
+    });
+    assert!(boundary_observed.snapshot.is_none());
+
+    // The first capture is enqueued immediately, so it observes both
+    // subsequent events.
+    enqueue_sample_capture(
+        &ep_store,
+        &project,
+        &buffer,
+        "prediction-observed",
+        Point::new(1, 0)..Point::new(2, 0),
+        CapturedPredictionContext {
+            repository_url: None,
+            revision: None,
+            uncommitted_diff: None,
+            buffer_diagnostics: Vec::new(),
+            editable_context: Vec::new(),
+        },
+        Some(boundary_observed),
+        VecDeque::new(),
+        cx,
+    )
+    .await;
+
+    buffer.update(cx, |buffer, cx| {
+        let offset = Point::new(1, 0).to_offset(buffer);
+        buffer.edit(vec![(offset..offset, "first ")], None, cx);
+    });
+    cx.run_until_parked();
+    buffer.update(cx, |buffer, cx| {
+        let offset = Point::new(20, 0).to_offset(buffer);
+        buffer.edit(vec![(offset..offset, "second ")], None, cx);
     });
+    cx.run_until_parked();
+
+    // The second capture is enqueued only after the first event was
+    // finalized, so its future history has a gap and it must be dropped.
+    enqueue_sample_capture(
+        &ep_store,
+        &project,
+        &buffer,
+        "prediction-missed",
+        Point::new(1, 0)..Point::new(2, 0),
+        CapturedPredictionContext {
+            repository_url: None,
+            revision: None,
+            uncommitted_diff: None,
+            buffer_diagnostics: Vec::new(),
+            editable_context: Vec::new(),
+        },
+        Some(boundary_missed),
+        VecDeque::new(),
+        cx,
+    )
+    .await;
+
+    cx.executor()
+        .advance_clock(EDIT_PREDICTION_SETTLED_QUIESCENCE);
+    cx.run_until_parked();
+
+    let mut settled_by_id = std::collections::HashMap::new();
+    for _ in 0..2 {
+        let request = requests
+            .settled
+            .next()
+            .await
+            .expect("settled request should be sent");
+        settled_by_id.insert(request.request_id.clone(), request);
+    }
+
+    let observed_request = settled_by_id.remove("prediction-observed").unwrap();
+    let sample_data = observed_request
+        .sample_data
+        .expect("sample data should be sent");
+    assert_eq!(sample_data.edit_events_before_quiescence, 2);
+    assert_eq!(sample_data.future_edit_history_events.len(), 2);
+
+    let missed_request = settled_by_id.remove("prediction-missed").unwrap();
+    assert_eq!(missed_request.sample_data, None);
+}
+
+#[gpui::test]
+async fn test_edit_prediction_settled_drops_future_events_when_their_oss_status_is_unknown(
+    cx: &mut TestAppContext,
+) {
+    let (ep_store, mut requests, project, buffer) = init_sample_capture_test(
+        json!({ "foo.md": (0..30).map(|ix| format!("line {ix}\n")).collect::() }),
+        cx,
+    )
+    .await;
+
+    enqueue_sample_capture(
+        &ep_store,
+        &project,
+        &buffer,
+        "prediction-non-oss",
+        Point::new(1, 0)..Point::new(2, 0),
+        CapturedPredictionContext {
+            repository_url: None,
+            revision: None,
+            uncommitted_diff: None,
+            buffer_diagnostics: Vec::new(),
+            editable_context: Vec::new(),
+        },
+        None,
+        VecDeque::new(),
+        cx,
+    )
+    .await;
 
+    // There is no LICENSE file, so this event's open-source status is unknown
+    // and the sample must be dropped.
+    buffer.update(cx, |buffer, cx| {
+        let offset = Point::new(20, 0).to_offset(buffer);
+        buffer.edit(vec![(offset..offset, "outside ")], None, cx);
+    });
     cx.run_until_parked();
+
     cx.executor()
         .advance_clock(EDIT_PREDICTION_SETTLED_QUIESCENCE);
     cx.run_until_parked();
@@ -4025,19 +4123,26 @@ async fn test_edit_prediction_settled_omits_body_when_data_collection_is_disable
         .next()
         .await
         .expect("settled request should be sent");
-    assert!(!settled_request.can_collect_data);
-    assert_eq!(settled_request.settled_editable_region, None);
-    assert_eq!(settled_request.example, None);
+    assert_eq!(settled_request.sample_data, None);
 }
 
 #[gpui::test]
-fn test_buffer_path_with_id_fallback_for_untitled_buffers(cx: &mut TestAppContext) {
+fn test_buffer_path_with_id_fallback(cx: &mut TestAppContext) {
     let buffer_1 = cx.new(|cx| Buffer::local("one", cx));
     let buffer_2 = cx.new(|cx| Buffer::local("two", cx));
 
     let snapshot_1 = buffer_1.read_with(cx, |buffer, _| buffer.text_snapshot());
     let snapshot_2 = buffer_2.read_with(cx, |buffer, _| buffer.text_snapshot());
 
+    let windows_file: Arc = Arc::new(language::TestFile {
+        path: util::rel_path::rel_path("src/main.rs").into(),
+        root_name: "workspace".into(),
+        local_root: None,
+    });
+    let windows_path =
+        cx.read(|cx| buffer_path_with_id_fallback(Some(&windows_file), &snapshot_1, cx));
+    assert_eq!(windows_path.to_string_lossy(), "workspace/src/main.rs");
+
     let path_1 = cx.read(|cx| buffer_path_with_id_fallback(None, &snapshot_1, cx));
     let path_2 = cx.read(|cx| buffer_path_with_id_fallback(None, &snapshot_2, cx));
 
diff --git a/crates/edit_prediction/src/example_spec.rs b/crates/edit_prediction/src/example_spec.rs
index c610b777dca0a8..a0bb6bd2ff57f9 100644
--- a/crates/edit_prediction/src/example_spec.rs
+++ b/crates/edit_prediction/src/example_spec.rs
@@ -4,11 +4,10 @@ use std::{borrow::Cow, fmt::Write as _, mem, path::Path, sync::Arc};
 use telemetry_events::EditPredictionRating;
 
 pub use zeta_prompt::udiff::{
-    CURSOR_POSITION_MARKER, encode_cursor_in_patch, extract_cursor_from_patch,
+    CURSOR_POSITION_MARKER, INLINE_CURSOR_MARKER, encode_cursor_in_patch, extract_cursor_from_patch,
 };
 
 use crate::data_collection::format_cursor_excerpt;
-pub const INLINE_CURSOR_MARKER: &str = "<|user_cursor|>";
 
 /// Maximum cursor file size to capture (64KB).
 /// Files larger than this will not have their content captured,
@@ -494,8 +493,7 @@ impl ExampleSpec {
     /// to the start of the hunk.
     ///
     /// In the serialized representation of this example, the cursor position is represented
-    /// using a comment line in the diff, beginning with `#`, and containing a `[CURSOR_POSITION]`
-    /// marker with the same format as the [`Self::cursor_excerpt`].
+    /// using an inline `<|user_cursor|>` marker in an added diff line.
     pub fn expected_patches_with_cursor_positions(&self) -> Vec<(String, Option)> {
         self.expected_patches
             .iter()
@@ -784,8 +782,7 @@ mod tests {
             +// prints a greeting
              fn main() {
             -    println!("hi");
-            +    println!("hello, {}", );
-            #                          ^[CURSOR_POSITION]
+            +    println!("hello, {}", <|user_cursor|>);
                  let x = 42;
              }
         "#}
@@ -814,8 +811,7 @@ mod tests {
             +++ b/test.rs
             @@ -1,2 +1,2 @@
             -fn old() {}
-            +fn new_name() {}
-            #       ^[CURSOR_POSITION]
+            +fn new_<|user_cursor|>name() {}
         "#};
 
         let cursor_offset = "fn new_name() {}".find("name").unwrap();
@@ -826,7 +822,7 @@ mod tests {
         assert_eq!(
             encoded_once
                 .lines()
-                .filter(|line| line.contains(CURSOR_POSITION_MARKER))
+                .filter(|line| line.contains(INLINE_CURSOR_MARKER))
                 .count(),
             1
         );
diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs
index a28480a1141ee4..349fc308318c3e 100644
--- a/crates/edit_prediction/src/fim.rs
+++ b/crates/edit_prediction/src/fim.rs
@@ -1,5 +1,5 @@
 use crate::{
-    EditPredictionId, EditPredictionModelInput, cursor_excerpt,
+    EditPredictionId, EditPredictionInputs, EditPredictionModelInput, cursor_excerpt,
     open_ai_compatible::{self, load_open_ai_compatible_api_key_if_needed},
     prediction::EditPredictionResult,
 };
@@ -10,7 +10,7 @@ use language::{
     language_settings::all_language_settings,
 };
 use std::{path::Path, sync::Arc, time::Instant};
-use zeta_prompt::{ZetaPromptInput, compute_editable_and_context_ranges};
+use zeta_prompt::{Zeta2PromptInput, compute_editable_and_context_ranges};
 
 const FIM_CONTEXT_TOKENS: usize = 512;
 
@@ -19,7 +19,7 @@ struct FimRequestOutput {
     edits: Vec<(std::ops::Range, Arc)>,
     editable_range: std::ops::Range,
     snapshot: BufferSnapshot,
-    inputs: ZetaPromptInput,
+    inputs: Zeta2PromptInput,
     buffer: Entity,
 }
 
@@ -78,7 +78,7 @@ pub fn request_prediction(
             0,
         );
 
-        let inputs = ZetaPromptInput {
+        let inputs = Zeta2PromptInput {
             events,
             related_files: Some(Vec::new()),
             active_buffer_diagnostics: Vec::new(),
@@ -154,7 +154,7 @@ pub fn request_prediction(
                 output.edits.into(),
                 None,
                 Some(output.editable_range),
-                output.inputs,
+                EditPredictionInputs::V2(output.inputs),
                 None,
                 trigger,
                 cx.background_executor().now() - request_start,
diff --git a/crates/edit_prediction/src/jump_example.rs b/crates/edit_prediction/src/jump_example.rs
deleted file mode 100644
index 33991df9c319a9..00000000000000
--- a/crates/edit_prediction/src/jump_example.rs
+++ /dev/null
@@ -1,372 +0,0 @@
-use std::{
-    ops::Range,
-    sync::Arc,
-    time::{Duration, Instant},
-};
-
-use anyhow::{Context as _, Result};
-pub use cloud_api_types::JumpExampleTrigger;
-use cloud_api_types::{
-    JumpExampleRecentFile, SubmitEditPredictionJumpExampleBody,
-    SubmitEditPredictionJumpExampleResponse,
-};
-use futures::future::Shared;
-use gpui::{AppContext as _, AsyncApp, Context, Entity, Task, TaskExt as _, WeakEntity};
-use language::{BufferSnapshot, File, Point};
-use project::{Project, WorktreeId};
-use release_channel::AppVersion;
-
-use text::ToPoint as _;
-use util::rel_path::RelPath;
-
-use crate::{
-    EditPredictionStore, ProjectState, StoredEvent,
-    data_collection::{
-        UncommittedDiffResult, compute_cursor_excerpt, compute_uncommitted_diff,
-        estimate_uncommitted_diff_byte_size, format_cursor_excerpt,
-    },
-    example_spec::RecentFile,
-    zeta,
-};
-
-pub const JUMP_EXAMPLE_MAX_PENDING_CAPTURE_COUNT: usize = 10;
-pub const JUMP_EXAMPLE_FUTURE_EVENT_COUNT: usize = 2;
-pub const JUMP_EXAMPLE_TTL: Duration = Duration::from_secs(60 * 2);
-pub const JUMP_EXAMPLE_NAVIGATION_COUNT: usize = 20;
-pub const JUMP_EXAMPLE_MAX_UNCOMMITTED_DIFF_SIZE: usize = 64 * 1024;
-
-pub struct PendingJumpExampleCapture {
-    key: PendingJumpExampleCaptureKey,
-    trigger: JumpExampleTrigger,
-    file: Arc,
-    edit_history: Vec>,
-    recently_opened_files: Vec,
-    recently_viewed_files: Vec,
-    worktree_root_name: String,
-    cursor_position: String,
-    started_at: Instant,
-    uncommitted_diff: Option,
-    pub future_events: Vec>,
-    pub navigation_history: Vec,
-    diagnostics: Vec,
-    repository_url: Option,
-    revision: Option,
-    can_collect_data: bool,
-    is_in_open_source_repo: bool,
-}
-
-#[derive(Eq, PartialEq, Hash, Clone)]
-pub struct PendingJumpExampleCaptureKey {
-    worktree_id: WorktreeId,
-    file_path: Arc,
-    row_bucket: u32,
-}
-
-pub fn try_start_jump_example_capture(
-    project_state: &ProjectState,
-    uncommitted_diffs: Shared>,
-    project: Entity,
-    snapshot: BufferSnapshot,
-    position: language::Anchor,
-    trigger: JumpExampleTrigger,
-    stored_events: Vec,
-    diagnostic_search_range: Range,
-    can_collect_data: bool,
-    is_in_open_source_repo: bool,
-    cx: &mut Context,
-) {
-    let Some(file) = snapshot.file().cloned() else {
-        return;
-    };
-
-    let example_key = PendingJumpExampleCaptureKey {
-        worktree_id: file.worktree_id(cx),
-        file_path: file.path().clone(),
-        row_bucket: position.to_point(&snapshot).row / 10,
-    };
-    let should_capture_example = project_state.pending_jump_example_captures.len()
-        < JUMP_EXAMPLE_MAX_PENDING_CAPTURE_COUNT
-        && !project_state
-            .starting_jump_example_captures
-            .contains(&example_key)
-        && !project_state
-            .pending_jump_example_captures
-            .iter()
-            .any(|capture| &capture.key == &example_key);
-
-    if !should_capture_example {
-        return;
-    }
-
-    let _project = project.clone();
-    let _example_key = example_key.clone();
-    let task = cx.spawn(async move |ep_store, cx| {
-        let project = _project;
-        let example_key = _example_key;
-        let Some(ep_store) = ep_store.upgrade() else {
-            return anyhow::Ok(());
-        };
-        ep_store.update(cx, |ep_store, cx| {
-            let project_state = ep_store.get_or_init_project(&project, cx);
-            project_state
-                .starting_jump_example_captures
-                .push(example_key.clone());
-        });
-
-        let (repository, worktree) = project.read_with(cx, |project, cx| {
-            let repository = project.active_repository(cx);
-            let worktree_id = file.worktree_id(cx);
-            let worktree = project.worktree_for_id(worktree_id, cx);
-            (repository, worktree)
-        });
-        let Some(worktree) = worktree else {
-            return Ok(());
-        };
-
-        let diagnostics = zeta::active_buffer_diagnostics(
-            &snapshot,
-            diagnostic_search_range.clone(),
-            position.to_point(&snapshot).row,
-            100,
-        );
-
-        let uncommitted_diff = 'uncommitted_diff: {
-            if repository.is_none() {
-                break 'uncommitted_diff None;
-            }
-            let uncommitted_diff_snapshot = uncommitted_diffs
-                .await
-                .map_err(|error| anyhow::anyhow!("{error:?}"))
-                .context("failed to capture uncommitted diff")?;
-            let estimated_byte_size =
-                estimate_uncommitted_diff_byte_size(&uncommitted_diff_snapshot);
-            if estimated_byte_size > JUMP_EXAMPLE_MAX_UNCOMMITTED_DIFF_SIZE {
-                break 'uncommitted_diff None;
-            }
-
-            let uncommitted_diff = cx
-                .background_executor()
-                .spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshot) })
-                .await;
-            if uncommitted_diff.len() > JUMP_EXAMPLE_MAX_UNCOMMITTED_DIFF_SIZE {
-                break 'uncommitted_diff None;
-            }
-            Some(uncommitted_diff)
-        };
-
-        let edit_history = stored_events
-            .iter()
-            .map(|e| e.event.clone())
-            .collect::>();
-        let (repository_url, revision) = if let Some(repository) = &repository {
-            repository.read_with(cx, |repository, _| {
-                let snapshot = repository.snapshot();
-                (
-                    snapshot
-                        .remote_origin_url
-                        .clone()
-                        .or_else(|| snapshot.remote_upstream_url.clone()),
-                    snapshot
-                        .head_commit
-                        .as_ref()
-                        .map(|commit| commit.sha.to_string()),
-                )
-            })
-        } else {
-            (None, None)
-        };
-        let line_comment_prefix = snapshot
-            .language()
-            .and_then(|language| language.config().line_comments.first())
-            .map(|prefix| prefix.to_string())
-            .unwrap_or_default();
-        let (cursor_excerpt, cursor_offset_in_excerpt, _) = cx
-            .background_executor()
-            .spawn(async move { compute_cursor_excerpt(&snapshot, position) })
-            .await;
-        let cursor_position = format_cursor_excerpt(
-            &cursor_excerpt,
-            cursor_offset_in_excerpt,
-            &line_comment_prefix,
-        );
-        let now = cx.background_executor().now();
-        ep_store.update(cx, |ep_store, cx| {
-            let recently_opened_files = ep_store.recently_opened_files_for_project(&project);
-            let recently_viewed_files = ep_store.recently_viewed_files_for_project(&project);
-            let project_state = ep_store.get_or_init_project(&project, cx);
-            project_state
-                .pending_jump_example_captures
-                .push(PendingJumpExampleCapture {
-                    key: example_key,
-                    trigger,
-                    file,
-                    uncommitted_diff,
-                    edit_history,
-                    recently_opened_files,
-                    recently_viewed_files,
-                    repository_url,
-                    revision,
-                    diagnostics,
-                    worktree_root_name: worktree.read(cx).root_name_str().to_owned(),
-                    cursor_position,
-                    started_at: now,
-                    future_events: Vec::new(),
-                    navigation_history: Vec::new(),
-                    is_in_open_source_repo,
-                    can_collect_data,
-                });
-            drain_completed_jump_example_captures(project_state, cx);
-        });
-        Ok(())
-    });
-    cx.spawn(async move |ep_store, cx| {
-        let result = task.await;
-        ep_store
-            .update(cx, |ep_store, cx| {
-                ep_store
-                    .get_or_init_project(&project, cx)
-                    .starting_jump_example_captures
-                    .retain(|key| key != &example_key);
-            })
-            .ok();
-        result
-    })
-    .detach_and_log_err(cx);
-}
-
-pub fn drain_completed_jump_example_captures(
-    project_state: &mut ProjectState,
-    cx: &mut Context,
-) {
-    let now = cx.background_executor().now();
-
-    let mut capture_index = 0;
-    while capture_index < project_state.pending_jump_example_captures.len() {
-        let capture = &project_state.pending_jump_example_captures[capture_index];
-        let finished = capture.future_events.len() >= JUMP_EXAMPLE_FUTURE_EVENT_COUNT
-            || now.saturating_duration_since(capture.started_at) >= JUMP_EXAMPLE_TTL;
-        if !finished {
-            capture_index += 1;
-            continue;
-        }
-
-        let capture = project_state
-            .pending_jump_example_captures
-            .remove(capture_index);
-        cx.spawn(async move |this, cx| {
-            let result = submit_jump_example_capture_task(this, capture, cx).await;
-            if let Err(error) = result {
-                log::error!("failed to submit jump opportunity capture: {error:?}");
-            }
-        })
-        .detach();
-    }
-}
-
-fn submit_jump_example_capture_task(
-    this: WeakEntity,
-    capture: PendingJumpExampleCapture,
-    cx: &mut AsyncApp,
-) -> Task> {
-    let Some((organization_id, client, llm_token, app_version)) = this
-        .update(cx, |this, cx| {
-            (
-                this.user_store
-                    .read(cx)
-                    .current_organization()
-                    .map(|organization| organization.id.clone()),
-                this.client.clone(),
-                this.llm_token.clone(),
-                AppVersion::global(cx),
-            )
-        })
-        .ok()
-    else {
-        return Task::ready(Ok(()));
-    };
-    cx.background_spawn(async move {
-        let PendingJumpExampleCapture {
-            key: _,
-            trigger,
-            file,
-            edit_history,
-            recently_opened_files,
-            recently_viewed_files,
-            worktree_root_name,
-            cursor_position,
-            started_at: _,
-            uncommitted_diff,
-            future_events,
-            navigation_history,
-            diagnostics,
-            repository_url,
-            revision,
-            is_in_open_source_repo,
-            can_collect_data,
-        } = capture;
-        let future_edit_history = render_jump_example_events(&future_events, &worktree_root_name);
-
-        let cursor_path = file.path().as_std_path().into();
-        let example = SubmitEditPredictionJumpExampleBody {
-            request_id: uuid::Uuid::new_v4(),
-            trigger,
-            repository_url,
-            revision,
-            uncommitted_diff,
-            recently_opened_files: jump_example_recent_files(recently_opened_files),
-            recently_viewed_files: jump_example_recent_files(recently_viewed_files),
-            cursor_path,
-            cursor_position,
-            edit_history,
-            diagnostics,
-            future_edit_history,
-            navigation_history: jump_example_recent_files(navigation_history),
-            is_in_open_source_repo,
-            can_collect_data,
-        };
-        let json_bytes = serde_json::to_vec(&example)?;
-        let compressed = zstd::encode_all(&json_bytes[..], 3)?;
-        let url = client
-            .http_client()
-            .build_zed_llm_url("/predict_edits/jump_example", &[])?;
-        EditPredictionStore::send_api_request::(
-            |builder| {
-                Ok(builder
-                    .uri(url.as_ref())
-                    .header("Content-Encoding", "zstd")
-                    .body(compressed.clone().into())?)
-            },
-            client,
-            llm_token,
-            organization_id,
-            app_version,
-        )
-        .await?;
-        Ok(())
-    })
-}
-
-fn jump_example_recent_files(files: Vec) -> Vec {
-    files
-        .into_iter()
-        .map(|file| JumpExampleRecentFile {
-            path: file.path,
-            cursor_position: file.cursor_position,
-        })
-        .collect()
-}
-
-fn render_jump_example_events(events: &[Arc], root_name: &str) -> String {
-    let mut edit_history = String::new();
-    for event in events {
-        crate::capture_example::write_event_with_relative_paths(
-            &mut edit_history,
-            event,
-            root_name,
-        );
-        if !edit_history.ends_with('\n') {
-            edit_history.push('\n');
-        }
-    }
-    edit_history
-}
diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs
index db41d896fef044..26e0b5ef24e5dd 100644
--- a/crates/edit_prediction/src/mercury.rs
+++ b/crates/edit_prediction/src/mercury.rs
@@ -1,7 +1,9 @@
 use crate::{
     DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput,
-    EditPredictionStartedDebugEvent, EditPredictionStore, open_ai_response::text_from_response,
-    prediction::EditPredictionResult, zeta::compute_edits,
+    EditPredictionStartedDebugEvent, EditPredictionStore,
+    open_ai_response::text_from_response,
+    prediction::{EditPredictionInputs, EditPredictionResult},
+    zeta::compute_edits,
 };
 use anyhow::{Context as _, Result};
 use cloud_llm_client::EditPredictionRejectReason;
@@ -16,7 +18,7 @@ use language_model::{ApiKeyState, EnvVar, env_var};
 use release_channel::AppVersion;
 use serde::{Deserialize, Serialize};
 use std::{mem, ops::Range, path::Path, sync::Arc};
-use zeta_prompt::ZetaPromptInput;
+use zeta_prompt::Zeta2PromptInput;
 
 const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions";
 
@@ -103,7 +105,7 @@ impl Mercury {
                 + excerpt_ranges.editable_350.start)
                 ..(excerpt_offset_range.start + excerpt_ranges.editable_350.end);
 
-            let inputs = zeta_prompt::ZetaPromptInput {
+            let inputs = zeta_prompt::Zeta2PromptInput {
                 events,
                 related_files: Some(related_files),
                 cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot)
@@ -141,6 +143,7 @@ impl Mercury {
                 stream: false,
                 stream_options: None,
                 max_completion_tokens: None,
+                max_tokens: None,
                 stop: vec![],
                 temperature: None,
                 tool_choice: None,
@@ -254,7 +257,7 @@ impl Mercury {
                     edits.into(),
                     None,
                     Some(editable_range),
-                    inputs,
+                    EditPredictionInputs::V2(inputs),
                     None,
                     trigger,
                     cx.background_executor().now() - request_start,
@@ -266,7 +269,7 @@ impl Mercury {
     }
 }
 
-fn build_prompt(inputs: &ZetaPromptInput) -> String {
+fn build_prompt(inputs: &Zeta2PromptInput) -> 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";
diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs
index c01c52e2bdae6c..592d5e3b1af13d 100644
--- a/crates/edit_prediction/src/prediction.rs
+++ b/crates/edit_prediction/src/prediction.rs
@@ -4,7 +4,7 @@ use cloud_llm_client::{EditPredictionRejectReason, PredictEditsRequestTrigger};
 use edit_prediction_types::{PredictedCursorPosition, interpolate_edits};
 use gpui::{AsyncApp, Entity, SharedString};
 use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot};
-use zeta_prompt::ZetaPromptInput;
+use zeta_prompt::{Zeta2PromptInput, Zeta3PromptInput};
 
 #[derive(Clone, Default, Debug, PartialEq, Eq, Hash)]
 pub struct EditPredictionId(pub SharedString);
@@ -21,12 +21,17 @@ impl std::fmt::Display for EditPredictionId {
     }
 }
 
+#[derive(Clone, serde::Serialize)]
+#[serde(tag = "version", content = "input")]
+pub enum EditPredictionInputs {
+    V2(Zeta2PromptInput),
+    V3(Zeta3PromptInput),
+}
+
 /// A prediction response that was returned from the provider, whether it was ultimately valid or not.
 pub struct EditPredictionResult {
-    pub id: EditPredictionId,
-    pub prediction: Result,
-    pub display_prediction: Option,
-    pub model_version: Option,
+    pub prediction: EditPrediction,
+    pub reject_reason: Option,
     pub e2e_latency: std::time::Duration,
 }
 
@@ -38,71 +43,52 @@ impl EditPredictionResult {
         edits: Arc<[(Range, Arc)]>,
         cursor_position: Option,
         editable_range: Option>,
-        inputs: ZetaPromptInput,
+        inputs: EditPredictionInputs,
         model_version: Option,
         trigger: PredictEditsRequestTrigger,
         e2e_latency: std::time::Duration,
         cx: &mut AsyncApp,
     ) -> Self {
-        if edits.is_empty() {
-            let empty_edits = Arc::new([]);
-            return Self {
-                id: id.clone(),
-                prediction: Err(EditPredictionRejectReason::Empty),
-                display_prediction: Some(EditPrediction {
-                    id,
-                    edits: empty_edits,
-                    cursor_position: None,
-                    editable_range,
-                    snapshot: edited_buffer_snapshot.clone(),
-                    edit_preview: EditPreview::unchanged(edited_buffer_snapshot),
-                    buffer: edited_buffer.clone(),
-                    inputs,
-                    model_version: model_version.clone(),
-                    trigger,
-                }),
-                model_version,
-                e2e_latency,
-            };
-        }
-
-        let (edits, snapshot) = edited_buffer.read_with(cx, |buffer, _cx| {
-            let new_snapshot = buffer.snapshot();
-            let edits: Option, Arc)]>> =
-                interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits).map(Arc::from);
-
-            (edits, new_snapshot)
-        });
-
-        let Some(edits) = edits else {
-            let empty_edits: Arc<[(Range, Arc)]> = Vec::new().into();
-            return Self {
-                id: id.clone(),
-                prediction: Err(EditPredictionRejectReason::InterpolatedEmpty),
-                display_prediction: Some(EditPrediction {
-                    id,
-                    edits: empty_edits,
-                    cursor_position: None,
-                    editable_range,
-                    snapshot: edited_buffer_snapshot.clone(),
-                    edit_preview: EditPreview::unchanged(edited_buffer_snapshot),
-                    inputs,
-                    buffer: edited_buffer.clone(),
-                    model_version: model_version.clone(),
-                    trigger,
-                }),
-                model_version,
-                e2e_latency,
-            };
+        let (edits, reject_reason, new_snapshot): (
+            Arc<[(Range, Arc)]>,
+            Option,
+            Option,
+        ) = if edits.is_empty() {
+            (
+                Arc::default(),
+                Some(EditPredictionRejectReason::Empty),
+                None,
+            )
+        } else {
+            edited_buffer.read_with(cx, |buffer, _cx| {
+                let new_snapshot = buffer.snapshot();
+                match interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits) {
+                    Some(edits) if edits.is_empty() => (
+                        Arc::default(),
+                        Some(EditPredictionRejectReason::InterpolatedEmpty),
+                        None,
+                    ),
+                    Some(edits) => (Arc::from(edits), None, Some(new_snapshot)),
+                    None => (
+                        Arc::default(),
+                        Some(EditPredictionRejectReason::InterpolateFailed),
+                        None,
+                    ),
+                }
+            })
         };
+        let snapshot = new_snapshot.unwrap_or_else(|| edited_buffer_snapshot.clone());
 
-        let edit_preview = edited_buffer
-            .read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx))
-            .await;
+        let edit_preview = if !edits.is_empty() {
+            edited_buffer
+                .read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx))
+                .await
+        } else {
+            EditPreview::unchanged(edited_buffer_snapshot)
+        };
 
         Self {
-            id: id.clone(),
-            prediction: Ok(EditPrediction {
+            prediction: EditPrediction {
                 id,
                 edits,
                 cursor_position,
@@ -111,11 +97,38 @@ impl EditPredictionResult {
                 edit_preview,
                 inputs,
                 buffer: edited_buffer.clone(),
-                model_version: model_version.clone(),
+                model_version,
                 trigger,
-            }),
-            display_prediction: None,
-            model_version,
+            },
+            reject_reason,
+            e2e_latency,
+        }
+    }
+
+    pub fn new_rejected(
+        id: EditPredictionId,
+        edited_buffer: &Entity,
+        edited_buffer_snapshot: &BufferSnapshot,
+        inputs: EditPredictionInputs,
+        model_version: Option,
+        trigger: PredictEditsRequestTrigger,
+        e2e_latency: std::time::Duration,
+        reject_reason: EditPredictionRejectReason,
+    ) -> Self {
+        Self {
+            prediction: EditPrediction {
+                id,
+                edits: Arc::default(),
+                cursor_position: None,
+                editable_range: None,
+                snapshot: edited_buffer_snapshot.clone(),
+                edit_preview: EditPreview::unchanged(edited_buffer_snapshot),
+                inputs,
+                buffer: edited_buffer.clone(),
+                model_version,
+                trigger,
+            },
+            reject_reason: Some(reject_reason),
             e2e_latency,
         }
     }
@@ -129,8 +142,8 @@ pub struct EditPrediction {
     pub editable_range: Option>,
     pub snapshot: BufferSnapshot,
     pub edit_preview: EditPreview,
+    pub inputs: EditPredictionInputs,
     pub buffer: Entity,
-    pub inputs: zeta_prompt::ZetaPromptInput,
     pub model_version: Option,
     pub trigger: PredictEditsRequestTrigger,
 }
@@ -164,7 +177,7 @@ mod tests {
     use super::*;
     use gpui::{App, Entity, TestAppContext, prelude::*};
     use language::{Buffer, ToOffset as _};
-    use zeta_prompt::ZetaPromptInput;
+    use zeta_prompt::Zeta2PromptInput;
 
     #[gpui::test]
     async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) {
@@ -187,7 +200,7 @@ mod tests {
             edit_preview,
             model_version: None,
             trigger: PredictEditsRequestTrigger::Other,
-            inputs: ZetaPromptInput {
+            inputs: EditPredictionInputs::V2(Zeta2PromptInput {
                 events: vec![],
                 related_files: Some(vec![]),
                 active_buffer_diagnostics: vec![],
@@ -200,7 +213,7 @@ mod tests {
                 in_open_source_repo: false,
                 can_collect_data: false,
                 repo_url: None,
-            },
+            }),
         };
 
         cx.update(|cx| {
diff --git a/crates/edit_prediction/src/udiff.rs b/crates/edit_prediction/src/udiff.rs
index b2468755a8979f..5606b35e7fd10d 100644
--- a/crates/edit_prediction/src/udiff.rs
+++ b/crates/edit_prediction/src/udiff.rs
@@ -2,14 +2,19 @@ use std::{mem, ops::Range, path::Path, path::PathBuf, sync::Arc};
 
 use anyhow::{Context as _, Result, anyhow};
 use collections::{HashMap, hash_map::Entry};
+use edit_prediction_types::PredictedCursorPosition;
 use gpui::{AsyncApp, Entity};
-use language::{Anchor, Buffer, OffsetRangeExt as _, TextBufferSnapshot, text_diff};
+use language::{
+    Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, TextBufferSnapshot, ToOffset as _,
+    text_diff,
+};
 use postage::stream::Stream as _;
 use project::Project;
 use util::{paths::PathStyle, rel_path::RelPath};
 use worktree::Worktree;
 use zeta_prompt::udiff::{
-    DiffEvent, DiffParser, FileStatus, Hunk, disambiguate_by_line_number, find_context_candidates,
+    DiffEvent, DiffParser, FileStatus, Hunk, INLINE_CURSOR_MARKER, disambiguate_by_line_number,
+    find_context_candidates,
 };
 
 pub use zeta_prompt::udiff::{
@@ -30,6 +35,145 @@ impl OpenedBuffers {
     }
 }
 
+pub async fn prediction_edits_for_single_file_diff(
+    diff_str: &str,
+    project: &Entity,
+    cx: &mut AsyncApp,
+) -> Result<
+    Option<(
+        Entity,
+        BufferSnapshot,
+        Vec<(Range, Arc)>,
+        Option,
+    )>,
+> {
+    let mut diff = DiffParser::new(diff_str);
+    let mut target_file = None;
+    let mut edits = Vec::new();
+    let mut cursor_position = None;
+
+    while let Some(event) = diff.next()? {
+        match event {
+            DiffEvent::Hunk { path, hunk, status } => {
+                anyhow::ensure!(
+                    status == FileStatus::Modified,
+                    "V4 edit predictions only support modifying existing files"
+                );
+
+                let path = path.to_string();
+                if let Some((target_path, _, _)) = &target_file {
+                    anyhow::ensure!(
+                        target_path == &path,
+                        "V4 edit predictions only support one file"
+                    );
+                } else {
+                    let project_path = project
+                        .update(cx, |project, cx| {
+                            project.find_project_path(Path::new(&path), cx)
+                        })
+                        .with_context(|| format!("no such path: {path}"))?;
+                    let buffer = project
+                        .update(cx, |project, cx| project.open_buffer(project_path, cx))
+                        .await?;
+                    let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+
+                    target_file = Some((path, buffer, snapshot));
+                }
+
+                let (_, _, snapshot) = target_file.as_ref().context("missing target file")?;
+                let mut pending_marker: Option<(Range, String, usize)> = None;
+                for (range, text) in resolve_hunk_edits_in_buffer(
+                    hunk,
+                    snapshot,
+                    &[Anchor::min_max_range_for_buffer(snapshot.remote_id())],
+                    status,
+                )? {
+                    let mut remaining = text.as_ref();
+                    let mut output = String::new();
+
+                    if let Some((pending_range, mut pending_text, pending_offset)) =
+                        pending_marker.take()
+                    {
+                        let matched_len = INLINE_CURSOR_MARKER[pending_text.len()..]
+                            .bytes()
+                            .zip(remaining.bytes())
+                            .take_while(|(left, right)| left == right)
+                            .count();
+
+                        if matched_len == 0 {
+                            edits.push((pending_range, pending_text.into()));
+                        } else {
+                            let marker_len = pending_text.len() + matched_len;
+                            if marker_len == INLINE_CURSOR_MARKER.len() {
+                                cursor_position.get_or_insert_with(|| {
+                                    PredictedCursorPosition::new(
+                                        pending_range.start,
+                                        pending_offset,
+                                    )
+                                });
+                                remaining = &remaining[matched_len..];
+                            } else if matched_len == remaining.len() {
+                                pending_text.push_str(
+                                    &INLINE_CURSOR_MARKER[pending_text.len()..marker_len],
+                                );
+                                pending_marker =
+                                    Some((pending_range, pending_text, pending_offset));
+                                continue;
+                            } else {
+                                pending_text.push_str(&remaining[..matched_len]);
+                                edits.push((pending_range, pending_text.into()));
+                                remaining = &remaining[matched_len..];
+                            }
+                        }
+                    }
+
+                    while let Some(marker_offset) = remaining.find(INLINE_CURSOR_MARKER) {
+                        output.push_str(&remaining[..marker_offset]);
+                        cursor_position.get_or_insert_with(|| {
+                            PredictedCursorPosition::new(range.start, output.len())
+                        });
+                        remaining = &remaining[marker_offset + INLINE_CURSOR_MARKER.len()..];
+                    }
+
+                    let marker_prefix_len = (1..=INLINE_CURSOR_MARKER.len().min(remaining.len()))
+                        .rev()
+                        .find(|prefix_len| {
+                            remaining.ends_with(&INLINE_CURSOR_MARKER[..*prefix_len])
+                        });
+                    if let Some(marker_prefix_len) = marker_prefix_len {
+                        let marker_start = remaining.len() - marker_prefix_len;
+                        output.push_str(&remaining[..marker_start]);
+                        pending_marker = Some((
+                            range.clone(),
+                            remaining[marker_start..].to_string(),
+                            output.len(),
+                        ));
+                    } else {
+                        output.push_str(remaining);
+                    }
+
+                    if range.start.to_offset(snapshot) != range.end.to_offset(snapshot)
+                        || !output.is_empty()
+                    {
+                        edits.push((range, output.into()));
+                    }
+                }
+                if let Some((range, text, _)) = pending_marker {
+                    edits.push((range, text.into()));
+                }
+            }
+            DiffEvent::FileEnd { renamed_to } => {
+                anyhow::ensure!(
+                    renamed_to.is_none(),
+                    "V4 edit predictions do not support renames"
+                );
+            }
+        }
+    }
+
+    Ok(target_file.map(|(_, buffer, snapshot)| (buffer, snapshot, edits, cursor_position)))
+}
+
 #[must_use]
 pub async fn apply_diff(
     diff_str: &str,
@@ -65,7 +209,7 @@ pub async fn apply_diff(
                 if status == FileStatus::Deleted {
                     let delete_task = project.update(cx, |project, cx| {
                         if let Some(path) = project.find_project_path(path.as_ref(), cx) {
-                            project.delete_file(path, false, cx)
+                            project.delete_file(path, cx)
                         } else {
                             None
                         }
@@ -165,12 +309,12 @@ pub async fn refresh_worktree_entries(
 ) -> Result<()> {
     let mut rel_paths = Vec::new();
     for path in paths {
-        if let Ok(rel_path) = RelPath::new(path, PathStyle::Posix) {
+        if let Ok(rel_path) = RelPath::new(path, PathStyle::Unix) {
             rel_paths.push(rel_path.into_arc());
         }
 
         let path_without_root: PathBuf = path.components().skip(1).collect();
-        if let Ok(rel_path) = RelPath::new(&path_without_root, PathStyle::Posix) {
+        if let Ok(rel_path) = RelPath::new(&path_without_root, PathStyle::Unix) {
             rel_paths.push(rel_path.into_arc());
         }
     }
@@ -243,7 +387,7 @@ fn resolve_hunk_edits_in_buffer(
     buffer: &TextBufferSnapshot,
     ranges: &[Range],
     status: FileStatus,
-) -> Result, Arc)>, anyhow::Error> {
+) -> Result, Arc)>, anyhow::Error> {
     let context_offset = if status == FileStatus::Created || hunk.context.is_empty() {
         0
     } else {
@@ -272,22 +416,26 @@ fn resolve_hunk_edits_in_buffer(
         return Err(anyhow!("Edit range {:?} exceeds buffer length", edit.range));
     }
 
-    let iter = hunk.edits.into_iter().flat_map(move |edit| {
-        let old_text = buffer
-            .text_for_range(context_offset + edit.range.start..context_offset + edit.range.end)
-            .collect::();
-        let edits_within_hunk = language::text_diff(&old_text, &edit.text);
-        edits_within_hunk
-            .into_iter()
-            .map(move |(inner_range, inner_text)| {
-                (
-                    buffer.anchor_after(context_offset + edit.range.start + inner_range.start)
-                        ..buffer.anchor_before(context_offset + edit.range.start + inner_range.end),
-                    inner_text,
-                )
-            })
-    });
-    Ok(iter)
+    Ok(hunk
+        .edits
+        .into_iter()
+        .flat_map(move |edit| {
+            let old_text = buffer
+                .text_for_range(context_offset + edit.range.start..context_offset + edit.range.end)
+                .collect::();
+            let edits_within_hunk = language::text_diff(&old_text, &edit.text);
+            edits_within_hunk
+                .into_iter()
+                .map(move |(inner_range, inner_text)| {
+                    (
+                        buffer.anchor_after(context_offset + edit.range.start + inner_range.start)
+                            ..buffer
+                                .anchor_before(context_offset + edit.range.start + inner_range.end),
+                        inner_text,
+                    )
+                })
+        })
+        .collect())
 }
 
 #[cfg(test)]
@@ -295,10 +443,12 @@ mod tests {
     use super::*;
     use gpui::TestAppContext;
     use indoc::indoc;
+
     use pretty_assertions::assert_eq;
     use project::{FakeFs, Project};
     use serde_json::json;
     use settings::SettingsStore;
+    use std::path::Path;
     use util::path;
 
     #[test]
@@ -363,6 +513,170 @@ mod tests {
         assert_eq!(text, "REPLACED");
     }
 
+    #[gpui::test]
+    async fn test_prediction_edits_for_single_file_diff_can_target_project_file(
+        cx: &mut TestAppContext,
+    ) {
+        let fs = init_test(cx);
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "file1": "Hello!\nHow\nBye\n",
+                "file2": "Hola!\nComo\nAdios\n",
+            }),
+        )
+        .await;
+        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+
+        let diff = indoc! {r#"
+            --- a/file2
+            +++ b/file2
+            @@ ... @@
+             Hola!
+            -Como
+            +Como estas?
+             Adios
+        "#};
+
+        let (buffer, snapshot, edits, cursor_position) =
+            prediction_edits_for_single_file_diff(diff, &project, &mut cx.to_async())
+                .await
+                .unwrap()
+                .unwrap();
+
+        assert!(cursor_position.is_none());
+        buffer.update(cx, |buffer, cx| buffer.edit(edits, None, cx));
+        assert_eq!(
+            snapshot.file().unwrap().path().as_std_path(),
+            Path::new("file2")
+        );
+        buffer.read_with(cx, |buffer, _cx| {
+            assert_eq!(buffer.text(), "Hola!\nComo estas?\nAdios\n");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_prediction_edits_for_single_file_diff_strips_inline_cursor_marker(
+        cx: &mut TestAppContext,
+    ) {
+        let fs = init_test(cx);
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "file": "Hello!\nHow\nBye\n",
+            }),
+        )
+        .await;
+        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+
+        let diff = indoc! {r#"
+            --- a/file
+            +++ b/file
+            @@ ... @@
+             Hello!
+            -How
+            +How are <|user_cursor|>you?
+             Bye
+        "#};
+
+        let (buffer, snapshot, edits, cursor_position) =
+            prediction_edits_for_single_file_diff(diff, &project, &mut cx.to_async())
+                .await
+                .unwrap()
+                .unwrap();
+
+        assert!(
+            edits
+                .iter()
+                .all(|(_, text)| !text.contains(INLINE_CURSOR_MARKER))
+        );
+        let cursor_position = cursor_position.unwrap();
+        assert_eq!(
+            cursor_position.anchor.to_offset(&snapshot),
+            "Hello!\nHow".len()
+        );
+        assert_eq!(cursor_position.offset, " are ".len());
+
+        buffer.update(cx, |buffer, cx| buffer.edit(edits, None, cx));
+        buffer.read_with(cx, |buffer, _cx| {
+            assert_eq!(buffer.text(), "Hello!\nHow are you?\nBye\n");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_prediction_edits_for_single_file_diff_drops_marker_only_edit(
+        cx: &mut TestAppContext,
+    ) {
+        let fs = init_test(cx);
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "file": "Name\n",
+            }),
+        )
+        .await;
+        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+
+        let diff = indoc! {r#"
+            --- a/file
+            +++ b/file
+            @@ ... @@
+            -Name
+            +<|user_cursor|>Name
+        "#};
+
+        let (buffer, snapshot, edits, cursor_position) =
+            prediction_edits_for_single_file_diff(diff, &project, &mut cx.to_async())
+                .await
+                .unwrap()
+                .unwrap();
+
+        assert!(edits.is_empty());
+        let cursor_position = cursor_position.unwrap();
+        assert_eq!(cursor_position.anchor.to_offset(&snapshot), 0);
+        assert_eq!(cursor_position.offset, 0);
+
+        buffer.update(cx, |buffer, cx| buffer.edit(edits, None, cx));
+        buffer.read_with(cx, |buffer, _cx| {
+            assert_eq!(buffer.text(), "Name\n");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_prediction_edits_for_single_file_diff_does_not_treat_completed_literal_marker_as_cursor(
+        cx: &mut TestAppContext,
+    ) {
+        let fs = init_test(cx);
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "file": "text <|user_cursor\n",
+            }),
+        )
+        .await;
+        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+
+        let diff = indoc! {r#"
+            --- a/file
+            +++ b/file
+            @@ ... @@
+            -text <|user_cursor
+            +text <|user_cursor|>
+        "#};
+
+        let (buffer, _, edits, cursor_position) =
+            prediction_edits_for_single_file_diff(diff, &project, &mut cx.to_async())
+                .await
+                .unwrap()
+                .unwrap();
+
+        assert!(cursor_position.is_none());
+        buffer.update(cx, |buffer, cx| buffer.edit(edits, None, cx));
+        buffer.read_with(cx, |buffer, _cx| {
+            assert_eq!(buffer.text(), "text <|user_cursor|>\n");
+        });
+    }
+
     #[gpui::test]
     async fn test_apply_diff_successful(cx: &mut TestAppContext) {
         let fs = init_test(cx);
diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
index c3cb556c7b1b4b..b7ae7d629ec9a4 100644
--- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
+++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
@@ -222,13 +222,22 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
 
             let Some(edits) = prediction.interpolate(&snapshot) else {
                 store.reject_current_prediction(
-                    EditPredictionRejectReason::InterpolatedEmpty,
+                    EditPredictionRejectReason::InterpolateFailed,
                     &self.project,
                     cx,
                 );
                 return None;
             };
 
+            if edits.is_empty() {
+                store.reject_current_prediction(
+                    EditPredictionRejectReason::InterpolatedEmpty,
+                    &self.project,
+                    cx,
+                );
+                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, _))| {
diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs
index 172726a1d575e2..54b324253ad71c 100644
--- a/crates/edit_prediction/src/zeta.rs
+++ b/crates/edit_prediction/src/zeta.rs
@@ -1,34 +1,36 @@
 use crate::{
     CloudRequestTimeoutError, CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent,
-    EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent,
-    EditPredictionStore, ZedUpdateRequiredError, buffer_path_with_id_fallback,
+    EditPredictionId, EditPredictionInputs, EditPredictionModelInput,
+    EditPredictionStartedDebugEvent, EditPredictionStore, PromptHistoryBoundary,
+    ZedUpdateRequiredError, buffer_path_with_id_fallback,
     cursor_excerpt::{self, compute_cursor_excerpt, compute_syntax_ranges},
-    data_collection::UncommittedDiffResult,
+    data_collection::CapturedPredictionContext,
     prediction::EditPredictionResult,
+    udiff::prediction_edits_for_single_file_diff,
 };
 use anyhow::{Context as _, Result};
 use cloud_llm_client::{
     AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest,
 };
 use edit_prediction_types::PredictedCursorPosition;
-use futures::future::Shared;
 use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*};
 use language::{
     Buffer, BufferSnapshot, DiagnosticSeverity, EditPredictionPromptFormat, OffsetRangeExt as _,
     ToOffset as _, ZetaVersion, language_settings::all_language_settings, text_diff,
 };
+use project::Project;
 use release_channel::AppVersion;
 use text::{Anchor, Bias, Point};
 use ui::SharedString;
 use workspace::notifications::simple_message_notification::MessageNotification;
 use workspace::notifications::{NotificationId, show_app_notification};
 use workspace::workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError};
-use zeta_prompt::{ParsedOutput, ZetaPromptInput};
 
 use std::{ops::Range, path::Path, sync::Arc};
 use zeta_prompt::{
-    ZetaFormat, excerpt_range_for_format, format_zeta_prompt, get_prefill,
-    parse_zeta2_model_output, stop_tokens_for_format,
+    FilePosition, ParsedOutput, Zeta2PromptInput, Zeta3PromptInput, ZetaFormat,
+    excerpt_ranges_for_format, format_zeta_prompt, get_prefill, parse_zeta2_model_output,
+    stop_tokens_for_format,
     zeta1::{self, EDITABLE_REGION_END_MARKER},
 };
 
@@ -36,15 +38,15 @@ use crate::open_ai_compatible::{
     load_open_ai_compatible_api_key_if_needed, send_custom_server_request,
 };
 
-pub fn request_prediction_with_zeta(
+pub(crate) fn request_prediction_with_zeta(
     store: &mut EditPredictionStore,
     EditPredictionModelInput {
         buffer,
         snapshot,
         position,
         related_files,
+        editable_context,
         events,
-        stored_events,
         debug_tx,
         mode,
         trigger,
@@ -52,9 +54,11 @@ pub fn request_prediction_with_zeta(
         diagnostic_search_range,
         can_collect_data,
         is_open_source,
+        allow_jump,
         ..
     }: EditPredictionModelInput,
-    capture_data: Option>>,
+    context_task: Option>>,
+    prompt_history_boundary: Option,
     repo_url: Option,
     cx: &mut Context,
 ) -> Task>> {
@@ -86,15 +90,25 @@ pub fn request_prediction_with_zeta(
         .map(|organization| organization.id.clone());
     let app_version = AppVersion::global(cx);
 
-    struct Prediction {
-        prompt_input: ZetaPromptInput,
-        buffer: Entity,
-        snapshot: BufferSnapshot,
-        edits: Vec<(Range, Arc)>,
-        cursor_position: Option,
-        editable_range_in_buffer: Range,
+    enum PendingPrediction {
+        Resolved {
+            inputs: EditPredictionInputs,
+            buffer: Entity,
+            snapshot: BufferSnapshot,
+            edits: Vec<(Range, Arc)>,
+            cursor_position: Option,
+            editable_range_in_buffer: Option>,
+        },
+        Patch {
+            inputs: EditPredictionInputs,
+            project: Entity,
+            buffer: Entity,
+            snapshot: BufferSnapshot,
+            patch: String,
+        },
     }
 
+    let project_for_request = project.clone();
     let request_task = cx.background_spawn({
         async move {
             let local_zeta_version = custom_server_settings
@@ -122,27 +136,78 @@ pub fn request_prediction_with_zeta(
                 })
                 .unwrap_or_default();
 
+            enum RequestInput {
+                V3 {
+                    full_context_offset_range: Range,
+                    prompt_input: Zeta2PromptInput,
+                    formatted_prompt: Option,
+                },
+                V4(Zeta3PromptInput),
+            }
+
             let cursor_offset = position.to_offset(&snapshot);
-            let (full_context_offset_range, prompt_input) = zeta2_prompt_input(
-                &snapshot,
-                related_files,
-                events,
-                diagnostic_search_range,
-                excerpt_path,
-                cursor_offset,
-                is_open_source,
-                can_collect_data,
-                repo_url,
-            );
+            let request_input =
+                if allow_jump && custom_server_settings.is_none() && raw_config.is_none() {
+                    let cursor_point = snapshot.offset_to_point(cursor_offset);
+                    let editable_context = editable_context
+                        .context("missing editable context task")?
+                        .await?;
+                    let active_buffer_diagnostics = active_buffer_diagnostics(
+                        &snapshot,
+                        diagnostic_search_range,
+                        cursor_point.row,
+                        ACTIVE_BUFFER_DIAGNOSTIC_ADDITIONAL_CONTEXT_TOKEN_COUNT,
+                    );
+                    let syntax_ranges =
+                        compute_syntax_ranges(&snapshot, cursor_offset, &(0..snapshot.len()));
+
+                    RequestInput::V4(Zeta3PromptInput {
+                        cursor_path: excerpt_path,
+                        cursor_position: FilePosition {
+                            row: cursor_point.row,
+                            column: cursor_point.column,
+                        },
+                        events,
+                        editable_context,
+                        syntax_ranges,
+                        active_buffer_diagnostics,
+                        in_open_source_repo: is_open_source,
+                        can_collect_data,
+                        repo_url,
+                    })
+                } else {
+                    let (full_context_offset_range, prompt_input) = zeta2_prompt_input(
+                        &snapshot,
+                        related_files,
+                        events,
+                        diagnostic_search_range,
+                        excerpt_path,
+                        cursor_offset,
+                        is_open_source,
+                        can_collect_data,
+                        repo_url,
+                    );
+                    let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_format);
 
-            let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_format);
+                    RequestInput::V3 {
+                        full_context_offset_range,
+                        prompt_input,
+                        formatted_prompt,
+                    }
+                };
 
             if let Some(debug_tx) = &debug_tx {
+                let prompt = match &request_input {
+                    RequestInput::V3 {
+                        formatted_prompt, ..
+                    } => formatted_prompt.clone(),
+                    RequestInput::V4(_) => None,
+                };
                 debug_tx
                     .unbounded_send(DebugEvent::EditPredictionStarted(
                         EditPredictionStartedDebugEvent {
                             buffer: buffer.downgrade(),
-                            prompt: formatted_prompt.clone(),
+                            prompt,
                             position,
                         },
                     ))
@@ -151,6 +216,45 @@ pub fn request_prediction_with_zeta(
 
             log::trace!("Sending edit prediction request");
 
+            let (full_context_offset_range, prompt_input, formatted_prompt) = match request_input {
+                RequestInput::V3 {
+                    full_context_offset_range,
+                    prompt_input,
+                    formatted_prompt,
+                } => (full_context_offset_range, prompt_input, formatted_prompt),
+                RequestInput::V4(v4_prompt_input) => {
+                    let inputs = EditPredictionInputs::V3(v4_prompt_input.clone());
+                    let (response, usage) = EditPredictionStore::send_v4_request(
+                        v4_prompt_input,
+                        preferred_experiment.clone(),
+                        client,
+                        llm_token,
+                        organization_id,
+                        app_version,
+                        trigger,
+                        mode,
+                    )
+                    .await?;
+
+                    let request_id = EditPredictionId(response.request_id.into());
+                    let model_version = response.model_version;
+                    return anyhow::Ok((
+                        Some((
+                            request_id,
+                            Some(PendingPrediction::Patch {
+                                inputs,
+                                project: project_for_request,
+                                buffer,
+                                snapshot: snapshot.clone(),
+                                patch: response.patch,
+                            }),
+                            model_version,
+                        )),
+                        usage,
+                    ));
+                }
+            };
+
             let Some((request_id, output, model_version, usage)) =
                 (if let Some(custom_settings) = &custom_server_settings {
                     let max_tokens = custom_settings.max_output_tokens * 4;
@@ -275,7 +379,6 @@ pub fn request_prediction_with_zeta(
 
                     Some((request_id, output, None, usage))
                 } else {
-                    // Use V3 endpoint - server handles model/version selection and suffix stripping
                     let (response, usage) = EditPredictionStore::send_v3_request(
                         prompt_input.clone(),
                         preferred_experiment.clone(),
@@ -304,77 +407,69 @@ pub fn request_prediction_with_zeta(
 
             log::trace!("Got edit prediction response");
 
-            let Some(ParsedOutput {
-                new_editable_region: mut output_text,
-                range_in_excerpt: editable_range_in_excerpt,
-                cursor_offset_in_new_editable_region: cursor_offset_in_output,
-            }) = output
-            else {
-                let editable_range_in_excerpt =
-                    excerpt_range_for_format(zeta_format, &prompt_input.excerpt_ranges).0;
-                let editable_range_in_buffer = editable_range_in_excerpt.start
-                    + full_context_offset_range.start
-                    ..editable_range_in_excerpt.end + full_context_offset_range.start;
-
-                return Ok((
-                    Some((
-                        request_id,
-                        Some(Prediction {
-                            prompt_input,
-                            buffer,
-                            snapshot: snapshot.clone(),
-                            edits: Vec::new(),
-                            cursor_position: None,
-                            editable_range_in_buffer,
-                        }),
-                        model_version,
-                    )),
-                    usage,
-                ));
-            };
+            let (new_editable_region, cursor_offset_in_output, editable_range_in_excerpt) =
+                if let Some(output) = output {
+                    let ParsedOutput {
+                        new_editable_region: output_text,
+                        range_in_excerpt: editable_range_in_excerpt,
+                        cursor_offset_in_new_editable_region: cursor_offset_in_output,
+                    } = output;
+                    (
+                        Some(output_text),
+                        cursor_offset_in_output,
+                        editable_range_in_excerpt,
+                    )
+                } else {
+                    let (editable_range, _) =
+                        excerpt_ranges_for_format(zeta_format, &prompt_input.excerpt_ranges);
+                    (None, None, editable_range)
+                };
 
             let editable_range_in_buffer = editable_range_in_excerpt.start
                 + full_context_offset_range.start
                 ..editable_range_in_excerpt.end + full_context_offset_range.start;
 
-            let mut old_text = snapshot
-                .text_for_range(editable_range_in_buffer.clone())
-                .collect::();
-
             if let Some(debug_tx) = &debug_tx {
                 debug_tx
                     .unbounded_send(DebugEvent::EditPredictionFinished(
                         EditPredictionFinishedDebugEvent {
                             buffer: buffer.downgrade(),
                             position,
-                            model_output: Some(output_text.clone()),
+                            model_output: new_editable_region.clone(),
                         },
                     ))
                     .ok();
             }
+            let (edits, cursor_position) = new_editable_region
+                .map(|mut output_text| {
+                    let mut old_text = snapshot
+                        .text_for_range(editable_range_in_buffer.clone())
+                        .collect::();
 
-            if !output_text.is_empty() && !output_text.ends_with('\n') {
-                output_text.push('\n');
-            }
-            if !old_text.is_empty() && !old_text.ends_with('\n') {
-                old_text.push('\n');
-            }
+                    if !output_text.is_empty() && !output_text.ends_with('\n') {
+                        output_text.push('\n');
+                    }
+                    if !old_text.is_empty() && !old_text.ends_with('\n') {
+                        old_text.push('\n');
+                    }
 
-            let (edits, cursor_position) = compute_edits_and_cursor_position(
-                old_text,
-                &output_text,
-                editable_range_in_buffer.start,
-                cursor_offset_in_output,
-                &snapshot,
-            );
+                    compute_edits_and_cursor_position(
+                        old_text,
+                        &output_text,
+                        editable_range_in_buffer.start,
+                        cursor_offset_in_output,
+                        &snapshot,
+                    )
+                })
+                .unwrap_or_default();
 
-            let prediction = Some(Prediction {
-                prompt_input,
+            let prediction = Some(PendingPrediction::Resolved {
+                inputs: EditPredictionInputs::V2(prompt_input),
                 buffer,
                 snapshot: snapshot.clone(),
                 edits,
                 cursor_position,
-                editable_range_in_buffer,
+                editable_range_in_buffer: Some(editable_range_in_buffer),
             });
 
             anyhow::Ok((Some((request_id, prediction, model_version)), usage))
@@ -389,23 +484,98 @@ pub fn request_prediction_with_zeta(
         };
         let request_duration = cx.background_executor().now() - request_start;
 
-        let Some(Prediction {
-            prompt_input: inputs,
-            buffer: edited_buffer,
-            snapshot: edited_buffer_snapshot,
+        let Some(prediction) = prediction else {
+            return Ok(None);
+        };
+
+        let (
+            inputs,
+            edited_buffer,
+            edited_buffer_snapshot,
             edits,
             cursor_position,
             editable_range_in_buffer,
-            ..
-        }) = prediction
-        else {
-            return Ok(Some(EditPredictionResult {
-                id,
-                prediction: Err(EditPredictionRejectReason::Empty),
-                display_prediction: None,
-                model_version,
-                e2e_latency: request_duration,
-            }));
+        ) = match prediction {
+            PendingPrediction::Resolved {
+                inputs,
+                buffer,
+                snapshot,
+                edits,
+                cursor_position,
+                editable_range_in_buffer,
+            } => (
+                inputs,
+                buffer,
+                snapshot,
+                edits,
+                cursor_position,
+                editable_range_in_buffer,
+            ),
+            PendingPrediction::Patch {
+                inputs,
+                project,
+                buffer: fallback_buffer,
+                snapshot: fallback_snapshot,
+                patch,
+            } => {
+                let (buffer, snapshot, edits, cursor_position) =
+                    match prediction_edits_for_single_file_diff(&patch, &project, cx).await {
+                        Ok(Some(edits)) => edits,
+                        Ok(None) => {
+                            return Ok(Some(
+                                EditPredictionResult::new(
+                                    id,
+                                    &fallback_buffer,
+                                    &fallback_snapshot,
+                                    Arc::new([]),
+                                    None,
+                                    None,
+                                    inputs,
+                                    model_version,
+                                    trigger,
+                                    request_duration,
+                                    cx,
+                                )
+                                .await,
+                            ));
+                        }
+                        Err(error) => {
+                            log::error!("failed to apply edit prediction patch: {error:?}");
+                            return Ok(Some(EditPredictionResult::new_rejected(
+                                id,
+                                &fallback_buffer,
+                                &fallback_snapshot,
+                                inputs,
+                                model_version,
+                                trigger,
+                                request_duration,
+                                EditPredictionRejectReason::PatchApplyFailed,
+                            )));
+                        }
+                    };
+                let editable_range_in_buffer =
+                    edits
+                        .iter()
+                        .fold(None, |range: Option>, (edit_range, _)| {
+                            let edit_range = edit_range.start.to_offset(&snapshot)
+                                ..edit_range.end.to_offset(&snapshot);
+                            Some(match range {
+                                Some(range) => {
+                                    range.start.min(edit_range.start)..range.end.max(edit_range.end)
+                                }
+                                None => edit_range,
+                            })
+                        });
+
+                (
+                    inputs,
+                    buffer,
+                    snapshot,
+                    edits,
+                    cursor_position,
+                    editable_range_in_buffer,
+                )
+            }
         };
 
         let result = EditPredictionResult::new(
@@ -414,7 +584,9 @@ pub fn request_prediction_with_zeta(
             &edited_buffer_snapshot,
             edits.into(),
             cursor_position,
-            Some(edited_buffer_snapshot.anchor_range_inside(editable_range_in_buffer.clone())),
+            editable_range_in_buffer
+                .clone()
+                .map(|range| edited_buffer_snapshot.anchor_range_inside(range)),
             inputs,
             model_version,
             trigger,
@@ -423,57 +595,15 @@ pub fn request_prediction_with_zeta(
         )
         .await;
 
-        if can_collect_data && let Ok(prediction) = &result.prediction {
+        if let Some(editable_range_in_buffer) = editable_range_in_buffer.clone() {
+            let prediction = &result.prediction;
             let weak_this = this.clone();
             let request_id = prediction.id.clone();
             let edited_buffer = edited_buffer.clone();
             let edited_buffer_snapshot = edited_buffer_snapshot.clone();
-            let editable_range_in_buffer = editable_range_in_buffer.clone();
             let edit_preview = prediction.edit_preview.clone();
             let model_version = prediction.model_version.clone();
-            let example_task = capture_data.and_then(|uncommitted_diffs| {
-                let (recently_opened_files, recently_viewed_files) = this
-                    .read_with(cx, |this, _| {
-                        (
-                            this.recently_opened_files_for_project(&project),
-                            this.recently_viewed_files_for_project(&project),
-                        )
-                    })
-                    .ok()?;
-                Some(cx.spawn({
-                    let project = project.clone();
-                    let edited_buffer = edited_buffer.clone();
-                    async move |cx| {
-                        let uncommitted_diffs = uncommitted_diffs
-                            .await
-                            .map_err(|error| anyhow::anyhow!("{error:?}"))
-                            .context("failed to capture uncommitted diff")?;
-                        let Some(task) = cx.update(|cx| {
-                            crate::capture_example::capture_example(
-                                project.clone(),
-                                edited_buffer.clone(),
-                                position,
-                                stored_events,
-                                recently_opened_files,
-                                recently_viewed_files,
-                                uncommitted_diffs,
-                                false,
-                                cx,
-                            )
-                        }) else {
-                            return Err(anyhow::anyhow!("failed to capture example"));
-                        };
-                        task.await
-                    }
-                }))
-            });
             cx.spawn(async move |cx| {
-                let example_spec = if let Some(task) = example_task {
-                    task.await.ok()
-                } else {
-                    None
-                };
-
                 weak_this
                     .update(cx, |this, cx| {
                         this.enqueue_settled_prediction(
@@ -483,7 +613,8 @@ pub fn request_prediction_with_zeta(
                             &edited_buffer_snapshot,
                             editable_range_in_buffer,
                             &edit_preview,
-                            example_spec,
+                            context_task,
+                            prompt_history_boundary,
                             model_version,
                             request_duration,
                             cx,
@@ -567,7 +698,8 @@ fn handle_api_response(
 
 const ACTIVE_BUFFER_DIAGNOSTIC_ADDITIONAL_CONTEXT_TOKEN_COUNT: usize = 100;
 const MAX_ACTIVE_BUFFER_DIAGNOSTICS_TO_COLLECT: usize = 20;
-const MAX_ACTIVE_BUFFER_DIAGNOSTIC_SNIPPET_TOKENS_TO_COLLECT: usize = 512;
+pub(crate) const MAX_ACTIVE_BUFFER_DIAGNOSTIC_MESSAGE_TOKENS_TO_COLLECT: usize = 512;
+pub(crate) const MAX_ACTIVE_BUFFER_DIAGNOSTIC_SNIPPET_TOKENS_TO_COLLECT: usize = 512;
 
 pub(crate) fn active_buffer_diagnostics(
     snapshot: &language::BufferSnapshot,
@@ -601,7 +733,11 @@ pub(crate) fn active_buffer_diagnostics(
             };
             (
                 severity,
-                entry.diagnostic.message.clone(),
+                zeta_prompt::clamp_text_to_token_count(
+                    &entry.diagnostic.message,
+                    MAX_ACTIVE_BUFFER_DIAGNOSTIC_MESSAGE_TOKENS_TO_COLLECT,
+                )
+                .to_string(),
                 diagnostic_point_range,
                 snippet_point_range,
             )
@@ -650,7 +786,7 @@ pub(crate) fn active_buffer_diagnostics(
 
 pub fn zeta2_prompt_input(
     snapshot: &language::BufferSnapshot,
-    related_files: Vec,
+    mut related_files: Vec,
     events: Vec>,
     diagnostic_search_range: Range,
     excerpt_path: Arc,
@@ -658,7 +794,7 @@ pub fn zeta2_prompt_input(
     is_open_source: bool,
     can_collect_data: bool,
     repo_url: Option,
-) -> (Range, zeta_prompt::ZetaPromptInput) {
+) -> (Range, zeta_prompt::Zeta2PromptInput) {
     let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) =
         compute_cursor_excerpt(snapshot, cursor_offset);
 
@@ -679,8 +815,13 @@ pub fn zeta2_prompt_input(
         snapshot.offset_to_point(cursor_offset).row,
         ACTIVE_BUFFER_DIAGNOSTIC_ADDITIONAL_CONTEXT_TOKEN_COUNT,
     );
+    for file in &mut related_files {
+        for excerpt in &mut file.excerpts {
+            excerpt.context_source = zeta_prompt::ContextSource::Lsp;
+        }
+    }
 
-    let prompt_input = zeta_prompt::ZetaPromptInput {
+    let prompt_input = zeta_prompt::Zeta2PromptInput {
         cursor_path: excerpt_path,
         cursor_excerpt,
         cursor_offset_in_excerpt,
diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs
index 0b5a75260fcf1a..f7e2f298bf95a8 100644
--- a/crates/edit_prediction_cli/src/example.rs
+++ b/crates/edit_prediction_cli/src/example.rs
@@ -16,7 +16,7 @@ use std::{
     io::Read,
     path::{Path, PathBuf},
 };
-use zeta_prompt::ZetaPromptInput;
+use zeta_prompt::Zeta2PromptInput;
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct Example {
@@ -26,7 +26,7 @@ pub struct Example {
     /// 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 prompt_inputs: Option,
+    pub prompt_inputs: Option,
 
     /// The input and expected output from the edit prediction model.
     #[serde(skip_serializing_if = "Option::is_none")]
@@ -116,6 +116,16 @@ impl ActualCursor {
         editable_region_byte_offset: usize,
         editable_region_start_line: usize,
     ) -> Self {
+        // Defensive: a malformed/edge-case cursor offset must never panic and
+        // abort an (expensive) batch run. Clamp into range and snap down to a
+        // char boundary before slicing.
+        let mut editable_region_cursor_offset =
+            editable_region_cursor_offset.min(new_editable_region.len());
+        while editable_region_cursor_offset > 0
+            && !new_editable_region.is_char_boundary(editable_region_cursor_offset)
+        {
+            editable_region_cursor_offset -= 1;
+        }
         let global_offset = editable_region_byte_offset + editable_region_cursor_offset;
         let new_region_prefix = &new_editable_region[..editable_region_cursor_offset];
         let row = (editable_region_start_line + new_region_prefix.matches('\n').count()) as u32;
diff --git a/crates/edit_prediction_cli/src/filter_languages.rs b/crates/edit_prediction_cli/src/filter_languages.rs
index cdf503fa23c95d..754ca7d1ba92ec 100644
--- a/crates/edit_prediction_cli/src/filter_languages.rs
+++ b/crates/edit_prediction_cli/src/filter_languages.rs
@@ -514,6 +514,11 @@ mod tests {
             detect_language(".env", &map),
             Some("Shell Script".to_string())
         );
+        // Gentoo ebuild files are a subset of bash
+        assert_eq!(
+            detect_language("app-editors/zed-1.5.4.ebuild", &map),
+            Some("Shell Script".to_string())
+        );
     }
 
     #[test]
diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs
index 8007499315a8e7..b7a752f96b2c43 100644
--- a/crates/edit_prediction_cli/src/format_prompt.rs
+++ b/crates/edit_prediction_cli/src/format_prompt.rs
@@ -10,11 +10,14 @@ use gpui::AsyncApp;
 use std::ops::Range;
 use std::sync::Arc;
 use zeta_prompt::{
-    ZetaFormat, format_expected_output, format_zeta_prompt, multi_region, resolve_cursor_region,
+    Zeta2PromptInput, ZetaFormat, format_edit_history_within_budget, format_expected_output,
+    format_zeta_prompt,
+    hashed_regions::{self, SnippetMarkers},
+    max_edit_event_count_for_format, resolve_cursor_region,
 };
 
 fn resolved_excerpt_ranges_for_format(
-    input: &zeta_prompt::ZetaPromptInput,
+    input: &zeta_prompt::Zeta2PromptInput,
     format: ZetaFormat,
 ) -> (Range, Range) {
     let (_, editable_range_in_context, context_range, _) = resolve_cursor_region(input, format);
@@ -40,6 +43,21 @@ pub async fn run_format_prompt(
     )
     .await?;
 
+    // Teacher-jumps addresses every edit through related-file excerpts and
+    // hard-errors unless the cursor file is covered by one. Settled-data
+    // samples carry a `cursor_excerpt` but were not run through current-file
+    // context retrieval, so normalize the input to synthesize a current-file
+    // excerpt from it when the cursor isn't already covered (a no-op for proper
+    // `ep context --type=current-file` runs).
+    if matches!(
+        args.provider,
+        PredictionProvider::TeacherJumps(_) | PredictionProvider::TeacherJumpsNonBatching(_)
+    ) {
+        if let Some(prompt_inputs) = example.prompt_inputs.as_mut() {
+            hashed_regions::ensure_cursor_file_excerpt(prompt_inputs);
+        }
+    }
+
     let step_progress = example_progress.start(Step::FormatPrompt);
 
     let prompt_inputs = example
@@ -71,22 +89,10 @@ pub async fn run_format_prompt(
                 provider: args.provider,
             });
         }
-        PredictionProvider::TeacherMultiRegion(_)
-        | PredictionProvider::TeacherMultiRegionNonBatching(_) => {
-            step_progress.set_substatus("formatting teacher multi-region prompt");
+        PredictionProvider::TeacherJumps(_) | PredictionProvider::TeacherJumpsNonBatching(_) => {
+            step_progress.set_substatus("formatting teacher jumps prompt");
 
-            let zeta_format = ZetaFormat::default();
-            let (editable_range, context_range) =
-                resolved_excerpt_ranges_for_format(prompt_inputs, zeta_format);
-
-            let include_diagnostics = matches!(zeta_format, ZetaFormat::V0420Diagnostics);
-
-            let prompt = TeacherMultiRegionPrompt::format_prompt(
-                example,
-                editable_range,
-                context_range,
-                include_diagnostics,
-            );
+            let prompt = TeacherJumpsPrompt::format_prompt(example, args.related_files_budget)?;
             example.prompt = Some(ExamplePrompt {
                 input: prompt,
                 expected_output: None,
@@ -351,36 +357,48 @@ impl TeacherPrompt {
     }
 }
 
-pub struct TeacherMultiRegionPrompt;
+/// Teacher prompt for long-range edit prediction ("jumps"). All prompt
+/// context — the cursor file and every related-file excerpt — is annotated
+/// with hashed region markers (V0609HashedRegions), and the teacher may
+/// output a sequence of marker-bounded edits targeting any of it.
+pub struct TeacherJumpsPrompt;
 
-impl TeacherMultiRegionPrompt {
+impl TeacherJumpsPrompt {
     pub(crate) const USER_CURSOR_MARKER: &str = "<|user_cursor|>";
     pub(crate) const NO_EDITS: &str = "NO_EDITS";
 
-    /// Truncate edit history to this number of last lines
-    const MAX_HISTORY_LINES: usize = 128;
+    const MAX_HISTORY_TOKENS: usize = 4000;
 
-    pub fn format_prompt(
-        example: &Example,
-        editable_range: Range,
-        context_range: Range,
-        include_diagnostics: bool,
-    ) -> String {
-        let edit_history = Self::format_edit_history(&example.spec.edit_history);
-        let context = Self::format_context(example);
-        let cursor_excerpt = Self::format_cursor_excerpt(example, editable_range, context_range);
-        let diagnostics = include_diagnostics
-            .then(|| TeacherPrompt::format_diagnostics(example))
-            .map(|diagnostics| format!("# 4. Diagnostics\n\n{diagnostics}"));
+    pub const DEFAULT_RELATED_FILES_BUDGET: usize = 8192;
 
-        let prompt_template = crate::prompt_assets::get_prompt("teacher_multi_region.md");
+    pub fn format_prompt(example: &Example, related_files_budget: usize) -> Result {
+        let prompt_inputs = example
+            .prompt_inputs
+            .as_ref()
+            .context("example is missing prompt inputs")?;
+        let marker_table = hashed_regions::build_marker_table(prompt_inputs);
+        let cursor = hashed_regions::locate_cursor_in_related_files(prompt_inputs).context(
+            "cursor position is not covered by any related-file excerpt of the cursor file; \
+             teacher-jumps requires current-file context retrieval (e.g. `ep context --type=current-file,...`)",
+        )?;
+
+        let edit_history = Self::format_edit_history(&prompt_inputs);
+        let context = Self::format_context(
+            prompt_inputs,
+            &marker_table,
+            related_files_budget,
+            cursor.file_ix,
+        );
+        let cursor_excerpt =
+            Self::format_cursor_excerpt(example, prompt_inputs, &marker_table, &cursor)?;
+
+        let prompt_template = crate::prompt_assets::get_prompt("teacher_jumps.md");
         let prompt = prompt_template
             .replace("{{context}}", &context)
             .replace("{{edit_history}}", &edit_history)
-            .replace("{{diagnostics}}", diagnostics.as_deref().unwrap_or(""))
             .replace("{{cursor_excerpt}}", &cursor_excerpt);
 
-        prompt
+        Ok(prompt)
     }
 
     pub fn parse(example: &Example, response: &str) -> Result<(String, Option)> {
@@ -400,155 +418,238 @@ impl TeacherMultiRegionPrompt {
             .as_ref()
             .context("example is missing prompt inputs")?;
 
-        let zeta_format = ZetaFormat::default();
-        let (editable_range, _) = resolved_excerpt_ranges_for_format(prompt_inputs, zeta_format);
-        let excerpt = prompt_inputs.cursor_excerpt.as_ref();
-        let old_editable_region = &excerpt[editable_range.clone()];
-        let marker_offsets = multi_region::compute_marker_offsets(old_editable_region);
-
-        let codeblock =
-            extract_last_codeblock(&response).context("no codeblock found in model response")?;
-        let (start_num, end_num, raw_new_span) = multi_region::extract_marker_span(&codeblock)?;
-
-        let start_idx = start_num
-            .checked_sub(1)
-            .context("marker numbers are 1-indexed")?;
-        let end_idx = end_num
-            .checked_sub(1)
-            .context("marker numbers are 1-indexed")?;
-        let start_byte = *marker_offsets
-            .get(start_idx)
-            .context("start marker number out of range")?;
-        let end_byte = *marker_offsets
-            .get(end_idx)
-            .context("end marker number out of range")?;
-
-        if start_byte > end_byte {
-            return Err(anyhow!("start marker must come before end marker"));
+        // The teacher emits reasoning plus a sequence of markdown code fences,
+        // one per edit, each a marker-bounded span. Extract the spans from the
+        // fences, then hand off to the shared hash-region patch assembler that
+        // the student parser also uses.
+        let codeblocks: Vec = extract_all_codeblocks(response)
+            .into_iter()
+            .filter(|block| block.contains(hashed_regions::MARKER_TAG_PREFIX))
+            .collect();
+        if codeblocks.is_empty() {
+            return Err(anyhow!(
+                "no marker-bounded edit codeblocks found in model response"
+            ));
         }
 
-        let cursor_in_span = raw_new_span.find(Self::USER_CURSOR_MARKER);
-        let new_span = raw_new_span.replace(Self::USER_CURSOR_MARKER, "");
-
-        let old_span = &old_editable_region[start_byte..end_byte];
-        let mut new_span = new_span;
-        if old_span.ends_with('\n') && !new_span.ends_with('\n') && !new_span.is_empty() {
-            new_span.push('\n');
+        let mut spans = Vec::with_capacity(codeblocks.len());
+        for codeblock in &codeblocks {
+            spans.push(hashed_regions::extract_marker_span(codeblock)?);
         }
-        if !old_span.ends_with('\n') && new_span.ends_with('\n') {
-            new_span.pop();
-        }
-
-        let mut new_editable_region = String::new();
-        new_editable_region.push_str(&old_editable_region[..start_byte]);
-        new_editable_region.push_str(&new_span);
-        new_editable_region.push_str(&old_editable_region[end_byte..]);
 
-        let cursor_offset = cursor_in_span.map(|pos| start_byte + pos);
-
-        if old_editable_region.starts_with('\n') && !new_editable_region.starts_with('\n') {
-            new_editable_region.insert(0, '\n');
-        }
-
-        let editable_region_offset = editable_range.start;
-        let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count();
-
-        let editable_region_lines = old_editable_region.lines().count() as u32;
-        let diff = language::unified_diff_with_context(
-            old_editable_region,
-            &new_editable_region,
-            editable_region_start_line as u32,
-            editable_region_start_line as u32,
-            editable_region_lines,
-        );
-
-        let diff = indoc::formatdoc! {"
-            --- a/{path}
-            +++ b/{path}
-            {diff}",
-            path = example.spec.cursor_path.to_string_lossy(),
-            diff = diff,
-        };
+        let (patch, cursor) = hashed_regions::build_patch_from_spans(
+            prompt_inputs,
+            &spans,
+            Self::USER_CURSOR_MARKER,
+        )?;
 
-        let actual_cursor = cursor_offset.map(|editable_region_cursor_offset| {
+        let actual_cursor = cursor.map(|cursor| {
             ActualCursor::from_editable_region(
-                &example.spec.cursor_path,
-                editable_region_cursor_offset,
-                &new_editable_region,
-                excerpt,
-                editable_region_offset,
-                editable_region_start_line,
+                &cursor.path,
+                cursor.cursor_offset_in_new_text,
+                &cursor.new_text,
+                &cursor.old_text,
+                0,
+                cursor.start_row as usize,
             )
         });
 
-        Ok((diff, actual_cursor))
+        Ok((patch, actual_cursor))
     }
 
-    fn format_edit_history(edit_history: &str) -> String {
-        let lines: Vec<&str> = edit_history.lines().collect();
-
-        if lines.is_empty() {
-            return "(No edit history)".to_string();
-        }
-
-        if lines.len() > Self::MAX_HISTORY_LINES {
-            let truncated = lines[lines.len() - Self::MAX_HISTORY_LINES..].join("\n");
-            format!("{truncated}\n[...truncated...]")
-        } else {
-            lines.join("\n")
-        }
+    fn format_edit_history(prompt_inputs: &Zeta2PromptInput) -> String {
+        format_edit_history_within_budget(
+            &prompt_inputs.events,
+            "",
+            "",
+            Self::MAX_HISTORY_TOKENS,
+            max_edit_event_count_for_format(&ZetaFormat::V0327SingleFile),
+        )
     }
 
-    pub fn format_context(example: &Example) -> String {
-        let related_files = example
-            .prompt_inputs
-            .as_ref()
-            .and_then(|pi| pi.related_files.as_deref());
-        let Some(related_files) = related_files else {
+    /// Render related files with hashed region markers, within a token
+    /// budget. Mirrors `zeta_prompt::format_related_files_within_budget`,
+    /// but inserts marker tags into every included excerpt. The cursor file
+    /// is skipped: it renders in its own prompt section via
+    /// `format_cursor_excerpt`, and including it here would duplicate it.
+    fn format_context(
+        prompt_inputs: &Zeta2PromptInput,
+        marker_table: &[SnippetMarkers],
+        max_tokens: usize,
+        cursor_file_ix: usize,
+    ) -> String {
+        let Some(related_files) = prompt_inputs.related_files.as_deref() else {
             return "(No context)".to_string();
         };
-
         if related_files.is_empty() {
             return "(No context)".to_string();
         }
 
-        let prefix = "`````";
-        let suffix = "`````\n\n";
-        let max_tokens = 1024;
-        zeta_prompt::format_related_files_within_budget(related_files, &prefix, &suffix, max_tokens)
-    }
+        let estimate_tokens = |bytes: usize| bytes / 3;
 
-    fn format_cursor_excerpt(
-        example: &Example,
-        editable_range: Range,
-        context_range: Range,
-    ) -> String {
-        let mut result = String::new();
+        struct RenderedExcerpt {
+            file_ix: usize,
+            excerpt_ix: usize,
+            order: usize,
+            rendered: String,
+        }
 
-        let prompt_inputs = example.prompt_inputs.as_ref().unwrap();
-        let excerpt = prompt_inputs.cursor_excerpt.as_ref();
-        let cursor_offset = prompt_inputs.cursor_offset_in_excerpt;
+        let mut candidates = Vec::new();
+        for (file_ix, file) in related_files.iter().enumerate() {
+            if file_ix == cursor_file_ix {
+                continue;
+            }
+            for (excerpt_ix, excerpt) in file.excerpts.iter().enumerate() {
+                let markers = marker_table.iter().find_map(|snippet| {
+                    (snippet.file_ix == file_ix && snippet.excerpt_ix == excerpt_ix)
+                        .then_some(&snippet.markers)
+                });
+                let mut rendered = String::new();
+                match markers {
+                    Some(markers) => hashed_regions::write_snippet_with_markers(
+                        &mut rendered,
+                        &excerpt.text,
+                        markers,
+                        None,
+                    ),
+                    None => rendered.push_str(&excerpt.text),
+                }
+                if !rendered.ends_with('\n') {
+                    rendered.push('\n');
+                }
+                candidates.push(RenderedExcerpt {
+                    file_ix,
+                    excerpt_ix,
+                    order: excerpt.order,
+                    rendered,
+                });
+            }
+        }
 
-        let editable_text = &excerpt[editable_range.clone()];
-        let cursor_in_editable = cursor_offset - editable_range.start;
+        let file_headers: Vec = related_files
+            .iter()
+            .map(|file| format!("`````{}\n", file.path.to_string_lossy()))
+            .collect();
+        let file_suffix = "`````\n\n";
 
-        let path_str = example.spec.cursor_path.to_string_lossy();
-        result.push_str(&format!("`````{path_str}\n"));
+        let mut selection_order: Vec = (0..candidates.len()).collect();
+        selection_order.sort_by_key(|&candidate_ix| {
+            let candidate = &candidates[candidate_ix];
+            (candidate.order, candidate.file_ix, candidate.excerpt_ix)
+        });
 
-        result.push_str(&excerpt[context_range.start..editable_range.start]);
+        let mut total_tokens = 0;
+        let mut included = vec![false; candidates.len()];
+        let mut file_included = vec![false; related_files.len()];
+        for &candidate_ix in &selection_order {
+            let candidate = &candidates[candidate_ix];
+            let header_cost = if file_included[candidate.file_ix] {
+                0
+            } else {
+                estimate_tokens(file_headers[candidate.file_ix].len() + file_suffix.len())
+            };
+            let excerpt_cost = estimate_tokens(candidate.rendered.len());
+            if total_tokens + header_cost + excerpt_cost > max_tokens {
+                break;
+            }
+            total_tokens += header_cost + excerpt_cost;
+            file_included[candidate.file_ix] = true;
+            included[candidate_ix] = true;
+        }
 
-        multi_region::write_editable_with_markers(
-            &mut result,
-            editable_text,
-            cursor_in_editable,
-            Self::USER_CURSOR_MARKER,
-        );
+        let mut result = String::new();
+        let mut last_file_ix = None;
+        for (candidate_ix, candidate) in candidates.iter().enumerate() {
+            if !included[candidate_ix] {
+                continue;
+            }
+            if last_file_ix != Some(candidate.file_ix) {
+                if last_file_ix.is_some() {
+                    result.push_str(file_suffix);
+                }
+                result.push_str(&file_headers[candidate.file_ix]);
+                last_file_ix = Some(candidate.file_ix);
+            }
+            result.push_str(&candidate.rendered);
+
+            let file = &related_files[candidate.file_ix];
+            let excerpt = &file.excerpts[candidate.excerpt_ix];
+            let next_excerpt_start = candidates
+                .iter()
+                .enumerate()
+                .skip(candidate_ix + 1)
+                .find(|(next_ix, next)| included[*next_ix] && next.file_ix == candidate.file_ix)
+                .map(|(_, next)| file.excerpts[next.excerpt_ix].row_range.start);
+            if zeta_prompt::rows_omitted_after_excerpt(excerpt, next_excerpt_start, file.max_row) {
+                result.push_str("...\n");
+            }
+        }
+        if last_file_ix.is_some() {
+            result.push_str(file_suffix);
+        }
 
-        result.push_str(&excerpt[editable_range.end..context_range.end]);
-        result.push_str("\n`````");
+        if result.is_empty() {
+            "(No context)".to_string()
+        } else {
+            result
+        }
+    }
 
-        result
+    /// Render the current file from its related-file entry, with marker tags
+    /// and the user cursor injected. The current file gets its own prompt
+    /// section but shares the related-file snippets and markers, so its
+    /// content appears in the prompt exactly once.
+    fn format_cursor_excerpt(
+        example: &Example,
+        prompt_inputs: &Zeta2PromptInput,
+        marker_table: &[SnippetMarkers],
+        cursor: &hashed_regions::RelatedFileCursor,
+    ) -> Result {
+        let related_files = prompt_inputs
+            .related_files
+            .as_deref()
+            .context("prompt inputs are missing related files")?;
+        let file = related_files
+            .get(cursor.file_ix)
+            .context("cursor file index out of range")?;
+
+        let path_str = example.spec.cursor_path.to_string_lossy();
+        let mut result = format!("`````{path_str}\n");
+        for (excerpt_ix, excerpt) in file.excerpts.iter().enumerate() {
+            let markers = marker_table
+                .iter()
+                .find_map(|snippet| {
+                    (snippet.file_ix == cursor.file_ix && snippet.excerpt_ix == excerpt_ix)
+                        .then_some(&snippet.markers)
+                })
+                .context("marker table is missing a cursor file snippet")?;
+            let cursor_in_excerpt = (excerpt_ix == cursor.excerpt_ix)
+                .then_some((cursor.offset_in_excerpt, Self::USER_CURSOR_MARKER));
+            hashed_regions::write_snippet_with_markers(
+                &mut result,
+                &excerpt.text,
+                markers,
+                cursor_in_excerpt,
+            );
+            if !result.ends_with('\n') {
+                result.push('\n');
+            }
+            if excerpt.row_range.end < file.max_row {
+                result.push_str("...\n");
+            }
+        }
+        result.push_str("`````");
+
+        Ok(result)
+    }
+}
+
+pub(crate) fn line_start_offset(text: &str, row: usize) -> Option {
+    let mut offset = 0;
+    for _ in 0..row {
+        offset += text[offset..].find('\n')? + 1;
     }
+    Some(offset)
 }
 
 /// Extract the cursor excerpt from an example.
@@ -597,6 +698,42 @@ pub fn extract_cursor_excerpt_from_example(example: &Example) -> Option
     Some(result)
 }
 
+/// Extract all top-level fenced codeblocks from `text`, in order.
+///
+/// A fence opens with 3+ backticks (optionally followed by an info string)
+/// and closes with a line of at least as many backticks, so codeblocks that
+/// themselves contain shorter fences are handled.
+pub(crate) fn extract_all_codeblocks(text: &str) -> Vec {
+    let mut codeblocks = Vec::new();
+    let mut current_block: Option<(usize, Vec<&str>)> = None;
+
+    for line in text.lines() {
+        match &mut current_block {
+            None => {
+                let backtick_count = line.chars().take_while(|&c| c == '`').count();
+                if backtick_count >= 3 {
+                    current_block = Some((backtick_count, Vec::new()));
+                }
+            }
+            Some((opening_count, lines)) => {
+                let trimmed = line.trim();
+                if trimmed.len() >= *opening_count && trimmed.chars().all(|c| c == '`') {
+                    let mut content = lines.join("\n");
+                    if !content.is_empty() {
+                        content.push('\n');
+                    }
+                    codeblocks.push(content);
+                    current_block = None;
+                } else {
+                    lines.push(line);
+                }
+            }
+        }
+    }
+
+    codeblocks
+}
+
 pub(crate) fn extract_last_codeblock(text: &str) -> Option {
     let lines: Vec<&str> = text.lines().collect();
 
@@ -645,6 +782,571 @@ pub(crate) fn extract_last_codeblock(text: &str) -> Option {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use zeta_prompt::multi_region;
+
+    fn make_example(
+        cursor_excerpt: &str,
+        cursor_offset: usize,
+        related: &[(&str, &[(&str, u32)])],
+    ) -> Example {
+        // The cursor file is included as the first related file, mirroring
+        // `ContextSource::CurrentFile` context retrieval.
+        let cursor_file = zeta_prompt::RelatedFile {
+            path: std::sync::Arc::from(std::path::Path::new("src/main.rs")),
+            max_row: 1000,
+            excerpts: vec![zeta_prompt::RelatedExcerpt {
+                row_range: 0..cursor_excerpt.matches('\n').count() as u32,
+                text: std::sync::Arc::from(cursor_excerpt),
+                order: 0,
+                context_source: zeta_prompt::ContextSource::CurrentFile,
+            }],
+            in_open_source_repo: false,
+        };
+        let related_files = std::iter::once(cursor_file)
+            .chain(related.iter().map(|(path, excerpts)| {
+                zeta_prompt::RelatedFile {
+                    path: std::sync::Arc::from(std::path::Path::new(path)),
+                    max_row: 1000,
+                    excerpts: excerpts
+                        .iter()
+                        .map(|(text, start_row)| zeta_prompt::RelatedExcerpt {
+                            row_range: *start_row..*start_row + text.matches('\n').count() as u32,
+                            text: std::sync::Arc::from(*text),
+                            order: 0,
+                            context_source: zeta_prompt::ContextSource::CurrentFile,
+                        })
+                        .collect(),
+                    in_open_source_repo: false,
+                }
+            }))
+            .collect();
+
+        Example {
+            spec: edit_prediction::example_spec::ExampleSpec {
+                name: "test".to_string(),
+                repository_url: "https://github.com/zed-industries/zed.git".to_string(),
+                revision: "HEAD".to_string(),
+                tags: Vec::new(),
+                reasoning: None,
+                uncommitted_diff: String::new(),
+                recently_opened_files: Vec::new(),
+                recently_viewed_files: Vec::new(),
+                uncommitted_diff_contains_edit_history: false,
+                cursor_path: std::sync::Arc::from(std::path::Path::new("src/main.rs")),
+                cursor_position: "0:0".to_string(),
+                edit_history: String::new(),
+                expected_patches: Vec::new(),
+                rejected_patch: None,
+                telemetry: None,
+                human_feedback: Vec::new(),
+                rating: None,
+            },
+            prompt_inputs: Some(zeta_prompt::Zeta2PromptInput {
+                cursor_path: std::path::Path::new("src/main.rs").into(),
+                cursor_excerpt: cursor_excerpt.into(),
+                cursor_offset_in_excerpt: cursor_offset,
+                excerpt_start_row: Some(0),
+                events: Vec::new(),
+                related_files: Some(related_files),
+                active_buffer_diagnostics: Vec::new(),
+                excerpt_ranges: zeta_prompt::ExcerptRanges::default(),
+                syntax_ranges: None,
+                in_open_source_repo: false,
+                can_collect_data: false,
+                repo_url: None,
+            }),
+            prompt: None,
+            predictions: Vec::new(),
+            score: Vec::new(),
+            qa: Vec::new(),
+            zed_version: None,
+            state: None,
+        }
+    }
+
+    #[test]
+    fn test_teacher_jumps_format_prompt_markers_everywhere() {
+        let example = make_example(
+            "fn main() {\n    let x = 1;\n}\n",
+            16,
+            &[("src/lib.rs", &[("pub fn helper() {}\n", 5)])],
+        );
+        let prompt = TeacherJumpsPrompt::format_prompt(&example, 8192).unwrap();
+
+        assert!(prompt.contains(TeacherJumpsPrompt::USER_CURSOR_MARKER));
+        assert!(prompt.contains("`````src/main.rs\n"));
+        assert!(prompt.contains("`````src/lib.rs\n"));
+        // Markers in both the current file and the related excerpt.
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        for snippet in &marker_table {
+            for (id, _) in &snippet.markers {
+                assert!(
+                    prompt.contains(&hashed_regions::marker_tag(id)),
+                    "prompt is missing marker {id}"
+                );
+            }
+        }
+        // The current file appears exactly once, in its own section, with the
+        // user cursor injected.
+        assert_eq!(prompt.matches("let x = 1;").count(), 1);
+        assert!(prompt.contains("<|user_cursor|>let x = 1;"));
+    }
+
+    #[test]
+    fn test_teacher_jumps_cursor_file_with_coinciding_worktree_root_name() {
+        // Worktree root `jaq` contains a `jaq/` subdirectory: the cursor path
+        // is `jaq/src/main.rs` while the related-file entry is prefixed with
+        // the root name (`jaq/jaq/src/main.rs`).
+        let mut example = make_example("fn main() {\n    let x = 1;\n}\n", 16, &[]);
+        example.spec.cursor_path = std::sync::Arc::from(std::path::Path::new("jaq/src/main.rs"));
+        {
+            let prompt_inputs = example.prompt_inputs.as_mut().unwrap();
+            prompt_inputs.cursor_path = std::path::Path::new("jaq/src/main.rs").into();
+            prompt_inputs.related_files.as_mut().unwrap()[0].path =
+                std::sync::Arc::from(std::path::Path::new("jaq/jaq/src/main.rs"));
+        }
+
+        let prompt = TeacherJumpsPrompt::format_prompt(&example, 8192).unwrap();
+        assert!(prompt.contains("<|user_cursor|>let x = 1;"));
+
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let cursor_markers = &marker_table[0].markers;
+        let start_tag = hashed_regions::marker_tag(&cursor_markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&cursor_markers[cursor_markers.len() - 1].0);
+        let response =
+            format!("`````\n{start_tag}\nfn main() {{\n    let x = 2;\n}}\n{end_tag}\n`````\n");
+        let (patch, _) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+        assert!(patch.contains("--- a/jaq/src/main.rs"), "patch: {patch}");
+    }
+
+    #[test]
+    fn test_teacher_jumps_format_prompt_requires_current_file_context() {
+        let mut example = make_example("fn main() {}\n", 0, &[]);
+        example.prompt_inputs.as_mut().unwrap().related_files = Some(Vec::new());
+        assert!(TeacherJumpsPrompt::format_prompt(&example, 8192).is_err());
+    }
+
+    #[test]
+    fn test_teacher_jumps_synthesizes_missing_cursor_file_excerpt() {
+        // Simulate a settled-data sample: `cursor_excerpt` is present, but the
+        // related-file excerpts of the cursor file don't cover the cursor (only
+        // an unrelated fragment elsewhere in the file).
+        let mut example = make_example("fn main() {\n    let x = 1;\n}\n", 16, &[]);
+        {
+            let prompt_inputs = example.prompt_inputs.as_mut().unwrap();
+            prompt_inputs.related_files.as_mut().unwrap()[0].excerpts =
+                vec![zeta_prompt::RelatedExcerpt {
+                    row_range: 40..42,
+                    text: std::sync::Arc::from("// unrelated\n// fragment\n"),
+                    order: 0,
+                    context_source: zeta_prompt::ContextSource::Bm25,
+                }];
+        }
+
+        // Without a covering cursor-file excerpt, formatting hard-errors.
+        assert!(TeacherJumpsPrompt::format_prompt(&example, 8192).is_err());
+
+        // `ensure_cursor_file_excerpt` (in zeta_prompt) synthesizes one from
+        // `cursor_excerpt`, so the prompt formats with the cursor in the
+        // current-file window and the unrelated fragment is replaced (no
+        // duplicated content with overlapping markers).
+        hashed_regions::ensure_cursor_file_excerpt(example.prompt_inputs.as_mut().unwrap());
+        let prompt = TeacherJumpsPrompt::format_prompt(&example, 8192).unwrap();
+        assert!(prompt.contains("<|user_cursor|>let x = 1;"));
+        assert!(!prompt.contains("// unrelated"));
+        assert_eq!(prompt.matches("let x = 1;").count(), 1);
+    }
+
+    #[test]
+    fn test_teacher_jumps_cursor_file_hunks_are_file_absolute() {
+        let mut example = make_example("fn main() {\n    let x = 1;\n}\n", 16, &[]);
+        {
+            let prompt_inputs = example.prompt_inputs.as_mut().unwrap();
+            prompt_inputs.excerpt_start_row = Some(10);
+            prompt_inputs.related_files.as_mut().unwrap()[0].excerpts[0].row_range = 10..13;
+        }
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let cursor_markers = &marker_table[0].markers;
+        let start_tag = hashed_regions::marker_tag(&cursor_markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&cursor_markers[cursor_markers.len() - 1].0);
+
+        let response = format!(
+            "The user is changing x.\n\n`````\n{start_tag}\nfn main() {{\n    let x = 2;<|user_cursor|>\n}}\n{end_tag}\n`````\n"
+        );
+        let (patch, cursor) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+
+        // Hunk rows are file-absolute (1-based in the hunk header, excerpt
+        // starts at 0-based row 10).
+        assert!(patch.contains("@@ -11,"), "patch: {patch}");
+        let cursor = cursor.unwrap();
+        assert_eq!(cursor.row, 11);
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_single_edit_in_cursor_file() {
+        let example = make_example("fn main() {\n    let x = 1;\n}\n", 16, &[]);
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let cursor_markers = &marker_table[0].markers;
+        let start_tag = hashed_regions::marker_tag(&cursor_markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&cursor_markers[cursor_markers.len() - 1].0);
+
+        let response = format!(
+            "The user is changing x.\n\n`````\n{start_tag}\nfn main() {{\n    let x = 2;<|user_cursor|>\n}}\n{end_tag}\n`````\n"
+        );
+        let (patch, cursor) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+
+        assert!(patch.contains("--- a/src/main.rs"), "patch: {patch}");
+        assert!(patch.contains("-    let x = 1;"), "patch: {patch}");
+        assert!(patch.contains("+    let x = 2;"), "patch: {patch}");
+        let cursor = cursor.unwrap();
+        assert_eq!(cursor.path, "src/main.rs");
+        assert_eq!(cursor.row, 1);
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_sequence_across_files() {
+        let example = make_example(
+            "fn fetch_user_cached() {}\n",
+            0,
+            &[(
+                "src/server.rs",
+                &[("fn handle() {\n    fetch_user();\n}\n", 10)],
+            )],
+        );
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        assert_eq!(marker_table.len(), 2);
+        let related_markers = &marker_table[1].markers;
+        let start_tag = hashed_regions::marker_tag(&related_markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&related_markers[related_markers.len() - 1].0);
+
+        let response = format!(
+            "Updating the call site to use the new name.\n\n\
+             `````\n{start_tag}\nfn handle() {{\n    fetch_user_cached();\n}}\n{end_tag}\n`````\n"
+        );
+        let (patch, cursor) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+
+        assert!(patch.contains("--- a/src/server.rs"), "patch: {patch}");
+        assert!(patch.contains("-    fetch_user();"), "patch: {patch}");
+        assert!(
+            patch.contains("+    fetch_user_cached();"),
+            "patch: {patch}"
+        );
+        // Hunk rows are file-absolute for related files (1-based in the
+        // hunk header, excerpt starts at 0-based row 10).
+        assert!(patch.contains("@@ -11,"), "patch: {patch}");
+        assert!(cursor.is_none());
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_multiple_edits_same_file() {
+        let cursor_excerpt = "\
+            fn alpha() {\n    one();\n}\n\nfn beta() {\n    two();\n}\n\n\
+            fn gamma() {\n    three();\n}\n\nfn delta() {\n    four();\n}\n";
+        let example = make_example(cursor_excerpt, 0, &[]);
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let markers = &marker_table[0].markers;
+        assert!(
+            markers.len() >= 3,
+            "expected internal markers, got {markers:?}"
+        );
+
+        // First edit: between the first two markers; second edit: between the
+        // second and last markers.
+        let tag = |ix: usize| hashed_regions::marker_tag(&markers[ix].0);
+        let old_first_span = &cursor_excerpt[markers[0].1..markers[1].1];
+        let old_second_span = &cursor_excerpt[markers[1].1..markers[markers.len() - 1].1];
+        let new_first_span = old_first_span.replace("one()", "uno()");
+        let new_second_span = old_second_span.replace("four()", "cuatro()");
+
+        let response = format!(
+            "Renaming calls.\n\n`````\n{}\n{}{}\n`````\n\n`````\n{}\n{}{}\n`````\n",
+            tag(0),
+            new_first_span,
+            tag(1),
+            tag(1),
+            new_second_span,
+            tag(markers.len() - 1),
+        );
+        let (patch, _) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+
+        assert!(patch.contains("+    uno();"), "patch: {patch}");
+        assert!(patch.contains("+    cuatro();"), "patch: {patch}");
+        assert_eq!(patch.matches("--- a/src/main.rs").count(), 1);
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_no_edits() {
+        let example = make_example("fn main() {}\n", 0, &[]);
+        let (patch, cursor) =
+            TeacherJumpsPrompt::parse(&example, "All good.\n\n`````\nNO_EDITS\n`````\n").unwrap();
+        assert!(patch.is_empty());
+        assert!(cursor.is_none());
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_rejects_truncated_span() {
+        let cursor_excerpt = "\
+            fn alpha() {\n    one();\n}\n\nfn beta() {\n    two();\n}\n\n\
+            fn gamma() {\n    three();\n}\n\nfn delta() {\n    four();\n}\n";
+        let example = make_example(cursor_excerpt, 0, &[]);
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let markers = &marker_table[0].markers;
+        assert!(markers.len() >= 3);
+        let start_tag = hashed_regions::marker_tag(&markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&markers[markers.len() - 1].0);
+
+        // The model reproduces only the head of the span and stops before the
+        // end marker; accepting this would silently delete the rest.
+        let head = &cursor_excerpt[markers[0].1..markers[1].1];
+        let response = format!("Minor cleanup.\n\n`````\n{start_tag}\n{head}{end_tag}\n`````\n");
+        let error = TeacherJumpsPrompt::parse(&example, &response).unwrap_err();
+        assert!(
+            error.to_string().contains("looks truncated"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_rejects_tail_deletion_after_head_edit() {
+        let cursor_excerpt = "\
+            fn alpha() {\n    one();\n}\n\nfn beta() {\n    two();\n}\n\n\
+            fn gamma() {\n    three();\n}\n\nfn delta() {\n    four();\n}\n";
+        let example = make_example(cursor_excerpt, 0, &[]);
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let markers = &marker_table[0].markers;
+        assert!(markers.len() >= 3);
+        let start_tag = hashed_regions::marker_tag(&markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&markers[markers.len() - 1].0);
+
+        // The dropped tail is large enough to trip the trailing-deletion check.
+        let tail = &cursor_excerpt[markers[1].1..];
+        assert!(tail.lines().filter(|line| !line.trim().is_empty()).count() > 3);
+
+        // The model makes a real edit at the head of the span, reproduces
+        // some context, and then stops before the end marker. The replacement
+        // is not a verbatim prefix of the span, but the tail is still
+        // silently deleted.
+        let head = &cursor_excerpt[markers[0].1..markers[1].1];
+        assert!(head.contains("fn alpha()"));
+        let edited_head = head.replacen("fn alpha()", "fn alpha_renamed()", 1);
+        let response =
+            format!("Renaming alpha.\n\n`````\n{start_tag}\n{edited_head}{end_tag}\n`````\n");
+        let error = TeacherJumpsPrompt::parse(&example, &response).unwrap_err();
+        assert!(
+            error.to_string().contains("looks truncated"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_allows_mid_span_deletion() {
+        let cursor_excerpt = "\
+            fn alpha() {\n    one();\n}\n\nfn beta() {\n    two();\n}\n\n\
+            fn gamma() {\n    three();\n}\n\nfn delta() {\n    four();\n}\n";
+        let example = make_example(cursor_excerpt, 0, &[]);
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let markers = &marker_table[0].markers;
+        assert!(markers.len() >= 3);
+        let start_tag = hashed_regions::marker_tag(&markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&markers[markers.len() - 1].0);
+
+        // Deleting code in the middle while reproducing the span's tail shows
+        // the model kept writing to the end marker, so it must be accepted.
+        let head = &cursor_excerpt[markers[0].1..markers[1].1];
+        let reproduced_tail = &cursor_excerpt[markers[markers.len() - 2].1..];
+        assert!(!reproduced_tail.trim().is_empty());
+        let response = format!(
+            "Removing the middle.\n\n`````\n{start_tag}\n{head}{reproduced_tail}{end_tag}\n`````\n"
+        );
+        let (patch, _) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+        assert!(patch.contains("-fn beta() {"), "patch: {patch}");
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_allows_small_tail_deletion() {
+        let cursor_excerpt = "\
+            fn alpha() {\n    one();\n}\n\nfn beta() {\n    two();\n}\n\n\
+            fn gamma() {\n    three();\n}\n\nfn delta() {\n    four();\n}\n";
+        let example = make_example(cursor_excerpt, 0, &[]);
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let markers = &marker_table[0].markers;
+        let start_tag = hashed_regions::marker_tag(&markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&markers[markers.len() - 1].0);
+
+        // Dropping only the last line of the span may be a genuine
+        // end-of-snippet deletion, so it stays below the threshold.
+        let new_span = cursor_excerpt.strip_suffix("}\n").unwrap();
+        let response =
+            format!("Dropping the brace.\n\n`````\n{start_tag}\n{new_span}{end_tag}\n`````\n");
+        let (patch, _) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+        assert!(patch.contains("-}"), "patch: {patch}");
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_allows_empty_span_deletion() {
+        let cursor_excerpt = "\
+            fn alpha() {\n    one();\n}\n\nfn beta() {\n    two();\n}\n\n\
+            fn gamma() {\n    three();\n}\n\nfn delta() {\n    four();\n}\n";
+        let example = make_example(cursor_excerpt, 0, &[]);
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        let markers = &marker_table[0].markers;
+        assert!(markers.len() >= 3);
+        let start_tag = hashed_regions::marker_tag(&markers[0].0);
+        let end_tag = hashed_regions::marker_tag(&markers[1].0);
+
+        // Deleting an entire span by replacing it with nothing is fine.
+        let response = format!("Removing alpha.\n\n`````\n{start_tag}\n{end_tag}\n`````\n");
+        let (patch, _) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+        assert!(patch.contains("-fn alpha() {"), "patch: {patch}");
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_span_across_contiguous_excerpts() {
+        // Two excerpts of src/lib.rs with touching row ranges (5..8 and
+        // 8..11) render seamlessly in the prompt, so the model may span a
+        // single edit across the excerpt boundary.
+        let example = make_example(
+            "fn main() {}\n",
+            0,
+            &[(
+                "src/lib.rs",
+                &[
+                    ("fn a() {\n    one();\n}\n", 5),
+                    ("fn b() {\n    two();\n}\n", 8),
+                ],
+            )],
+        );
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        assert_eq!(marker_table.len(), 3);
+        let start_tag = hashed_regions::marker_tag(&marker_table[1].markers[0].0);
+        let last_markers = &marker_table[2].markers;
+        let end_tag = hashed_regions::marker_tag(&last_markers[last_markers.len() - 1].0);
+
+        let response = format!(
+            "Renaming both.\n\n`````\n{start_tag}\nfn a() {{\n    uno();\n}}\nfn b() {{\n    dos();\n}}\n{end_tag}\n`````\n"
+        );
+        let (patch, _) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+
+        assert!(patch.contains("+    uno();"), "patch: {patch}");
+        assert!(patch.contains("+    dos();"), "patch: {patch}");
+        assert_eq!(patch.matches("--- a/src/lib.rs").count(), 1);
+        // Hunk rows are file-absolute (merged region starts at 0-based row 5).
+        assert!(patch.contains("@@ -6,"), "patch: {patch}");
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_insertion_at_contiguous_excerpt_seam() {
+        // The two markers at the seam between contiguous excerpts map to the
+        // same merged offset; bracketing them expresses a pure insertion.
+        let example = make_example(
+            "fn main() {}\n",
+            0,
+            &[(
+                "src/lib.rs",
+                &[
+                    ("fn a() {\n    one();\n}\n", 5),
+                    ("fn b() {\n    two();\n}\n", 8),
+                ],
+            )],
+        );
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        assert_eq!(marker_table.len(), 3);
+        let first_markers = &marker_table[1].markers;
+        let start_tag = hashed_regions::marker_tag(&first_markers[first_markers.len() - 1].0);
+        let end_tag = hashed_regions::marker_tag(&marker_table[2].markers[0].0);
+
+        let response = format!(
+            "Adding a function between a and b.\n\n`````\n{start_tag}\nfn between() {{}}\n{end_tag}\n`````\n"
+        );
+        let (patch, _) = TeacherJumpsPrompt::parse(&example, &response).unwrap();
+
+        assert!(patch.contains("+fn between() {}"), "patch: {patch}");
+        assert!(
+            !patch.contains("-\n"),
+            "patch should be pure insertion: {patch}"
+        );
+        // Insertion lands between the excerpts (after 0-based row 7).
+        assert!(patch.contains("@@ -6,"), "patch: {patch}");
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_rejects_span_across_gapped_excerpts() {
+        // Same file, but the excerpts don't touch (5..8 and 20..23): rows in
+        // between were never shown to the model, so a span across them is
+        // invalid.
+        let example = make_example(
+            "fn main() {}\n",
+            0,
+            &[(
+                "src/lib.rs",
+                &[
+                    ("fn a() {\n    one();\n}\n", 5),
+                    ("fn b() {\n    two();\n}\n", 20),
+                ],
+            )],
+        );
+        let marker_table =
+            hashed_regions::build_marker_table(example.prompt_inputs.as_ref().unwrap());
+        assert_eq!(marker_table.len(), 3);
+        let start_tag = hashed_regions::marker_tag(&marker_table[1].markers[0].0);
+        let last_markers = &marker_table[2].markers;
+        let end_tag = hashed_regions::marker_tag(&last_markers[last_markers.len() - 1].0);
+
+        let response = format!(
+            "Renaming both.\n\n`````\n{start_tag}\nfn a() {{\n    uno();\n}}\nfn b() {{\n    dos();\n}}\n{end_tag}\n`````\n"
+        );
+        let error = TeacherJumpsPrompt::parse(&example, &response).unwrap_err();
+        assert!(
+            error.to_string().contains("different context snippets"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[test]
+    fn test_teacher_jumps_parse_rejects_unknown_marker() {
+        let example = make_example("fn main() {}\n", 0, &[]);
+        let response = "`````\n<|marker_zzzz|>\nnew\n<|marker_yyyy|>\n`````\n";
+        assert!(TeacherJumpsPrompt::parse(&example, response).is_err());
+    }
+
+    #[test]
+    fn test_extract_all_codeblocks_multiple() {
+        let text = indoc::indoc! {"
+            First edit:
+
+            `````
+            block one
+            `````
+
+            Second edit:
+
+            `````
+            block two
+            with ``` nested
+            `````
+            "};
+        let blocks = extract_all_codeblocks(text);
+        assert_eq!(
+            blocks,
+            vec![
+                "block one\n".to_string(),
+                "block two\nwith ``` nested\n".to_string()
+            ]
+        );
+    }
 
     #[test]
     fn test_extract_last_code_block() {
@@ -896,7 +1598,7 @@ mod tests {
             .map(|index| format!("line{index:02}\n"))
             .collect::();
         let cursor_offset = excerpt.find("line40").expect("cursor line exists");
-        let prompt_inputs = zeta_prompt::ZetaPromptInput {
+        let prompt_inputs = zeta_prompt::Zeta2PromptInput {
             cursor_path: std::path::Path::new("src/main.rs").into(),
             cursor_excerpt: excerpt.clone().into(),
             cursor_offset_in_excerpt: cursor_offset,
diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs
index f370f013ff061e..cdb69fe09e90d9 100644
--- a/crates/edit_prediction_cli/src/load_project.rs
+++ b/crates/edit_prediction_cli/src/load_project.rs
@@ -15,7 +15,7 @@ use gpui::{AsyncApp, Entity};
 use language::{Anchor, Buffer, LanguageNotFound, ToOffset};
 use project::{Project, ProjectPath, buffer_store::BufferStoreEvent};
 use std::{fs, path::PathBuf, sync::Arc};
-use zeta_prompt::ZetaPromptInput;
+use zeta_prompt::Zeta2PromptInput;
 
 pub async fn run_load_project(
     example: &mut Example,
@@ -96,7 +96,7 @@ pub async fn run_load_project(
         );
 
         (
-            ZetaPromptInput {
+            Zeta2PromptInput {
                 cursor_path: example.spec.cursor_path.clone(),
                 cursor_excerpt,
                 cursor_offset_in_excerpt,
diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs
index 78f8324432d6a2..a449b8d4be942d 100644
--- a/crates/edit_prediction_cli/src/main.rs
+++ b/crates/edit_prediction_cli/src/main.rs
@@ -342,6 +342,9 @@ struct ReadArgs {}
 struct FormatPromptArgs {
     #[clap(long, short('p'), default_value_t = PredictionProvider::default())]
     provider: PredictionProvider,
+    /// Token budget for related-file context in teacher-jumps prompts.
+    #[clap(long, default_value_t = format_prompt::TeacherJumpsPrompt::DEFAULT_RELATED_FILES_BUDGET)]
+    related_files_budget: usize,
 }
 
 #[derive(Debug, Args, Clone)]
@@ -396,6 +399,8 @@ pub enum TeacherBackend {
     #[default]
     Sonnet45,
     Gpt52,
+    Gpt54,
+    Gpt55,
 }
 
 impl std::fmt::Display for TeacherBackend {
@@ -404,6 +409,8 @@ impl std::fmt::Display for TeacherBackend {
             TeacherBackend::Sonnet46 => write!(f, "sonnet46"),
             TeacherBackend::Sonnet45 => write!(f, "sonnet45"),
             TeacherBackend::Gpt52 => write!(f, "gpt52"),
+            TeacherBackend::Gpt54 => write!(f, "gpt54"),
+            TeacherBackend::Gpt55 => write!(f, "gpt55"),
         }
     }
 }
@@ -415,10 +422,12 @@ impl std::str::FromStr for TeacherBackend {
         match s.to_lowercase().as_str() {
             "sonnet45" | "sonnet" | "claude" => Ok(TeacherBackend::Sonnet45),
             "sonnet46" => Ok(TeacherBackend::Sonnet46),
-            "gpt52" | "gpt" | "openai" => Ok(TeacherBackend::Gpt52),
+            "gpt52" => Ok(TeacherBackend::Gpt52),
+            "gpt54" | "gpt" | "openai" => Ok(TeacherBackend::Gpt54),
+            "gpt55" => Ok(TeacherBackend::Gpt55),
             "v0114180editableregion" => Ok(TeacherBackend::Sonnet45),
             _ => anyhow::bail!(
-                "unknown teacher backend `{s}`. Valid options: sonnet45, sonnet46, gpt52"
+                "unknown teacher backend `{s}`. Valid options: sonnet45, sonnet46, gpt52, gpt54, gpt55"
             ),
         }
     }
@@ -430,6 +439,8 @@ impl TeacherBackend {
             TeacherBackend::Sonnet45 => "claude-sonnet-4-5",
             TeacherBackend::Sonnet46 => "claude-sonnet-4-6",
             TeacherBackend::Gpt52 => "gpt-5.2",
+            TeacherBackend::Gpt54 => "gpt-5.4",
+            TeacherBackend::Gpt55 => "gpt-5.5",
         }
     }
 }
@@ -441,9 +452,9 @@ enum PredictionProvider {
     Zeta2(ZetaFormat),
     Baseten(ZetaFormat),
     Teacher(TeacherBackend, ZetaFormat),
-    TeacherMultiRegion(TeacherBackend),
+    TeacherJumps(TeacherBackend),
     TeacherNonBatching(TeacherBackend, ZetaFormat),
-    TeacherMultiRegionNonBatching(TeacherBackend),
+    TeacherJumpsNonBatching(TeacherBackend),
     Repair,
 }
 
@@ -463,14 +474,14 @@ impl std::fmt::Display for PredictionProvider {
             PredictionProvider::Teacher(backend, format) => {
                 write!(f, "teacher:{backend}:{format:?}")
             }
-            PredictionProvider::TeacherMultiRegion(backend) => {
-                write!(f, "teacher-multi-region:{backend}")
+            PredictionProvider::TeacherJumps(backend) => {
+                write!(f, "teacher-jumps:{backend}")
             }
             PredictionProvider::TeacherNonBatching(backend, format) => {
                 write!(f, "teacher-non-batching:{backend}:{format:?}")
             }
-            PredictionProvider::TeacherMultiRegionNonBatching(backend) => {
-                write!(f, "teacher-multi-region-non-batching:{backend}")
+            PredictionProvider::TeacherJumpsNonBatching(backend) => {
+                write!(f, "teacher-jumps-non-batching:{backend}")
             }
             PredictionProvider::Repair => write!(f, "repair"),
         }
@@ -499,19 +510,19 @@ impl std::str::FromStr for PredictionProvider {
                 let (backend, format) = parse_teacher_args(arg)?;
                 Ok(PredictionProvider::TeacherNonBatching(backend, format))
             }
-            "teacher-multi-region" | "teacher_multi_region" => {
+            "teacher-jumps" | "teacher_jumps" => {
                 let backend = arg
                     .map(|a| a.parse())
                     .transpose()?
                     .unwrap_or(TeacherBackend::default());
-                Ok(PredictionProvider::TeacherMultiRegion(backend))
+                Ok(PredictionProvider::TeacherJumps(backend))
             }
-            "teacher-multi-region-non-batching" | "teacher_multi_region_non_batching" => {
+            "teacher-jumps-non-batching" | "teacher_jumps_non_batching" => {
                 let backend = arg
                     .map(|a| a.parse())
                     .transpose()?
                     .unwrap_or(TeacherBackend::default());
-                Ok(PredictionProvider::TeacherMultiRegionNonBatching(backend))
+                Ok(PredictionProvider::TeacherJumpsNonBatching(backend))
             }
             "repair" => Ok(PredictionProvider::Repair),
             "baseten" => {
@@ -523,9 +534,9 @@ impl std::str::FromStr for PredictionProvider {
             }
             _ => {
                 anyhow::bail!(
-                    "unknown provider `{provider}`. Valid options: mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-multi-region, teacher-multi-region:, teacher-non-batching, teacher-multi-region-non-batching, repair\n\
+                    "unknown provider `{provider}`. Valid options: mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-jumps, teacher-jumps:, teacher-non-batching, teacher-jumps-non-batching, repair\n\
                  For zeta2, you can optionally specify a version like `zeta2:ordered` or `zeta2:V0113_Ordered`.\n\
-                 For teacher providers, you can specify a backend like `teacher:sonnet46`, `teacher-multi-region:sonnet46`, `teacher-multi-region-non-batching:sonnet46`, or `teacher:gpt52`.\n\
+                 For teacher providers, you can specify a backend like `teacher:sonnet46`, `teacher-jumps:sonnet46`, `teacher-jumps-non-batching:sonnet46`, or `teacher:gpt52`.\n\
                  Available zeta versions:\n{}",
                     ZetaFormat::options_as_string()
                 )
@@ -614,32 +625,23 @@ mod tests {
     use super::*;
 
     #[test]
-    fn prediction_provider_multi_region_non_batched_round_trips_to_primary_spelling() {
-        let provider: PredictionProvider = "teacher-multi-region-non-batching:sonnet46"
-            .parse()
-            .unwrap();
+    fn prediction_provider_jumps_non_batched_round_trips_to_primary_spelling() {
+        let provider: PredictionProvider = "teacher-jumps-non-batching:sonnet46".parse().unwrap();
         assert_eq!(
             provider,
-            PredictionProvider::TeacherMultiRegionNonBatching(TeacherBackend::Sonnet46)
-        );
-        assert_eq!(
-            provider.to_string(),
-            "teacher-multi-region-non-batching:sonnet46"
+            PredictionProvider::TeacherJumpsNonBatching(TeacherBackend::Sonnet46)
         );
+        assert_eq!(provider.to_string(), "teacher-jumps-non-batching:sonnet46");
     }
 
     #[test]
-    fn prediction_provider_multi_region_non_batched_alias_round_trips_to_primary_spelling() {
-        let provider: PredictionProvider =
-            "teacher_multi_region_non_batching:gpt52".parse().unwrap();
+    fn prediction_provider_jumps_non_batched_alias_round_trips_to_primary_spelling() {
+        let provider: PredictionProvider = "teacher_jumps_non_batching:gpt52".parse().unwrap();
         assert_eq!(
             provider,
-            PredictionProvider::TeacherMultiRegionNonBatching(TeacherBackend::Gpt52)
-        );
-        assert_eq!(
-            provider.to_string(),
-            "teacher-multi-region-non-batching:gpt52"
+            PredictionProvider::TeacherJumpsNonBatching(TeacherBackend::Gpt52)
         );
+        assert_eq!(provider.to_string(), "teacher-jumps-non-batching:gpt52");
     }
 }
 
diff --git a/crates/edit_prediction_cli/src/openai_client.rs b/crates/edit_prediction_cli/src/openai_client.rs
index 3352fcf508fca4..72b8e75e864695 100644
--- a/crates/edit_prediction_cli/src/openai_client.rs
+++ b/crates/edit_prediction_cli/src/openai_client.rs
@@ -42,6 +42,7 @@ impl PlainOpenAiClient {
             stream: false,
             stream_options: None,
             max_completion_tokens: Some(max_tokens),
+            max_tokens: None,
             stop: Vec::new(),
             temperature: None,
             tool_choice: None,
@@ -503,6 +504,7 @@ impl BatchingOpenAiClient {
                     stream: false,
                     stream_options: None,
                     max_completion_tokens: Some(serializable_request.max_tokens),
+                    max_tokens: None,
                     stop: Vec::new(),
                     temperature: None,
                     tool_choice: None,
diff --git a/crates/edit_prediction_cli/src/parse_output.rs b/crates/edit_prediction_cli/src/parse_output.rs
index c8e0fa7568cb2b..c6ce8094653dd2 100644
--- a/crates/edit_prediction_cli/src/parse_output.rs
+++ b/crates/edit_prediction_cli/src/parse_output.rs
@@ -1,11 +1,13 @@
 use crate::{
     PredictionProvider,
     example::{ActualCursor, Example},
-    format_prompt::{TeacherMultiRegionPrompt, TeacherPrompt},
+    format_prompt::{TeacherJumpsPrompt, TeacherPrompt},
     repair,
 };
 use anyhow::{Context as _, Result};
-use zeta_prompt::{ZetaFormat, parse_zeta2_model_output, parsed_output_to_patch};
+use zeta_prompt::{
+    ZetaFormat, parse_zeta2_model_output, parse_zeta2_model_output_as_patch, parsed_output_to_patch,
+};
 
 pub fn run_parse_output(example: &mut Example) -> Result<()> {
     example
@@ -40,9 +42,8 @@ pub fn parse_prediction_output(
         PredictionProvider::Teacher(_, _) | PredictionProvider::TeacherNonBatching(_, _) => {
             TeacherPrompt::parse(example, actual_output)
         }
-        PredictionProvider::TeacherMultiRegion(_)
-        | PredictionProvider::TeacherMultiRegionNonBatching(_) => {
-            TeacherMultiRegionPrompt::parse(example, actual_output)
+        PredictionProvider::TeacherJumps(_) | PredictionProvider::TeacherJumpsNonBatching(_) => {
+            TeacherJumpsPrompt::parse(example, actual_output)
         }
         PredictionProvider::Zeta2(version) => parse_zeta2_output(example, actual_output, version),
         PredictionProvider::Repair => repair::parse(example, actual_output),
@@ -63,6 +64,13 @@ fn parse_zeta2_output(
         .as_ref()
         .context("prompt_inputs required")?;
 
+    if format == ZetaFormat::V0615HashRegions {
+        return Ok((
+            parse_zeta2_model_output_as_patch(actual_output, format, prompt_inputs)?,
+            None,
+        ));
+    }
+
     let parsed = parse_zeta2_model_output(actual_output, format, prompt_inputs)?;
     let range_in_excerpt = parsed.range_in_excerpt.clone();
     let excerpt = prompt_inputs.cursor_excerpt.as_ref();
diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs
index 5ba7efb5bfa8b3..643b0bf6fd8ba0 100644
--- a/crates/edit_prediction_cli/src/predict.rs
+++ b/crates/edit_prediction_cli/src/predict.rs
@@ -2,7 +2,7 @@ use crate::{
     FormatPromptArgs, PredictArgs, PredictionProvider, TeacherBackend,
     anthropic_client::AnthropicClient,
     example::{Example, ExamplePrediction, ExamplePrompt},
-    format_prompt::{TeacherMultiRegionPrompt, TeacherPrompt, run_format_prompt},
+    format_prompt::{TeacherJumpsPrompt, TeacherPrompt, run_format_prompt},
     headless::EpAppState,
     load_project::run_load_project,
     openai_client::OpenAiClient,
@@ -57,16 +57,10 @@ pub async fn run_prediction(
         );
     };
 
-    if matches!(
-        provider,
-        PredictionProvider::TeacherMultiRegion(..)
-            | PredictionProvider::TeacherMultiRegionNonBatching(..)
-    ) {
-        anyhow::bail!("Teacher multi-region providers are not supported for prediction.");
-    }
-
     if let PredictionProvider::Teacher(backend, _)
-    | PredictionProvider::TeacherNonBatching(backend, _) = provider
+    | PredictionProvider::TeacherNonBatching(backend, _)
+    | PredictionProvider::TeacherJumps(backend)
+    | PredictionProvider::TeacherJumpsNonBatching(backend) = provider
     {
         run_context_retrieval(
             example,
@@ -79,7 +73,10 @@ pub async fn run_prediction(
         .await?;
         run_format_prompt(
             example,
-            &FormatPromptArgs { provider },
+            &FormatPromptArgs {
+                provider,
+                related_files_budget: TeacherJumpsPrompt::DEFAULT_RELATED_FILES_BUDGET,
+            },
             app_state.clone(),
             example_progress,
             cx,
@@ -89,7 +86,7 @@ pub async fn run_prediction(
         let step_progress = example_progress.start(Step::Predict);
         let batched = matches!(
             provider,
-            PredictionProvider::Teacher(..) | PredictionProvider::TeacherMultiRegion(..)
+            PredictionProvider::Teacher(..) | PredictionProvider::TeacherJumps(..)
         );
         return predict_teacher(
             example,
@@ -107,6 +104,7 @@ pub async fn run_prediction(
             example,
             &FormatPromptArgs {
                 provider: PredictionProvider::Zeta2(format),
+                related_files_budget: TeacherJumpsPrompt::DEFAULT_RELATED_FILES_BUDGET,
             },
             app_state.clone(),
             example_progress,
@@ -161,9 +159,9 @@ pub async fn run_prediction(
             PredictionProvider::Zeta2(_) => edit_prediction::EditPredictionModel::Zeta,
             PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury,
             PredictionProvider::Teacher(..)
-            | PredictionProvider::TeacherMultiRegion(..)
+            | PredictionProvider::TeacherJumps(..)
             | PredictionProvider::TeacherNonBatching(..)
-            | PredictionProvider::TeacherMultiRegionNonBatching(..)
+            | PredictionProvider::TeacherJumpsNonBatching(..)
             | PredictionProvider::Repair
             | PredictionProvider::Baseten(_) => {
                 unreachable!()
@@ -301,8 +299,8 @@ pub async fn run_prediction(
             })
             .await?;
 
-        let actual_patch = prediction.and_then(|prediction| {
-            let prediction = prediction.prediction.ok()?;
+        let actual_patch = prediction.and_then(|result| {
+            let prediction = result.prediction;
             prediction
                 .edit_preview
                 .as_unified_diff(prediction.snapshot.file(), &prediction.edits)
@@ -360,7 +358,7 @@ async fn predict_teacher(
             )
             .await
         }
-        TeacherBackend::Gpt52 => {
+        TeacherBackend::Gpt52 | TeacherBackend::Gpt54 | TeacherBackend::Gpt55 => {
             predict_openai(
                 example,
                 backend,
@@ -441,39 +439,48 @@ async fn predict_anthropic(
                 .unwrap_or(PredictionProvider::Teacher(backend, ZetaFormat::default()))
         } else {
             match example.prompt.as_ref().map(|prompt| prompt.provider) {
-                Some(PredictionProvider::TeacherMultiRegion(_))
-                | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => {
-                    PredictionProvider::TeacherMultiRegionNonBatching(backend)
+                Some(PredictionProvider::TeacherJumps(_))
+                | Some(PredictionProvider::TeacherJumpsNonBatching(_)) => {
+                    PredictionProvider::TeacherJumpsNonBatching(backend)
                 }
                 _ => PredictionProvider::TeacherNonBatching(backend, ZetaFormat::default()),
             }
         };
 
-        let (actual_patch, actual_cursor) = match parser_provider {
-            PredictionProvider::TeacherMultiRegion(_)
-            | PredictionProvider::TeacherMultiRegionNonBatching(_) => {
-                TeacherMultiRegionPrompt::parse(example, &actual_output)?
+        let parse_result = match parser_provider {
+            PredictionProvider::TeacherJumps(_)
+            | PredictionProvider::TeacherJumpsNonBatching(_) => {
+                TeacherJumpsPrompt::parse(example, &actual_output)
             }
-            _ => TeacherPrompt::parse(example, &actual_output)?,
+            _ => TeacherPrompt::parse(example, &actual_output),
+        };
+        // A teacher response can parse as text yet describe an invalid edit
+        // (e.g. an edit span crossing non-contiguous snippets, or a truncated
+        // span). Record the rejection on the prediction instead of propagating
+        // it: the raw output is preserved for `parse-output`/inspection, and a
+        // single bad example no longer aborts an entire (already paid for) batch.
+        let (actual_patch, actual_cursor, error) = match parse_result {
+            Ok((patch, cursor)) => (Some(patch), cursor, None),
+            Err(err) => (None, None, Some(format!("{err:#}"))),
         };
 
         let prediction = ExamplePrediction {
-            actual_patch: Some(actual_patch),
+            actual_patch,
             actual_output,
             actual_cursor,
-            error: None,
+            error,
             provider: if batched {
                 match example.prompt.as_ref().map(|prompt| prompt.provider) {
-                    Some(PredictionProvider::TeacherMultiRegion(_)) => {
-                        PredictionProvider::TeacherMultiRegion(backend)
+                    Some(PredictionProvider::TeacherJumps(_)) => {
+                        PredictionProvider::TeacherJumps(backend)
                     }
                     _ => PredictionProvider::Teacher(backend, ZetaFormat::default()),
                 }
             } else {
                 match example.prompt.as_ref().map(|prompt| prompt.provider) {
-                    Some(PredictionProvider::TeacherMultiRegion(_))
-                    | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => {
-                        PredictionProvider::TeacherMultiRegionNonBatching(backend)
+                    Some(PredictionProvider::TeacherJumps(_))
+                    | Some(PredictionProvider::TeacherJumpsNonBatching(_)) => {
+                        PredictionProvider::TeacherJumpsNonBatching(backend)
                     }
                     _ => PredictionProvider::TeacherNonBatching(backend, ZetaFormat::default()),
                 }
@@ -560,39 +567,45 @@ async fn predict_openai(
                 .unwrap_or(PredictionProvider::Teacher(backend, ZetaFormat::default()))
         } else {
             match example.prompt.as_ref().map(|prompt| prompt.provider) {
-                Some(PredictionProvider::TeacherMultiRegion(_))
-                | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => {
-                    PredictionProvider::TeacherMultiRegionNonBatching(backend)
+                Some(PredictionProvider::TeacherJumps(_))
+                | Some(PredictionProvider::TeacherJumpsNonBatching(_)) => {
+                    PredictionProvider::TeacherJumpsNonBatching(backend)
                 }
                 _ => PredictionProvider::TeacherNonBatching(backend, ZetaFormat::default()),
             }
         };
 
-        let (actual_patch, actual_cursor) = match parser_provider {
-            PredictionProvider::TeacherMultiRegion(_)
-            | PredictionProvider::TeacherMultiRegionNonBatching(_) => {
-                TeacherMultiRegionPrompt::parse(example, &actual_output)?
+        let parse_result = match parser_provider {
+            PredictionProvider::TeacherJumps(_)
+            | PredictionProvider::TeacherJumpsNonBatching(_) => {
+                TeacherJumpsPrompt::parse(example, &actual_output)
             }
-            _ => TeacherPrompt::parse(example, &actual_output)?,
+            _ => TeacherPrompt::parse(example, &actual_output),
+        };
+        // See `predict_anthropic`: an unparseable/invalid teacher edit is
+        // recorded as a per-prediction error rather than aborting the batch.
+        let (actual_patch, actual_cursor, error) = match parse_result {
+            Ok((patch, cursor)) => (Some(patch), cursor, None),
+            Err(err) => (None, None, Some(format!("{err:#}"))),
         };
 
         let prediction = ExamplePrediction {
-            actual_patch: Some(actual_patch),
+            actual_patch,
             actual_output,
             actual_cursor,
-            error: None,
+            error,
             provider: if batched {
                 match example.prompt.as_ref().map(|prompt| prompt.provider) {
-                    Some(PredictionProvider::TeacherMultiRegion(_)) => {
-                        PredictionProvider::TeacherMultiRegion(backend)
+                    Some(PredictionProvider::TeacherJumps(_)) => {
+                        PredictionProvider::TeacherJumps(backend)
                     }
                     _ => PredictionProvider::Teacher(backend, ZetaFormat::default()),
                 }
             } else {
                 match example.prompt.as_ref().map(|prompt| prompt.provider) {
-                    Some(PredictionProvider::TeacherMultiRegion(_))
-                    | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => {
-                        PredictionProvider::TeacherMultiRegionNonBatching(backend)
+                    Some(PredictionProvider::TeacherJumps(_))
+                    | Some(PredictionProvider::TeacherJumpsNonBatching(_)) => {
+                        PredictionProvider::TeacherJumpsNonBatching(backend)
                     }
                     _ => PredictionProvider::TeacherNonBatching(backend, ZetaFormat::default()),
                 }
@@ -694,7 +707,7 @@ pub async fn predict_baseten(
 pub async fn sync_batches(provider: Option<&PredictionProvider>) -> anyhow::Result<()> {
     match provider {
         Some(PredictionProvider::Teacher(backend, _))
-        | Some(PredictionProvider::TeacherMultiRegion(backend)) => match backend {
+        | Some(PredictionProvider::TeacherJumps(backend)) => match backend {
             TeacherBackend::Sonnet45 | TeacherBackend::Sonnet46 => {
                 let llm_client = ANTHROPIC_CLIENT.get_or_init(|| {
                     AnthropicClient::batch(&crate::paths::LLM_CACHE_DB)
@@ -705,7 +718,7 @@ pub async fn sync_batches(provider: Option<&PredictionProvider>) -> anyhow::Resu
                     .await
                     .context("Failed to sync Anthropic batches")?;
             }
-            TeacherBackend::Gpt52 => {
+            TeacherBackend::Gpt52 | TeacherBackend::Gpt54 | TeacherBackend::Gpt55 => {
                 let llm_client = OPENAI_CLIENT.get_or_init(|| {
                     OpenAiClient::batch(&crate::paths::LLM_CACHE_DB)
                         .expect("Failed to create OpenAI client")
@@ -725,7 +738,9 @@ pub async fn reprocess_after_batch_wait(
     examples: &mut [Example],
     args: &PredictArgs,
 ) -> anyhow::Result<()> {
-    let Some(PredictionProvider::Teacher(backend, _)) = args.provider else {
+    let (Some(PredictionProvider::Teacher(backend, _))
+    | Some(PredictionProvider::TeacherJumps(backend))) = args.provider
+    else {
         return Ok(());
     };
 
@@ -784,7 +799,8 @@ pub async fn wait_for_batches(provider: Option<&PredictionProvider>) -> anyhow::
 
 fn pending_batch_count(provider: Option<&PredictionProvider>) -> anyhow::Result {
     match provider {
-        Some(PredictionProvider::Teacher(backend, _)) => match backend {
+        Some(PredictionProvider::Teacher(backend, _))
+        | Some(PredictionProvider::TeacherJumps(backend)) => match backend {
             TeacherBackend::Sonnet45 | TeacherBackend::Sonnet46 => {
                 let llm_client = ANTHROPIC_CLIENT.get_or_init(|| {
                     AnthropicClient::batch(&crate::paths::LLM_CACHE_DB)
@@ -792,7 +808,7 @@ fn pending_batch_count(provider: Option<&PredictionProvider>) -> anyhow::Result<
                 });
                 llm_client.pending_batch_count()
             }
-            TeacherBackend::Gpt52 => {
+            TeacherBackend::Gpt52 | TeacherBackend::Gpt54 | TeacherBackend::Gpt55 => {
                 let llm_client = OPENAI_CLIENT.get_or_init(|| {
                     OpenAiClient::batch(&crate::paths::LLM_CACHE_DB)
                         .expect("Failed to create OpenAI client")
diff --git a/crates/edit_prediction_cli/src/prompts/teacher_multi_region.md b/crates/edit_prediction_cli/src/prompts/teacher_jumps.md
similarity index 61%
rename from crates/edit_prediction_cli/src/prompts/teacher_multi_region.md
rename to crates/edit_prediction_cli/src/prompts/teacher_jumps.md
index 61c5c8f3837a32..44543dc7f6f5f3 100644
--- a/crates/edit_prediction_cli/src/prompts/teacher_multi_region.md
+++ b/crates/edit_prediction_cli/src/prompts/teacher_jumps.md
@@ -1,14 +1,15 @@
 # Instructions
 
-You are an edit prediction assistant in a code editor. Your task is to predict the next edit to a given region of code surrounding the user's cursor.
+You are an edit prediction assistant in a code editor. Your task is to predict the next edits the user will make, based on their recent edit history and the code they can see.
 
 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 a region of code near their cursor)
+3. Predict the edits a human programmer would logically make next — including edits **far from the cursor**, in other parts of the current file, or in **other files** shown in the related excerpts
 
 ## Focus on
 
 - Completing any partially-applied changes made
+- Propagating changes to every place they affect: callers after a signature change, imports after a new reference, tests after a behavior change, and so on
 - Ensuring consistency with the programming style and patterns already established
 - Making edits that maintain or improve code quality
 
@@ -24,7 +25,7 @@ You are an edit prediction assistant in a code editor. Your task is to predict t
 - Auto-generated code can be modified: Hunks marked with `// User accepted prediction:` contain code from a previous prediction the user accepted. Unlike user-typed content, these hunks CAN be edited, corrected, or replaced if it improves the code. The "never undo/revert" rule protects the user's *current typing intent*—auto-generated code doesn't carry this protection
 - 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
+- Keep existing formatting unless it's absolutely necessary. In particular, never add or delete blank lines or make other whitespace-only changes that are separate from your actual edit — reproduce untouched lines exactly as they appear, including blank ones
 - When edit history and surrounding code suggest different edits, prioritize the most recent edits in the history as they best reflect current intent.
 - Treat partial text at or near the cursor as the beginning of something the user is actively typing. Complete the code the user appears to be creating based on context.
 - When completing partial code, prefer predictions that save meaningful keystrokes, even if this requires making educated guesses about the user's intent.
@@ -36,27 +37,32 @@ You are an edit prediction assistant in a code editor. Your task is to predict t
 You will be provided with:
 1. The user's *edit history*, in chronological order. Use this to infer the user's trajectory and predict the next most logical edit.
   - Hunks preceded by `// User accepted prediction:` indicate code that was auto-generated by a previous prediction and accepted by the user. These are treated differently than user-typed edits (see Rules).
-2. A set of *related excerpts* from the user's codebase. Some of these may be needed for correctly predicting the next edit.
-  - `…` may appear within a related file to indicate that some code has been skipped.
-3. An excerpt from the user's *current file*.
-    - The excerpt contains numbered *marker* tags (`<|marker_1|>`, `<|marker_2|>`, etc.) placed at block boundaries throughout the code. These markers divide the excerpt into spans that you can target for editing.
-    - Code that appears before the first marker or after the last marker is read-only context and cannot be edited.
+2. A set of *related excerpts* from the user's codebase. Some of these may be needed for correctly predicting the next edit, and you can also predict edits inside them.
+  - `...` may appear within a related file to indicate that some code has been skipped.
+3. The user's *current file*.
     - The `<|user_cursor|>` tag marks the user's current cursor position, as it stands after the last edit in the history.
+    - `...` may appear here too when parts of the file are skipped.
+
+All of the code you can see — the current file **and** every related excerpt — contains *marker* tags (e.g. `<|marker_b1f8|>`). Each marker has a unique four-character identifier derived from the code near it. Markers are placed at block boundaries and divide the code into spans that you can target for editing. Any span between two markers in the same excerpt can be rewritten.
 
 # Output Format
 
-- Briefly explain the user's current intent based on the edit history and their current cursor location.
-- Output a markdown codeblock containing your predicted edit as a **marker-bounded span**:
-  - The codeblock must **start** with a marker tag (e.g. `<|marker_2|>`) and **end** with a marker tag (e.g. `<|marker_4|>`).
-  - The content between these two markers is the full replacement for that span in the original file.
-  - Choose the **narrowest** pair of markers that fully contains your predicted edits, to minimize unnecessary output.
-  - Reproduce any unchanged lines within the chosen span faithfully — do not omit or alter them.
+- Briefly explain the user's current intent based on the edit history and their current cursor location, and which locations need to change next.
+- Output your predicted edits as a **sequence of markdown codeblocks** — one codeblock per edit — in the order you expect the user to make them. Often a single edit is enough; output multiple edits only when the user's trajectory clearly requires changes in several places.
+- Each codeblock contains one **marker-bounded span**:
+  - The codeblock must **start** with a marker tag (e.g. `<|marker_b1f8|>`) and **end** with another marker tag (e.g. `<|marker_Tx2v|>`).
+  - Both markers must come from the **same excerpt**, with the start marker appearing before the end marker.
+  - The content between these two markers is the full replacement for that span in the original code. **Every original line you do not reproduce is deleted.** You cannot stop early: write the span's content all the way to the end marker, even when your actual change only touches the beginning of the span.
+  - Choose the **narrowest** pair of markers that fully contains your predicted edit, to minimize unnecessary output.
+  - Reproduce any unchanged lines within the chosen span faithfully — do not omit or alter them. Before writing the end marker, verify that the lines just before it match the original lines just before that marker (unless you intend to delete them).
+  - To delete code, prefer a span whose end marker lies **beyond** the deleted code, so that your output still ends with reproduced unchanged lines. Output that reproduces the start of a span and then stops at the end marker is treated as malformed (truncated), not as a deletion.
   - Do not include any intermediate marker tags in your output — only the start and end markers.
-- If no edit is needed (the code is already complete and correct, or there is no clear next edit to make), output a codeblock containing only `NO_EDITS`:
+- All edits apply to the code **as shown above** (the original snapshot): the spans of different edits must not overlap, and a later edit must not depend on the text inserted by an earlier one.
+- If no edit is needed (the code is already complete and correct, or there is no clear next edit to make), output a single codeblock containing only `NO_EDITS`:
   `````
   NO_EDITS
   `````
-- If there is a specific place in the predicted output where the user is likely to edit next, indicate it using the `<|user_cursor|>` tag.
+- If there is a specific place in the predicted output where the user is likely to edit next, indicate it using the `<|user_cursor|>` tag (in at most one edit).
 
 ## Example 1
 
@@ -64,11 +70,13 @@ There is code missing at the cursor location. The related excerpts includes the
 
 ### Related Excerpts
 
-`````
+`````src/product.rs
+<|marker_Hw3q|>
 struct Product {
     name: String,
     price: u32,
 }
+<|marker_pY7c|>
 `````
 
 ### User Edit History
@@ -89,15 +97,17 @@ struct Product {
 ### Current File
 
 `````src/calculate.rs
+<|marker_b1f8|>
 fn calculate_total(products: &[Product]) -> u32 {
-<|marker_1|>
+<|marker_Tx2v|>
     let mut total = 0;
     for product in products {
         total += <|user_cursor|>;
     }
     total
-<|marker_2|>
+<|marker_e9Kd|>
 }
+<|marker_0aRm|>
 `````
 
 ### Output
@@ -105,13 +115,13 @@ fn calculate_total(products: &[Product]) -> u32 {
 The user is computing a sum based on a list of products. The only numeric field on `Product` is `price`, so they must intend to sum the prices.
 
 `````
-<|marker_1|>
+<|marker_Tx2v|>
     let mut total = 0;
     for product in products {
         total += product.price;
     }
     total
-<|marker_2|>
+<|marker_e9Kd|>
 `````
 
 ## Example 2
@@ -134,14 +144,14 @@ The user appears to be in the process of typing an eprintln call. Rather than fi
 ### Current File
 
 `````src/modal.rs
-<|marker_1|>
+<|marker_kQ4z|>
 // handle the close button click
 fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) {
-<|marker_2|>
+<|marker_8sWn|>
     modal_state.close();
     epr<|user_cursor|>modal_state.dismiss();
 }
-<|marker_3|>
+<|marker_vJ2p|>
 `````
 
 ### Output
@@ -149,66 +159,64 @@ fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) {
 The user is clearly starting to type `eprintln!()`, however, what they intend to print is not obvious. I should fill in the print call and string literal, with the cursor positioned inside the string literal so the user can print whatever they want.
 
 `````
-<|marker_2|>
+<|marker_8sWn|>
     modal_state.close();
     eprintln!("<|user_cursor|>");
     modal_state.dismiss();
 }
-<|marker_3|>
+<|marker_vJ2p|>
 `````
 
 ## Example 3
 
-Here, the user is adding a function. There's no way to tell for sure what the function's name will be. In this situation, you should make a reasonable guess at the function's name and signature, and place the user's cursor in the function body. This way, if you guess correctly, it will save the user a meaningful number of keystrokes, and the file will be left in a coherent state.
+Here, the user has renamed a function in the current file. The old name is still used in another file, shown in the related excerpts. You should predict the sequence of edits that completes the rename: first where they are typing, then the call site in the other file.
 
-### User Edit History
+### Related Excerpts
 
+`````src/server.rs
+<|marker_Fq6a|>
+fn handle_request(request: Request) -> Response {
+    let user = fetch_user(request.user_id);
+<|marker_mC1t|>
+    let permissions = fetch_permissions(&user);
+    Response::ok(user, permissions)
+}
+<|marker_2dXe|>
 `````
---- a/src/modal.rs
-+++ b/src/modal.rs
-@@ -100,4 +100,4 @@
- fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) {
-     modal_state.close();
-     modal_state.dismiss();
- }
-+
-+fn
 
- fn handle_keystroke(modal_state: &mut ModalState, evt: &Event) {
+### User Edit History
+
+`````
+--- a/src/db.rs
++++ b/src/db.rs
+@@ -10,7 +10,7 @@
+-fn fetch_user(user_id: UserId) -> User {
++fn fetch_user_cached(user_id: UserId) -> User {
 `````
 
 ### Current File
 
-`````src/modal.rs
-// handle the close button click
-fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) {
-    modal_state.close();
-<|marker_1|>
-    modal_state.dismiss();
+`````src/db.rs
+<|marker_pL5w|>
+fn fetch_user_cached<|user_cursor|>(user_id: UserId) -> User {
+    CACHE.get_or_insert(user_id, || load_user(user_id))
 }
-
-fn<|user_cursor|>
-
-<|marker_2|>
-fn handle_keystroke(modal_state: &mut ModalState, evt: &Event) {
-    modal_state.begin_edit();
-<|marker_3|>
+<|marker_zN8j|>
+fn fetch_permissions(user: &User) -> Permissions {
+    load_permissions(user.role)
+}
+<|marker_4tGb|>
 `````
 
 ### Output
 
-The user is adding a new function. The existing functions I see are `handle_close_button_click` and `handle_keystroke`, which have similar signatures. One possible function they might be adding is `handle_submit`.
+The user renamed `fetch_user` to `fetch_user_cached`. The related excerpt from `src/server.rs` still calls the old name `fetch_user`, so that call site must be updated to match.
 
 `````
-<|marker_1|>
-    modal_state.dismiss();
-}
-
-fn handle_submit(modal_state: &mut ModalState, evt: &Event) {
-    <|user_cursor|>
-}
-
-<|marker_2|>
+<|marker_Fq6a|>
+fn handle_request(request: Request) -> Response {
+    let user = fetch_user_cached(request.user_id);
+<|marker_mC1t|>
 `````
 
 ## Example 4
@@ -230,11 +238,11 @@ The code is already complete and there is no clear next edit to make. You should
 ### Current File
 
 `````src/utils.rs
-<|marker_1|>
+<|marker_Wd9u|>
 fn add(a: i32, b: i32) -> i32 {
     a + b<|user_cursor|>
 }
-<|marker_2|>
+<|marker_aE3h|>
 `````
 
 ### Output
@@ -264,11 +272,11 @@ The user just deleted code, leaving behind what looks incomplete. You must NOT "
 ### Current File
 
 `````config.nix
-<|marker_1|>
+<|marker_Jr7y|>
     # /etc/modular/crashdb needs to be mutable
     ln -s /tmp/cr<|user_cursor|> $out/etc/modular/crashdb
   '';
-<|marker_2|>
+<|marker_sB1v|>
 `````
 
 ### Output
@@ -281,6 +289,61 @@ NO_EDITS
 
 ## Example 6
 
+The user is using a type that is not yet imported, and is partway through using it in the function body. The next edits are: finish the line they are typing, then add the missing import at the top of the file — a sequence of two edits in different places.
+
+### User Edit History
+
+`````
+--- a/src/api.py
++++ b/src/api.py
+@@ -40,6 +40,7 @@
+ def get_report(report_id):
+     report = load_report(report_id)
++    timestamp = datetime.
+     return render(report)
+`````
+
+### Current File
+
+`````src/api.py
+<|marker_Gc2f|>
+import json
+
+from app.reports import load_report, render
+<|marker_x7Qe|>
+
+def get_report(report_id):
+    report = load_report(report_id)
+    timestamp = datetime.<|user_cursor|>
+    return render(report)
+<|marker_nV5s|>
+`````
+
+### Output
+
+The user is adding a timestamp using `datetime`, which is not imported yet. The most likely completion is `datetime.now()`. After completing that line, the user will need to import `datetime` at the top of the file.
+
+`````
+<|marker_x7Qe|>
+
+def get_report(report_id):
+    report = load_report(report_id)
+    timestamp = datetime.now(<|user_cursor|>)
+    return render(report)
+<|marker_nV5s|>
+`````
+
+`````
+<|marker_Gc2f|>
+import json
+from datetime import datetime
+
+from app.reports import load_report, render
+<|marker_x7Qe|>
+`````
+
+## Example 7
+
 The user accepted a prediction for a function, then started renaming it. The original arguments were auto-generated (marked with `// User accepted prediction:`), so they CAN be updated to match the new function name. This is NOT reverting user input—it's improving auto-generated scaffolding.
 
 ### User Edit History
@@ -319,14 +382,14 @@ The user accepted a prediction for a function, then started renaming it. The ori
 ### Current File
 
 `````math_utils.py
-<|marker_1|>
+<|marker_Ub4n|>
 def calculate_rectangle_area(width, height):
     return width * height
 
-<|marker_2|>
+<|marker_h3Zt|>
 def calculate_sq<|user_cursor|>_perimeter(width, height):
 
-<|marker_3|>
+<|marker_q8Pj|>
 `````
 
 ### Output
@@ -334,10 +397,10 @@ def calculate_sq<|user_cursor|>_perimeter(width, height):
 The user accepted a prediction for `calculate_rectangle_perimeter(width, height)`, then started renaming `rectangle` to `square`. Since squares have equal sides, the arguments should change from `(width, height)` to `(side)`. The arguments were auto-generated (from an accepted prediction), so modifying them is appropriate.
 
 `````
-<|marker_2|>
+<|marker_h3Zt|>
 def calculate_square_perimeter(side):
     <|user_cursor|>
-<|marker_3|>
+<|marker_q8Pj|>
 `````
 
 
@@ -363,4 +426,4 @@ def calculate_square_perimeter(side):
 
 -----
 
-Based on the edit history and context above, predict the user's next edit within the marker-bounded spans.
+Based on the edit history and context above, predict the user's next edits as a sequence of marker-bounded spans.
diff --git a/crates/edit_prediction_cli/src/pull_examples.rs b/crates/edit_prediction_cli/src/pull_examples.rs
index 15171c6feb92bb..2d3c0652def40d 100644
--- a/crates/edit_prediction_cli/src/pull_examples.rs
+++ b/crates/edit_prediction_cli/src/pull_examples.rs
@@ -12,7 +12,7 @@ use std::sync::Arc;
 use std::time::Duration;
 use telemetry_events::EditPredictionRating;
 
-use zeta_prompt::{ZetaFormat, ZetaPromptInput, excerpt_range_for_format};
+use zeta_prompt::{Zeta2PromptInput, ZetaFormat, excerpt_range_for_format};
 
 use crate::PredictionProvider;
 use crate::example::{Example, ExamplePrediction, ExamplePrompt};
@@ -1130,7 +1130,7 @@ fn rated_examples_from_response<'a>(
 
             let request_id = get_string("request_id");
             let inputs_json = get_json("inputs");
-            let inputs: Option = match &inputs_json {
+            let inputs: Option = match &inputs_json {
                 Some(v) => match serde_json::from_value(v.clone()) {
                     Ok(parsed) => Some(parsed),
                     Err(e) => {
@@ -1188,7 +1188,7 @@ fn build_rated_example(
     request_id: Option,
     device_id: String,
     time: String,
-    input: ZetaPromptInput,
+    input: Zeta2PromptInput,
     output: String,
     settled_editable_region: Option,
     rating: String,
@@ -1296,7 +1296,7 @@ fn requested_examples_from_response<'a>(
             let device_id = get_string("device_id");
             let time = get_string("time");
             let input_json = get_json("input");
-            let input: Option =
+            let input: Option =
                 input_json.clone().and_then(|v| serde_json::from_value(v).ok());
             let zed_version = get_string("zed_version");
 
@@ -1376,7 +1376,7 @@ fn settled_examples_from_response<'a>(
             let time = get_string("time");
             let input_raw = get_value("input");
             let input_json = parse_json_value(input_raw.as_ref());
-            let input: Option = input_json
+            let input: Option = input_json
                 .as_ref()
                 .and_then(|parsed| serde_json::from_value(parsed.clone()).ok());
             let requested_output = get_string("requested_output");
@@ -1497,7 +1497,7 @@ fn captured_examples_from_response<'a>(
             let time = get_string("time");
             let input_raw = get_value("input");
             let input_json = parse_json_value(input_raw.as_ref());
-            let input: Option = input_json
+            let input: Option = input_json
                 .as_ref()
                 .and_then(|parsed| serde_json::from_value(parsed.clone()).ok());
             let example_raw = get_value("example");
@@ -1580,7 +1580,7 @@ fn build_settled_example(
     request_id: String,
     device_id: String,
     time: String,
-    input: ZetaPromptInput,
+    input: Zeta2PromptInput,
     requested_output: String,
     settled_editable_region: String,
     requested_format: ZetaFormat,
@@ -1636,7 +1636,7 @@ fn build_captured_example(
     request_id: String,
     device_id: String,
     time: String,
-    input: ZetaPromptInput,
+    input: Zeta2PromptInput,
     mut example_spec: ExampleSpec,
     settled_editable_region: String,
     zed_version: Option,
@@ -1721,7 +1721,7 @@ fn rejected_examples_from_response<'a>(
             let device_id = get_string("device_id");
             let time = get_string("time");
             let input_json = get_json("input");
-            let input: Option =
+            let input: Option =
                 input_json.clone().and_then(|v| serde_json::from_value(v).ok());
             let prompt = get_string("prompt");
             let output = get_string("output");
@@ -1768,7 +1768,7 @@ fn build_rejected_example(
     request_id: String,
     device_id: String,
     time: String,
-    input: ZetaPromptInput,
+    input: Zeta2PromptInput,
     prompt: Option,
     output: String,
     settled_editable_region: Option,
@@ -1872,7 +1872,7 @@ fn accepted_examples_from_response<'a>(
             let device_id = get_string("device_id");
             let time = get_string("time");
             let input_json = get_json("input");
-            let input: Option =
+            let input: Option =
                 input_json.clone().and_then(|v| serde_json::from_value(v).ok());
             let prompt = get_string("prompt");
             let output = get_string("output");
@@ -1913,7 +1913,7 @@ fn build_accepted_example(
     request_id: String,
     device_id: String,
     time: String,
-    input: ZetaPromptInput,
+    input: Zeta2PromptInput,
     prompt: Option,
     output: String,
     settled_editable_region: Option,
@@ -1970,7 +1970,7 @@ fn build_example_from_snowflake(
     request_id: String,
     device_id: String,
     time: String,
-    input: ZetaPromptInput,
+    input: Zeta2PromptInput,
     tags: Vec,
     rejection: Option,
     zed_version: Option,
diff --git a/crates/edit_prediction_cli/src/qa.rs b/crates/edit_prediction_cli/src/qa.rs
index 81a2a77b27c19e..d47c8b0526f127 100644
--- a/crates/edit_prediction_cli/src/qa.rs
+++ b/crates/edit_prediction_cli/src/qa.rs
@@ -87,8 +87,7 @@ pub fn build_prompt(example: &Example) -> Result {
             path,
             old_path,
             diff,
-            predicted: _,
-            in_open_source_repo: _,
+            ..
         } = event.as_ref();
         edit_history.push_str(&format!("--- a{}\n", old_path.display()));
         edit_history.push_str(&format!("+++ b{}\n", path.display()));
diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs
index 93ce5ecb2c3eb8..802fca21d4d5c3 100644
--- a/crates/edit_prediction_cli/src/retrieve_context.rs
+++ b/crates/edit_prediction_cli/src/retrieve_context.rs
@@ -1,13 +1,15 @@
 use crate::{
     example::Example,
+    format_prompt::line_start_offset,
     headless::EpAppState,
     load_project::run_load_project,
     progress::{ExampleProgress, InfoStyle, Step, StepProgress},
 };
 use anyhow::Context as _;
 use clap::ValueEnum;
-use collections::HashSet;
+use collections::{HashMap, HashSet};
 use edit_prediction::{DebugEvent, EditPredictionStore, udiff::refresh_worktree_entries};
+use edit_prediction_context::OracleTarget;
 use futures::{FutureExt as _, StreamExt as _, channel::mpsc};
 use gpui::{AsyncApp, Entity};
 use language::Buffer;
@@ -29,6 +31,7 @@ pub enum ContextRetrievalType {
     GitLog,
     Bm25,
     OracleFile,
+    OracleSnippet,
     #[default]
     All,
     None,
@@ -45,6 +48,7 @@ impl std::fmt::Display for ContextRetrievalType {
             ContextRetrievalType::GitLog => write!(f, "git-log"),
             ContextRetrievalType::Bm25 => write!(f, "bm25"),
             ContextRetrievalType::OracleFile => write!(f, "oracle-file"),
+            ContextRetrievalType::OracleSnippet => write!(f, "oracle-snippet"),
             ContextRetrievalType::All => write!(f, "all"),
             ContextRetrievalType::None => write!(f, "none"),
         }
@@ -62,6 +66,7 @@ impl ContextRetrievalType {
             ContextRetrievalType::GitLog => vec![ContextSource::GitLog],
             ContextRetrievalType::Bm25 => vec![ContextSource::Bm25],
             ContextRetrievalType::OracleFile => vec![ContextSource::OracleFile],
+            ContextRetrievalType::OracleSnippet => vec![ContextSource::OracleSnippet],
             ContextRetrievalType::All => {
                 let mut sources = vec![ContextSource::Lsp];
                 sources.extend(editable_context_sources());
@@ -158,10 +163,16 @@ pub async fn run_context_retrieval(
         .filter(|context_source| *context_source != ContextSource::Lsp)
         .collect::>();
     if !editable_context_sources.is_empty() {
-        let oracle_paths = if editable_context_sources.contains(&ContextSource::OracleFile) {
-            let oracle_paths = oracle_paths_from_expected_patches(example);
+        let oracle_targets = if editable_context_sources.contains(&ContextSource::OracleFile)
+            || editable_context_sources.contains(&ContextSource::OracleSnippet)
+        {
+            let oracle_targets = oracle_targets_from_expected_patches(example);
+            let oracle_paths = oracle_targets
+                .iter()
+                .map(|target| target.path.clone())
+                .collect::>();
             refresh_paths(&project, &oracle_paths, &mut cx).await?;
-            oracle_paths
+            oracle_targets
         } else {
             Vec::new()
         };
@@ -172,7 +183,7 @@ pub async fn run_context_retrieval(
                     project.clone(),
                     state.buffer.clone(),
                     state.cursor_position,
-                    oracle_paths,
+                    oracle_targets,
                     editable_context_sources,
                     cx,
                 )
@@ -201,41 +212,121 @@ fn merge_context_files(
         {
             existing_file.max_row = existing_file.max_row.max(new_file.max_row);
             existing_file.excerpts.append(&mut new_file.excerpts);
-            existing_file
-                .excerpts
-                .sort_by_key(|excerpt| (excerpt.order, excerpt.row_range.start));
             existing_file.in_open_source_repo =
                 existing_file.in_open_source_repo && new_file.in_open_source_repo;
         } else {
             context_files.push(new_file);
         }
     }
+    for file in context_files.iter_mut() {
+        coalesce_touching_excerpts(&mut file.excerpts);
+    }
 }
 
-fn oracle_paths_from_expected_patches(example: &Example) -> Vec> {
-    let mut seen_paths = HashSet::default();
-    let mut paths = Vec::new();
-
-    for patch in &example.spec.expected_patches {
-        for path in paths_from_diff(patch) {
-            if seen_paths.insert(path.clone()) {
-                paths.push(path.into());
+/// Sort a file's excerpts by position and merge those whose row ranges touch
+/// or overlap. Touching excerpts render seamlessly in prompts (no `...`
+/// separator), so keeping them separate splits edit addressing across a
+/// boundary the model can't see and wastes adjacent marker tags (see
+/// `TeacherJumpsPrompt::parse`).
+///
+/// A merged excerpt keeps the minimum `order` (and that excerpt's context
+/// source), so the highest-priority content still survives budget-based
+/// selection downstream; the tradeoff is that lower-priority touching
+/// content is now selected together with it.
+fn coalesce_touching_excerpts(excerpts: &mut Vec) {
+    excerpts.sort_by_key(|excerpt| (excerpt.row_range.start, excerpt.row_range.end));
+    let mut coalesced: Vec = Vec::with_capacity(excerpts.len());
+    for excerpt in excerpts.drain(..) {
+        let Some(last) = coalesced.last_mut() else {
+            coalesced.push(excerpt);
+            continue;
+        };
+        if excerpt.row_range.start > last.row_range.end {
+            coalesced.push(excerpt);
+            continue;
+        }
+        if excerpt.row_range.end > last.row_range.end {
+            // Touching or overlapping. Shared rows come from the same buffer
+            // snapshot, so drop the duplicated prefix of the new excerpt and
+            // append the rest.
+            let mut overlap_rows = (last.row_range.end - excerpt.row_range.start) as usize;
+            if !last.text.ends_with('\n') && !last.text.is_empty() {
+                // An unterminated final line means `last` includes the
+                // content of its `row_range.end` row itself.
+                overlap_rows += 1;
+            }
+            let Some(tail_start) = line_start_offset(&excerpt.text, overlap_rows) else {
+                // The excerpt has fewer lines than its row range claims;
+                // leave it unmerged rather than corrupt the text.
+                coalesced.push(excerpt);
+                continue;
+            };
+            let mut text =
+                String::with_capacity(last.text.len() + excerpt.text.len() - tail_start + 1);
+            text.push_str(&last.text);
+            if !text.is_empty() && !text.ends_with('\n') {
+                text.push('\n');
             }
+            text.push_str(&excerpt.text[tail_start..]);
+            last.text = text.into();
+            last.row_range.end = excerpt.row_range.end;
+        }
+        if excerpt.order < last.order {
+            last.order = excerpt.order;
+            last.context_source = excerpt.context_source;
         }
     }
-
-    paths
+    *excerpts = coalesced;
 }
 
-fn paths_from_diff(diff: &str) -> Vec {
-    diff.lines()
-        .filter_map(|line| match DiffLine::parse(line) {
-            DiffLine::OldPath { path } | DiffLine::NewPath { path }
-                if path.as_ref() != "/dev/null" =>
-            {
-                Some(Path::new(path.as_ref()).to_path_buf())
+fn oracle_targets_from_expected_patches(example: &Example) -> Vec {
+    let mut target_indices: HashMap = HashMap::default();
+    let mut targets: Vec<(PathBuf, Vec>)> = Vec::new();
+
+    let mut target_index = |path: &str, targets: &mut Vec<(PathBuf, Vec>)>| {
+        let path = Path::new(path).to_path_buf();
+        *target_indices.entry(path.clone()).or_insert_with(|| {
+            targets.push((path, Vec::new()));
+            targets.len() - 1
+        })
+    };
+
+    for patch in &example.spec.expected_patches {
+        // Index of the target whose old-side rows the current hunk headers
+        // refer to. Hunk old rows refer to the file's current state only for
+        // the first expected patch; later patches drift, but the snippet
+        // padding absorbs small offsets.
+        let mut current_target: Option = None;
+        for line in patch.lines() {
+            match DiffLine::parse(line) {
+                DiffLine::OldPath { path } => {
+                    current_target = (path.as_ref() != "/dev/null")
+                        .then(|| target_index(path.as_ref(), &mut targets));
+                }
+                DiffLine::NewPath { path } => {
+                    if path.as_ref() != "/dev/null" {
+                        let index = target_index(path.as_ref(), &mut targets);
+                        if current_target.is_none() {
+                            current_target = Some(index);
+                        }
+                    }
+                }
+                DiffLine::HunkHeader(Some(location)) => {
+                    if let Some(index) = current_target {
+                        let start = location.start_line_old;
+                        targets[index].1.push(start..start + location.count_old);
+                    }
+                }
+                _ => {}
             }
-            _ => None,
+        }
+    }
+
+    targets
+        .into_iter()
+        .map(|(path, row_ranges)| OracleTarget {
+            path: path.into(),
+            row_ranges,
         })
         .collect()
 }
@@ -369,3 +460,132 @@ async fn wait_for_language_servers_to_start(
     step_progress.clear_substatus();
     Ok(())
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use zeta_prompt::{ContextSource, RelatedExcerpt, RelatedFile};
+
+    fn excerpt(
+        row_range: std::ops::Range,
+        text: &str,
+        order: usize,
+        context_source: ContextSource,
+    ) -> RelatedExcerpt {
+        RelatedExcerpt {
+            row_range,
+            text: Arc::from(text),
+            order,
+            context_source,
+        }
+    }
+
+    #[test]
+    fn test_coalesce_touching_excerpts() {
+        let mut excerpts = vec![
+            excerpt(2..4, "line2\nline3\n", 1, ContextSource::EditHistory),
+            excerpt(0..2, "line0\nline1\n", 3, ContextSource::Bm25),
+            excerpt(4..6, "line4\nline5\n", 2, ContextSource::OracleSnippet),
+            excerpt(10..12, "line10\nline11\n", 0, ContextSource::Bm25),
+        ];
+        coalesce_touching_excerpts(&mut excerpts);
+
+        assert_eq!(excerpts.len(), 2);
+        assert_eq!(excerpts[0].row_range, 0..6);
+        assert_eq!(
+            excerpts[0].text.as_ref(),
+            "line0\nline1\nline2\nline3\nline4\nline5\n"
+        );
+        // The merged excerpt keeps the highest priority (minimum order) and
+        // its context source.
+        assert_eq!(excerpts[0].order, 1);
+        assert_eq!(excerpts[0].context_source, ContextSource::EditHistory);
+        // The gapped excerpt stays separate.
+        assert_eq!(excerpts[1].row_range, 10..12);
+    }
+
+    #[test]
+    fn test_coalesce_overlapping_excerpts_drops_duplicated_rows() {
+        let mut excerpts = vec![
+            excerpt(0..3, "line0\nline1\nline2\n", 0, ContextSource::Bm25),
+            excerpt(
+                2..5,
+                "line2\nline3\nline4\n",
+                1,
+                ContextSource::OracleSnippet,
+            ),
+        ];
+        coalesce_touching_excerpts(&mut excerpts);
+
+        assert_eq!(excerpts.len(), 1);
+        assert_eq!(excerpts[0].row_range, 0..5);
+        assert_eq!(
+            excerpts[0].text.as_ref(),
+            "line0\nline1\nline2\nline3\nline4\n"
+        );
+        assert_eq!(excerpts[0].order, 0);
+        assert_eq!(excerpts[0].context_source, ContextSource::Bm25);
+    }
+
+    #[test]
+    fn test_coalesce_contained_excerpt_upgrades_order() {
+        let mut excerpts = vec![
+            excerpt(0..4, "line0\nline1\nline2\nline3\n", 5, ContextSource::Bm25),
+            excerpt(1..3, "line1\nline2\n", 2, ContextSource::OracleSnippet),
+        ];
+        coalesce_touching_excerpts(&mut excerpts);
+
+        assert_eq!(excerpts.len(), 1);
+        assert_eq!(excerpts[0].row_range, 0..4);
+        assert_eq!(excerpts[0].text.as_ref(), "line0\nline1\nline2\nline3\n");
+        assert_eq!(excerpts[0].order, 2);
+        assert_eq!(excerpts[0].context_source, ContextSource::OracleSnippet);
+    }
+
+    #[test]
+    fn test_coalesce_handles_unterminated_final_line() {
+        // An excerpt ending without a newline includes the content of its
+        // `row_range.end` row (e.g. git-log excerpts ending at EOF), so a
+        // touching excerpt's first row duplicates it.
+        let mut excerpts = vec![
+            excerpt(0..2, "line0\nline1\nline2", 0, ContextSource::GitLog),
+            excerpt(2..4, "line2\nline3\n", 1, ContextSource::Bm25),
+        ];
+        coalesce_touching_excerpts(&mut excerpts);
+
+        assert_eq!(excerpts.len(), 1);
+        assert_eq!(excerpts[0].row_range, 0..4);
+        assert_eq!(excerpts[0].text.as_ref(), "line0\nline1\nline2\nline3\n");
+    }
+
+    #[test]
+    fn test_merge_context_files_coalesces_across_sources() {
+        let path: Arc = Path::new("root/src/lib.rs").into();
+        let mut context_files = vec![RelatedFile {
+            path: path.clone(),
+            max_row: 100,
+            excerpts: vec![excerpt(
+                0..2,
+                "line0\nline1\n",
+                0,
+                ContextSource::CurrentFile,
+            )],
+            in_open_source_repo: false,
+        }];
+        let new_files = vec![RelatedFile {
+            path,
+            max_row: 100,
+            excerpts: vec![excerpt(2..4, "line2\nline3\n", 1, ContextSource::Bm25)],
+            in_open_source_repo: false,
+        }];
+        merge_context_files(&mut context_files, new_files);
+
+        assert_eq!(context_files.len(), 1);
+        assert_eq!(context_files[0].excerpts.len(), 1);
+        assert_eq!(context_files[0].excerpts[0].row_range, 0..4);
+        assert_eq!(
+            context_files[0].excerpts[0].text.as_ref(),
+            "line0\nline1\nline2\nline3\n"
+        );
+    }
+}
diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs
index 841d15541e4044..e911f0302730b1 100644
--- a/crates/edit_prediction_cli/src/score.rs
+++ b/crates/edit_prediction_cli/src/score.rs
@@ -66,18 +66,6 @@ pub async fn run_scoring(
                 None
             };
 
-            let prepared_expected_patches = edit_prediction_metrics::prepare_expected_patches(
-                &expected_patches_with_cursors,
-                original_text,
-                old_editable_region.as_deref(),
-            )
-            .with_context(|| {
-                format!(
-                    "Expected patch did not apply for {}",
-                    example_for_scoring.spec.name
-                )
-            })?;
-
             let cursor_path = example_for_scoring.spec.cursor_path.as_ref();
             let context = context_excerpts(
                 &example_for_scoring,
@@ -86,6 +74,30 @@ pub async fn run_scoring(
                 context_source_filter.as_deref(),
             );
 
+            let prepared_expected_patches = match edit_prediction_metrics::prepare_expected_patches(
+                &expected_patches_with_cursors,
+                original_text,
+                old_editable_region.as_deref(),
+            ) {
+                Ok(prepared_expected_patches) => prepared_expected_patches,
+                Err(_) if !context.is_empty() => expected_patches_with_cursors
+                    .iter()
+                    .map(|(patch, cursor_offset)| edit_prediction_metrics::PreparedExpectedPatch {
+                        patch: patch.clone(),
+                        text: original_text.to_string(),
+                        cursor_editable_region_offset: *cursor_offset,
+                    })
+                    .collect(),
+                Err(error) => {
+                    return Err(error).with_context(|| {
+                        format!(
+                            "Expected patch did not apply for {}",
+                            example_for_scoring.spec.name
+                        )
+                    });
+                }
+            };
+
             let mut scores = vec![];
             if allow_missing_predictions && example_for_scoring.predictions.is_empty() {
                 scores.push(edit_prediction_metrics::score_prediction(
@@ -190,7 +202,7 @@ pub fn run_context_coverage_scoring(
 
 fn context_excerpts(
     _example: &Example,
-    prompt_inputs: &zeta_prompt::ZetaPromptInput,
+    prompt_inputs: &zeta_prompt::Zeta2PromptInput,
     retrieved_context_byte_limit: Option,
     context_source_filter: Option<&[ContextSource]>,
 ) -> Vec {
@@ -295,8 +307,6 @@ pub fn print_report(
     context_source_filter: Option<&[ContextSource]>,
 ) {
     const MAX_EXAMPLES_DEFAULT: usize = 20;
-    use crate::metrics::ClassificationMetrics;
-
     const LINE_WIDTH: usize = 101;
 
     if context_only {
@@ -318,59 +328,14 @@ pub fn print_report(
     );
     println!("{}", separator);
 
-    let mut all_delta_chr_f_scores = Vec::new();
-    let mut all_reversal_ratios = Vec::new();
-    let mut braces_disbalance_sum: usize = 0;
-    let mut total_delta_chr_f = ClassificationMetrics::default();
-    let mut total_delta_chr_f_precision = 0.0;
-    let mut total_delta_chr_f_recall = 0.0;
-    let mut delta_chr_f_beta = 0.0;
-    let mut total_exact_lines = ClassificationMetrics::default();
-    let mut total_scores: usize = 0;
-    let mut qa_reverts_count: usize = 0;
-    let mut qa_reverts_total: usize = 0;
-    let mut qa_confidence_sum: u64 = 0;
-    let mut qa_confidence_count: usize = 0;
-    let mut cursor_exact_matches: usize = 0;
-    let mut cursor_total: usize = 0;
-    let mut cursor_distance_sum: usize = 0;
-    let mut cursor_distance_count: usize = 0;
-    let mut wrong_editable_region_count: usize = 0;
-    let mut wrong_editable_region_total: usize = 0;
-    let mut isolated_whitespace_count: usize = 0;
-    let mut kept_rate_sum: f64 = 0.0;
-    let mut kept_rate_count: usize = 0;
-    let mut kept_chars_total: usize = 0;
-    let mut correctly_deleted_chars_total: usize = 0;
-    let mut discarded_chars_total: usize = 0;
-    let mut recall_rate_sum: f64 = 0.0;
-    let mut recall_rate_count: usize = 0;
-    let mut editable_context_coverage_count: usize = 0;
-    let mut editable_context_lines_precision_sum = 0.0;
-    let mut editable_context_lines_recall_sum = 0.0;
-    let mut editable_context_lines_f1_sum = 0.0;
-    let mut editable_context_files_precision_sum = 0.0;
-    let mut editable_context_files_recall_sum = 0.0;
-    let mut editable_context_files_f1_sum = 0.0;
-    let mut total_editable_context_lines = ClassificationMetrics::default();
-    let mut total_editable_context_files = ClassificationMetrics::default();
     let mut patch_inserted_tokens: Vec = Vec::new();
     let mut patch_deleted_tokens: Vec = Vec::new();
     let mut predictions_with_patch: usize = 0;
-    let mut retrieved_context_bytes_sum = 0.0;
-    let mut retrieved_context_bytes_count = 0;
 
     let mut printed_lines: usize = 0;
     let mut skipped_lines: usize = 0;
 
     for example in examples {
-        if let Some(bytes) =
-            retrieved_context_bytes(example, retrieved_context_byte_limit, context_source_filter)
-        {
-            retrieved_context_bytes_sum += bytes as f64;
-            retrieved_context_bytes_count += 1;
-        }
-
         for (score_idx, score) in example.score.iter().enumerate() {
             let exact_lines = score.exact_lines_counts();
 
@@ -418,82 +383,8 @@ pub fn print_report(
                 skipped_lines += 1;
             }
 
-            all_delta_chr_f_scores.push(score.delta_chr_f);
-            all_reversal_ratios.push(score.reversal_ratio);
-            total_scores += 1;
-            braces_disbalance_sum += score.braces_disbalance;
-            total_delta_chr_f.accumulate(&score.delta_chr_f_counts());
-            total_delta_chr_f_precision += score.delta_chr_f_precision;
-            total_delta_chr_f_recall += score.delta_chr_f_recall;
-            delta_chr_f_beta = score.delta_chr_f_beta;
-            total_exact_lines.accumulate(&score.exact_lines_counts());
-
-            // Accumulate QA metrics
-            if let Some(qa) = qa_result {
-                if let Some(reverts) = qa.reverts_edits {
-                    qa_reverts_total += 1;
-                    if reverts {
-                        qa_reverts_count += 1;
-                    }
-                }
-                if let Some(conf) = qa.confidence {
-                    qa_confidence_sum += conf as u64;
-                    qa_confidence_count += 1;
-                }
-            }
-
-            // Accumulate wrong editable region metrics
-            if let Some(wrong) = score.wrong_editable_region {
-                wrong_editable_region_total += 1;
-                if wrong {
-                    wrong_editable_region_count += 1;
-                }
-            }
-
-            // Accumulate isolated whitespace metrics
-            if score.has_isolated_whitespace_changes {
-                isolated_whitespace_count += 1;
-            }
-
-            // Accumulate kept and recall rate metrics
-            if let Some(kr) = score.kept_rate {
-                kept_rate_sum += kr;
-                kept_rate_count += 1;
-            }
-            if let Some(kept_chars) = score.kept_chars {
-                kept_chars_total += kept_chars;
-            }
-            if let Some(correctly_deleted_chars) = score.correctly_deleted_chars {
-                correctly_deleted_chars_total += correctly_deleted_chars;
-            }
-            if let Some(discarded_chars) = score.discarded_chars {
-                discarded_chars_total += discarded_chars;
-            }
-            if let Some(rr) = score.recall_rate {
-                recall_rate_sum += rr;
-                recall_rate_count += 1;
-            }
-            if let Some(coverage) = &score.editable_context_coverage {
-                editable_context_coverage_count += 1;
-                editable_context_lines_precision_sum += coverage.lines_precision;
-                editable_context_lines_recall_sum += coverage.lines_recall;
-                editable_context_lines_f1_sum += coverage.lines_f1;
-                editable_context_files_precision_sum += coverage.files_precision;
-                editable_context_files_recall_sum += coverage.files_recall;
-                editable_context_files_f1_sum += coverage.files_f1;
-                total_editable_context_lines.accumulate(&ClassificationMetrics {
-                    true_positives: coverage.lines_tp,
-                    false_positives: coverage.lines_fp,
-                    false_negatives: coverage.lines_fn,
-                });
-                total_editable_context_files.accumulate(&ClassificationMetrics {
-                    true_positives: coverage.files_tp,
-                    false_positives: coverage.files_fp,
-                    false_negatives: coverage.files_fn,
-                });
-            }
-
-            // Accumulate token change metrics (only for predictions that produced a patch)
+            // Token change percentiles need the raw per-prediction values, so
+            // they are collected here rather than in `compute_summary`.
             let has_patch = example
                 .predictions
                 .get(score_idx)
@@ -504,18 +395,6 @@ pub fn print_report(
                 patch_inserted_tokens.push(score.inserted_tokens);
                 patch_deleted_tokens.push(score.deleted_tokens);
             }
-
-            // Accumulate cursor metrics
-            if let Some(exact_match) = score.cursor_exact_match {
-                cursor_total += 1;
-                if exact_match {
-                    cursor_exact_matches += 1;
-                }
-            }
-            if let Some(dist) = score.cursor_distance {
-                cursor_distance_sum += dist;
-                cursor_distance_count += 1;
-            }
         }
     }
 
@@ -528,68 +407,33 @@ pub fn print_report(
     }
     println!("{}", separator);
 
-    if !all_delta_chr_f_scores.is_empty() {
-        let avg_delta_chr_f: f32 =
-            all_delta_chr_f_scores.iter().sum::() / all_delta_chr_f_scores.len() as f32;
-        let avg_reversal_ratio: f32 =
-            all_reversal_ratios.iter().sum::() / all_reversal_ratios.len() as f32;
-        let braces_disbalance_avg: f32 = braces_disbalance_sum as f32 / total_scores as f32;
-
-        let qa_reverts_str = if qa_reverts_total > 0 {
-            format!(
-                "{:.1}%",
-                qa_reverts_count as f32 / qa_reverts_total as f32 * 100.0
-            )
-        } else {
-            "-".to_string()
-        };
-        let qa_conf_str = if qa_confidence_count > 0 {
-            format!(
-                "{:.1}",
-                qa_confidence_sum as f32 / qa_confidence_count as f32
-            )
-        } else {
-            "-".to_string()
-        };
-        let cursor_str = if cursor_total > 0 {
-            format!(
-                "{:.0}%",
-                cursor_exact_matches as f32 / cursor_total as f32 * 100.0
-            )
-        } else {
-            "-".to_string()
-        };
-        let wrong_er_str = if wrong_editable_region_total > 0 {
-            format!(
-                "{:.2}%",
-                wrong_editable_region_count as f32 / wrong_editable_region_total as f32 * 100.0
-            )
-        } else {
-            "-".to_string()
-        };
-        let isolated_ws_str = if total_scores > 0 {
-            format!(
-                "{}/{} ({:.1}%)",
-                isolated_whitespace_count,
-                total_scores,
-                isolated_whitespace_count as f32 / total_scores as f32 * 100.0
-            )
-        } else {
-            "-".to_string()
-        };
-        let avg_cursor_distance = if cursor_distance_count > 0 {
-            Some(cursor_distance_sum as f32 / cursor_distance_count as f32)
-        } else {
-            None
+    let summary = compute_summary(
+        examples,
+        retrieved_context_byte_limit,
+        context_source_filter,
+    );
+
+    if summary.total_examples > 0 {
+        let total_scores = summary.total_examples;
+        let format_rate = |rate: Option, precision: usize| {
+            rate.map(|rate| format!("{:.*}%", precision, rate * 100.0))
+                .unwrap_or_else(|| "-".to_string())
         };
+        let qa_reverts_str = format_rate(summary.qa_avg_reverts_edits, 1);
+        let qa_conf_str = summary
+            .qa_avg_confidence
+            .map(|confidence| format!("{:.1}", confidence))
+            .unwrap_or_else(|| "-".to_string());
+        let cursor_str = format_rate(summary.cursor_exact_match_rate, 0);
+        let wrong_er_str = format_rate(summary.wrong_editable_region_rate, 2);
 
         println!(
             "{:<40} {:>8.2} {:>5.1} {:>6.1}% {:>6.1}% {:>7} {:>7} {:>6} {:>5}",
             "TOTAL / AVERAGE",
-            avg_delta_chr_f,
-            braces_disbalance_avg,
-            total_exact_lines.f1() * 100.0,
-            avg_reversal_ratio * 100.0,
+            summary.avg_delta_chr_f,
+            summary.avg_braces_disbalance,
+            summary.exact_lines_f1 * 100.0,
+            summary.avg_reversal_ratio * 100.0,
             qa_reverts_str,
             qa_conf_str,
             cursor_str,
@@ -598,81 +442,108 @@ pub fn print_report(
         println!("{}", separator);
         println!(
             "Delta chrF (β={:.1}): TP={}, FP={}, FN={}, P={:.1}%, R={:.1}%",
-            delta_chr_f_beta,
-            total_delta_chr_f.true_positives,
-            total_delta_chr_f.false_positives,
-            total_delta_chr_f.false_negatives,
-            total_delta_chr_f_precision / total_scores as f64 * 100.0,
-            total_delta_chr_f_recall / total_scores as f64 * 100.0
+            summary.delta_chr_f_beta,
+            summary.delta_chr_f_true_positives,
+            summary.delta_chr_f_false_positives,
+            summary.delta_chr_f_false_negatives,
+            summary.delta_chr_f_precision * 100.0,
+            summary.delta_chr_f_recall * 100.0
         );
 
-        // Print additional cursor metrics if available
-        if let Some(avg_dist) = avg_cursor_distance {
+        if let Some(avg_distance) = summary.cursor_avg_distance {
             println!(
                 "Cursor: {}/{} exact matches ({:.0}%), avg distance: {:.1} bytes",
-                cursor_exact_matches,
-                cursor_total,
-                cursor_exact_matches as f32 / cursor_total as f32 * 100.0,
-                avg_dist
+                summary.cursor_exact_matches.unwrap_or(0),
+                summary.cursor_total_evaluated.unwrap_or(0),
+                summary.cursor_exact_match_rate.unwrap_or(0.0) * 100.0,
+                avg_distance
             );
         }
 
-        // Print isolated whitespace metrics
-        if total_scores > 0 {
-            println!("Isolated whitespace changes: {}", isolated_ws_str);
+        if let (Some(count), Some(rate)) = (
+            summary.isolated_whitespace_count,
+            summary.isolated_whitespace_rate,
+        ) {
+            println!(
+                "Isolated whitespace changes: {}/{} ({:.1}%)",
+                count,
+                total_scores,
+                rate * 100.0
+            );
         }
 
-        // Print kept and recall rate metrics
-        if kept_rate_count > 0 {
-            let avg_kept_rate = kept_rate_sum / kept_rate_count as f64;
+        if let (Some(avg_kept_rate), Some(evaluated)) =
+            (summary.avg_kept_rate, summary.kept_rate_examples)
+        {
             println!(
                 "Kept rate: {:.1}% avg ({} evaluated, kept chars: {}, correctly deleted chars: {}, discarded chars: {})",
                 avg_kept_rate * 100.0,
-                kept_rate_count,
-                kept_chars_total,
-                correctly_deleted_chars_total,
-                discarded_chars_total
+                evaluated,
+                summary.total_kept_chars.unwrap_or(0),
+                summary.total_correctly_deleted_chars.unwrap_or(0),
+                summary.total_discarded_chars.unwrap_or(0)
             );
         }
-        if recall_rate_count > 0 {
-            let avg_recall_rate = recall_rate_sum / recall_rate_count as f64;
+        if let (Some(avg_recall_rate), Some(evaluated)) =
+            (summary.avg_recall_rate, summary.recall_rate_examples)
+        {
             println!(
                 "Recall rate: {:.1}% avg ({} evaluated)",
                 avg_recall_rate * 100.0,
-                recall_rate_count
+                evaluated
             );
         }
-        if retrieved_context_bytes_count > 0 {
+        if let (Some(avg_bytes), Some(example_count)) = (
+            summary.avg_retrieved_context_bytes,
+            summary.retrieved_context_examples,
+        ) {
             println!(
                 "Retrieved context size: {:.0} bytes avg ({} examples)",
-                retrieved_context_bytes_sum / retrieved_context_bytes_count as f64,
-                retrieved_context_bytes_count
-            );
-        }
-        if editable_context_coverage_count > 0 {
-            let count = editable_context_coverage_count as f64;
-            println!(
-                "Editable context lines: P={:.1}%, R={:.1}%, F1={:.1}% avg ({} evaluated, TP={}, FP={}, FN={})",
-                editable_context_lines_precision_sum / count * 100.0,
-                editable_context_lines_recall_sum / count * 100.0,
-                editable_context_lines_f1_sum / count * 100.0,
-                editable_context_coverage_count,
-                total_editable_context_lines.true_positives,
-                total_editable_context_lines.false_positives,
-                total_editable_context_lines.false_negatives
-            );
-            println!(
-                "Editable context files: P={:.1}%, R={:.1}%, F1={:.1}% avg ({} evaluated, TP={}, FP={}, FN={})",
-                editable_context_files_precision_sum / count * 100.0,
-                editable_context_files_recall_sum / count * 100.0,
-                editable_context_files_f1_sum / count * 100.0,
-                editable_context_coverage_count,
-                total_editable_context_files.true_positives,
-                total_editable_context_files.false_positives,
-                total_editable_context_files.false_negatives
+                avg_bytes, example_count
             );
         }
 
+        print_prf_line(
+            "Editable context lines",
+            summary.editable_context_examples,
+            summary.avg_editable_context_lines_precision,
+            summary.avg_editable_context_lines_recall,
+            summary.avg_editable_context_lines_f1,
+            summary.editable_context_lines_tp,
+            summary.editable_context_lines_fp,
+            summary.editable_context_lines_fn,
+        );
+        print_prf_line(
+            "Editable context files",
+            summary.editable_context_examples,
+            summary.avg_editable_context_files_precision,
+            summary.avg_editable_context_files_recall,
+            summary.avg_editable_context_files_f1,
+            summary.editable_context_files_tp,
+            summary.editable_context_files_fp,
+            summary.editable_context_files_fn,
+        );
+        print_prf_line(
+            "Jump location lines",
+            summary.jump_location_examples,
+            summary.avg_jump_location_lines_precision,
+            summary.avg_jump_location_lines_recall,
+            summary.avg_jump_location_lines_f1,
+            summary.jump_location_lines_tp,
+            summary.jump_location_lines_fp,
+            summary.jump_location_lines_fn,
+        );
+        print_prf_line(
+            "Jump location files",
+            summary.jump_location_examples,
+            summary.avg_jump_location_files_precision,
+            summary.avg_jump_location_files_recall,
+            summary.avg_jump_location_files_f1,
+            summary.jump_location_files_tp,
+            summary.jump_location_files_fp,
+            summary.jump_location_files_fn,
+        );
+
         // Print token change percentile summary (only for predictions with a patch)
         if !patch_inserted_tokens.is_empty() {
             patch_inserted_tokens.sort_unstable();
@@ -737,8 +608,6 @@ fn print_context_coverage_report(
     const MAX_EXAMPLES_DEFAULT: usize = 20;
     const LINE_WIDTH: usize = 120;
 
-    use crate::metrics::ClassificationMetrics;
-
     let separator = "─".repeat(LINE_WIDTH);
     println!("{}", separator);
     println!(
@@ -759,28 +628,10 @@ fn print_context_coverage_report(
     );
     println!("{}", separator);
 
-    let mut total_lines = ClassificationMetrics::default();
-    let mut total_files = ClassificationMetrics::default();
-    let mut line_precision_sum = 0.0;
-    let mut line_recall_sum = 0.0;
-    let mut line_f1_sum = 0.0;
-    let mut file_precision_sum = 0.0;
-    let mut file_recall_sum = 0.0;
-    let mut file_f1_sum = 0.0;
-    let mut total_scores = 0;
-    let mut retrieved_context_bytes_sum = 0.0;
-    let mut retrieved_context_bytes_count = 0;
     let mut printed_lines = 0;
     let mut skipped_lines = 0;
 
     for example in examples {
-        if let Some(bytes) =
-            retrieved_context_bytes(example, retrieved_context_byte_limit, context_source_filter)
-        {
-            retrieved_context_bytes_sum += bytes as f64;
-            retrieved_context_bytes_count += 1;
-        }
-
         for score in &example.score {
             let Some(coverage) = &score.editable_context_coverage else {
                 continue;
@@ -807,24 +658,6 @@ fn print_context_coverage_report(
             } else {
                 skipped_lines += 1;
             }
-
-            total_scores += 1;
-            line_precision_sum += coverage.lines_precision;
-            line_recall_sum += coverage.lines_recall;
-            line_f1_sum += coverage.lines_f1;
-            file_precision_sum += coverage.files_precision;
-            file_recall_sum += coverage.files_recall;
-            file_f1_sum += coverage.files_f1;
-            total_lines.accumulate(&ClassificationMetrics {
-                true_positives: coverage.lines_tp,
-                false_positives: coverage.lines_fp,
-                false_negatives: coverage.lines_fn,
-            });
-            total_files.accumulate(&ClassificationMetrics {
-                true_positives: coverage.files_tp,
-                false_positives: coverage.files_fp,
-                false_negatives: coverage.files_fn,
-            });
         }
     }
 
@@ -838,34 +671,41 @@ fn print_context_coverage_report(
 
     println!("{}", separator);
 
-    if total_scores > 0 {
-        let count = total_scores as f64;
+    let summary = compute_summary(
+        examples,
+        retrieved_context_byte_limit,
+        context_source_filter,
+    );
+
+    if let Some(total_scores) = summary.editable_context_examples {
         println!(
             "{:<40} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5}",
             "TOTAL / AVERAGE",
-            line_precision_sum / count * 100.0,
-            line_recall_sum / count * 100.0,
-            line_f1_sum / count * 100.0,
-            total_lines.true_positives,
-            total_lines.false_positives,
-            total_lines.false_negatives,
-            file_precision_sum / count * 100.0,
-            file_recall_sum / count * 100.0,
-            file_f1_sum / count * 100.0,
-            total_files.true_positives,
-            total_files.false_positives,
-            total_files.false_negatives
+            summary.avg_editable_context_lines_precision.unwrap_or(0.0) * 100.0,
+            summary.avg_editable_context_lines_recall.unwrap_or(0.0) * 100.0,
+            summary.avg_editable_context_lines_f1.unwrap_or(0.0) * 100.0,
+            summary.editable_context_lines_tp.unwrap_or(0),
+            summary.editable_context_lines_fp.unwrap_or(0),
+            summary.editable_context_lines_fn.unwrap_or(0),
+            summary.avg_editable_context_files_precision.unwrap_or(0.0) * 100.0,
+            summary.avg_editable_context_files_recall.unwrap_or(0.0) * 100.0,
+            summary.avg_editable_context_files_f1.unwrap_or(0.0) * 100.0,
+            summary.editable_context_files_tp.unwrap_or(0),
+            summary.editable_context_files_fp.unwrap_or(0),
+            summary.editable_context_files_fn.unwrap_or(0)
         );
         println!("{}", separator);
         println!(
             "Evaluated editable context coverage for {} examples",
             total_scores
         );
-        if retrieved_context_bytes_count > 0 {
+        if let (Some(avg_bytes), Some(example_count)) = (
+            summary.avg_retrieved_context_bytes,
+            summary.retrieved_context_examples,
+        ) {
             println!(
                 "Retrieved context size: {:.0} bytes avg ({} examples)",
-                retrieved_context_bytes_sum / retrieved_context_bytes_count as f64,
-                retrieved_context_bytes_count
+                avg_bytes, example_count
             );
         }
     }
@@ -873,6 +713,51 @@ fn print_context_coverage_report(
     println!("\n");
 }
 
+/// Print one "P/R/F1 avg + pooled TP/FP/FN" summary line, or nothing when
+/// the metric was never evaluated.
+fn print_prf_line(
+    label: &str,
+    examples: Option,
+    precision: Option,
+    recall: Option,
+    f1: Option,
+    true_positives: Option,
+    false_positives: Option,
+    false_negatives: Option,
+) {
+    let (
+        Some(examples),
+        Some(precision),
+        Some(recall),
+        Some(f1),
+        Some(true_positives),
+        Some(false_positives),
+        Some(false_negatives),
+    ) = (
+        examples,
+        precision,
+        recall,
+        f1,
+        true_positives,
+        false_positives,
+        false_negatives,
+    )
+    else {
+        return;
+    };
+    println!(
+        "{}: P={:.1}%, R={:.1}%, F1={:.1}% avg ({} evaluated, TP={}, FP={}, FN={})",
+        label,
+        precision * 100.0,
+        recall * 100.0,
+        f1 * 100.0,
+        examples,
+        true_positives,
+        false_positives,
+        false_negatives
+    );
+}
+
 fn percentile(sorted_values: &[usize], p: usize) -> usize {
     if sorted_values.is_empty() {
         return 0;
@@ -951,7 +836,7 @@ mod tests {
     use edit_prediction::example_spec::ExampleSpec;
     use edit_prediction_metrics::PredictionScore;
     use std::path::Path;
-    use zeta_prompt::{ExcerptRanges, RelatedExcerpt, ZetaPromptInput};
+    use zeta_prompt::{ExcerptRanges, RelatedExcerpt, Zeta2PromptInput};
 
     #[test]
     fn summary_includes_limited_filtered_retrieved_context_bytes_once_per_example() {
@@ -1004,7 +889,7 @@ mod tests {
                 human_feedback: Vec::new(),
                 rating: None,
             },
-            prompt_inputs: Some(ZetaPromptInput {
+            prompt_inputs: Some(Zeta2PromptInput {
                 cursor_path: Path::new("project/src/main.rs").into(),
                 cursor_excerpt: "".into(),
                 cursor_offset_in_excerpt: 0,
diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml
index 244b5f736e5862..f1bcefc4065231 100644
--- a/crates/edit_prediction_context/Cargo.toml
+++ b/crates/edit_prediction_context/Cargo.toml
@@ -26,7 +26,7 @@ serde.workspace = true
 smallvec.workspace = true
 telemetry.workspace = true
 text.workspace = true
-tree-sitter.workspace = true
+tree-sitter = { workspace = true, features = ["wasm"] }
 util.workspace = true
 zeta_prompt.workspace = true
 
@@ -43,4 +43,3 @@ serde_json.workspace = true
 settings = {workspace= true, features = ["test-support"]}
 text = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
-
diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs
index 44e295908af88c..d2be8f2ce6ac1d 100644
--- a/crates/edit_prediction_context/src/edit_prediction_context.rs
+++ b/crates/edit_prediction_context/src/edit_prediction_context.rs
@@ -5,8 +5,8 @@ use futures::{FutureExt, StreamExt as _, channel::mpsc, future};
 use gpui::{
     App, AppContext, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, TaskExt, WeakEntity,
 };
-use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _};
-use project::{LocationLink, Project, ProjectPath};
+use language::{Anchor, Bias, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _};
+use project::{EditPredictionDefinition, Project, ProjectPath};
 use smallvec::SmallVec;
 use std::{
     collections::hash_map,
@@ -29,7 +29,8 @@ mod fake_definition_lsp;
 mod git_log_context;
 
 pub use editable_context::{
-    EditHistoryContextEntry, collect_editable_context, limit_retrieved_context_to_bytes,
+    EditHistoryContextEntry, OracleTarget, collect_editable_context,
+    limit_retrieved_context_to_bytes,
 };
 
 pub use zeta_prompt::{ContextSource, RelatedExcerpt, RelatedFile};
@@ -76,20 +77,15 @@ struct Identifier {
 
 enum DefinitionTask {
     CacheHit(Arc),
-    CacheMiss(
-        Task<
-            Option<(
-                Task>>>,
-                Task>>>,
-            )>,
-        >,
-    ),
+    CacheMiss {
+        project: WeakEntity,
+        task: Task>>,
+    },
 }
 
 #[derive(Debug)]
 struct CacheEntry {
     definitions: SmallVec<[CachedDefinition; 1]>,
-    type_definitions: SmallVec<[CachedDefinition; 1]>,
 }
 
 #[derive(Clone, Debug)]
@@ -182,7 +178,7 @@ impl RelatedExcerptStore {
                     .path
                     .strip_prefix(worktree.root_name().as_unix_str())
                     .ok()?;
-                let relative_path = RelPath::new(relative_path, PathStyle::Posix).ok()?;
+                let relative_path = RelPath::new(relative_path, PathStyle::Unix).ok()?;
                 let project_path = ProjectPath {
                     worktree_id: worktree.id(),
                     path: relative_path.into_owned().into(),
@@ -311,34 +307,21 @@ impl RelatedExcerptStore {
                         DefinitionTask::CacheHit(entry.clone())
                     } else {
                         let project = this.project.clone();
-                        let buffer = buffer.downgrade();
-                        DefinitionTask::CacheMiss(cx.spawn(async move |_, cx| {
-                            let buffer = buffer.upgrade()?;
-                            let definitions = project
-                                .update(cx, |project, cx| {
-                                    project.workspace_definitions(
-                                        &buffer,
-                                        identifier.range.start,
-                                        cx,
-                                    )
-                                })
-                                .ok()?;
-                            let type_definitions = project
-                                .update(cx, |project, cx| {
-                                    // tombi LSP for toml will open a scratch buffer with the JSON schema of
-                                    // the toml file when a goto type definition is requested
-                                    if is_tombi_lsp_in_toml(project, &buffer, cx) {
-                                        return Task::ready(Ok(None));
-                                    }
-                                    project.workspace_type_definitions(
-                                        &buffer,
-                                        identifier.range.start,
-                                        cx,
-                                    )
-                                })
-                                .ok()?;
-                            Some((definitions, type_definitions))
-                        }))
+                        let task = project
+                            .update(cx, |project, cx| {
+                                // tombi LSP for toml will open a scratch buffer with the JSON schema of
+                                // the toml file when a goto type definition is requested
+                                let include_type_definitions =
+                                    !is_tombi_lsp_in_toml(project, &buffer, cx);
+                                project.edit_prediction_definitions(
+                                    &buffer,
+                                    identifier.range.start,
+                                    include_type_definitions,
+                                    cx,
+                                )
+                            })
+                            .unwrap_or_else(|_| Task::ready(Ok(Vec::new())));
+                        DefinitionTask::CacheMiss { project, task }
                     };
 
                     let cx = async_cx.clone();
@@ -347,50 +330,29 @@ impl RelatedExcerptStore {
                             DefinitionTask::CacheHit(cache_entry) => {
                                 Some((identifier, cache_entry, None))
                             }
-                            DefinitionTask::CacheMiss(task) => {
-                                let (definitions, type_definitions) = task.await?;
-                                let (definition_locations, type_definition_locations) =
-                                    futures::join!(definitions, type_definitions);
+                            DefinitionTask::CacheMiss { project, task } => {
+                                let definition_locations = task.await.log_err().unwrap_or_default();
                                 let duration = start_time.elapsed();
 
-                                let definition_locations =
-                                    definition_locations.log_err().flatten().unwrap_or_default();
-                                let type_definition_locations = type_definition_locations
-                                    .log_err()
-                                    .flatten()
-                                    .unwrap_or_default();
-
                                 let definitions: SmallVec<[CachedDefinition; 1]> =
-                                    definition_locations
-                                        .into_iter()
-                                        .filter_map(|location| {
+                                    future::join_all(definition_locations.into_iter().map(
+                                        |definition| {
+                                            let project = project.clone();
                                             let mut cx = cx.clone();
-                                            process_definition(location, &mut cx)
-                                        })
-                                        .collect();
-
-                                let type_definitions: SmallVec<[CachedDefinition; 1]> =
-                                    type_definition_locations
-                                        .into_iter()
-                                        .filter_map(|location| {
-                                            let mut cx = cx.clone();
-                                            process_definition(location, &mut cx)
-                                        })
-                                        .filter(|type_def| {
-                                            !definitions.iter().any(|def| {
-                                                def.buffer.entity_id()
-                                                    == type_def.buffer.entity_id()
-                                                    && def.anchor_range == type_def.anchor_range
-                                            })
-                                        })
-                                        .collect();
+                                            async move {
+                                                process_definition(definition, &project, &mut cx)
+                                                    .await
+                                            }
+                                        },
+                                    ))
+                                    .await
+                                    .into_iter()
+                                    .flatten()
+                                    .collect();
 
                                 Some((
                                     identifier,
-                                    Arc::new(CacheEntry {
-                                        definitions,
-                                        type_definitions,
-                                    }),
+                                    Arc::new(CacheEntry { definitions }),
                                     Some(duration),
                                 ))
                             }
@@ -468,11 +430,7 @@ async fn rebuild_related_files(
     let mut snapshots = HashMap::default();
     let mut worktree_root_names = HashMap::default();
     for entry in new_entries.values() {
-        for definition in entry
-            .definitions
-            .iter()
-            .chain(entry.type_definitions.iter())
-        {
+        for definition in entry.definitions.iter() {
             if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) {
                 definition
                     .buffer
@@ -509,11 +467,7 @@ async fn rebuild_related_files(
                     .get(identifier)
                     .copied()
                     .unwrap_or(usize::MAX);
-                for definition in entry
-                    .definitions
-                    .iter()
-                    .chain(entry.type_definitions.iter())
-                {
+                for definition in entry.definitions.iter() {
                     let Some(snapshot) = snapshots.get(&definition.buffer.entity_id()) else {
                         continue;
                     };
@@ -634,16 +588,24 @@ use language::ToPoint as _;
 
 const MAX_TARGET_LEN: usize = 128;
 
-fn process_definition(location: LocationLink, cx: &mut AsyncApp) -> Option {
+async fn process_definition(
+    definition: EditPredictionDefinition,
+    project: &WeakEntity,
+    cx: &mut AsyncApp,
+) -> Option {
+    let EditPredictionDefinition { path, range } = definition;
+    let buffer = project
+        .update(cx, |project, cx| project.open_buffer(path.clone(), cx))
+        .ok()?
+        .await
+        .log_err()?;
+
     cx.update(|cx| {
-        let buffer = location.target.buffer;
         let buffer_snapshot = buffer.read(cx);
-        let file = buffer_snapshot.file()?;
-        let path = ProjectPath {
-            worktree_id: file.worktree_id(cx),
-            path: file.path().clone(),
-        };
-        let anchor_range = location.target.range;
+        let target_start = buffer_snapshot.clip_point_utf16(range.start, Bias::Left);
+        let target_end = buffer_snapshot.clip_point_utf16(range.end, Bias::Left);
+        let anchor_range =
+            buffer_snapshot.anchor_after(target_start)..buffer_snapshot.anchor_before(target_end);
 
         // 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.
diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs
index 5b8e9c6aecb49c..7c98f7a99268a5 100644
--- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs
+++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs
@@ -5,10 +5,11 @@ use gpui::TestAppContext;
 use indoc::indoc;
 use language::{Point, ToPoint as _, rust_lang};
 use lsp::FakeLanguageServer;
-use project::{FakeFs, LocationLink, Project};
+use project::{FakeFs, LocationLink, Project, ProjectPath};
 use serde_json::json;
 use settings::SettingsStore;
 use std::fmt::Write as _;
+use util::rel_path::rel_path;
 use util::{path, test::marked_text_ranges};
 
 #[gpui::test]
@@ -561,9 +562,10 @@ async fn test_type_definition_deduplication(cx: &mut TestAppContext) {
 
     // In this project the only identifier near the cursor whose type definition
     // resolves is `TypeA`, and its GotoTypeDefinition returns the exact same
-    // location as GotoDefinition. After deduplication the CacheEntry for `TypeA`
-    // should have an empty `type_definitions` vec, meaning the type-definition
-    // path contributes nothing extra to the related-file output.
+    // location as GotoDefinition. After the definitions and type definitions are
+    // merged and deduped, the type-definition location is dropped, so it
+    // contributes nothing extra to the related-file output (the target location
+    // appears only once).
     fs.insert_tree(
         path!("/root"),
         json!({
@@ -638,6 +640,85 @@ async fn test_type_definition_deduplication(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_edit_prediction_filters_raw_definitions_before_opening_buffers(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "src": {
+                "main.rs": indoc! {"
+                    // fake-definition-lsp-extra target /root/src/large.rs 0 0 0 129
+                    // fake-definition-lsp-extra target /root/src/valid.rs 0 3 0 9
+                    // fake-definition-lsp-extra target /outside.rs 0 3 0 10
+                    fn main() {
+                        target();
+                    }
+                "},
+                "valid.rs": "fn target() {}\n",
+                "large.rs": format!("{}\n", "a".repeat(MAX_TARGET_LEN + 16)),
+            },
+        }),
+    )
+    .await;
+    fs.insert_file(path!("/outside.rs"), "fn outside() {}\n".into())
+        .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 _fake_language_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("target();")
+                .expect("target call not found");
+            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, cx| {
+        assert_related_files(
+            &store.related_files(cx),
+            &[
+                ("root/src/valid.rs", &["fn target() {}"]),
+                ("root/src/main.rs", &["fn main() {\n    target();\n}"]),
+            ],
+        );
+    });
+
+    let worktree_id = buffer.read_with(cx, |buffer, cx| {
+        buffer.file().expect("buffer has file").worktree_id(cx)
+    });
+    let valid_path = ProjectPath {
+        worktree_id,
+        path: rel_path("src/valid.rs").into(),
+    };
+    project.read_with(cx, |project, cx| {
+        assert!(project.get_open_buffer(&valid_path, cx).is_some());
+        assert_eq!(project.worktrees(cx).count(), 1);
+    });
+}
+
 #[gpui::test]
 async fn test_definitions_ranked_by_cursor_proximity(cx: &mut TestAppContext) {
     init_test(cx);
diff --git a/crates/edit_prediction_context/src/editable_context.rs b/crates/edit_prediction_context/src/editable_context.rs
index 1aede0ce65a889..ce275191417bab 100644
--- a/crates/edit_prediction_context/src/editable_context.rs
+++ b/crates/edit_prediction_context/src/editable_context.rs
@@ -9,7 +9,7 @@ use std::{
 };
 use text::Anchor;
 use util::{paths::PathStyle, rel_path::RelPath};
-use zeta_prompt::{ContextSource, RelatedExcerpt, RelatedFile};
+use zeta_prompt::{ContextSource, RelatedExcerpt, RelatedFile, multi_region::is_good_block_start};
 
 use crate::{
     bm25_context::{Bm25ContextCandidate, collect_bm25_context},
@@ -22,6 +22,15 @@ const CURSOR_CONTEXT_LINE_COUNT: u32 = 20;
 const EDIT_HISTORY_CONTEXT_LINE_COUNT: u32 = 20;
 const GIT_LOG_CONTEXT_LINE_COUNT: u32 = 10000;
 const GIT_LOG_CONTEXT_FILE_COUNT: usize = 10;
+const ORACLE_SNIPPET_MIN_CONTEXT_LINE_COUNT: u32 = 10;
+const ORACLE_SNIPPET_MAX_CONTEXT_LINE_COUNT: u32 = 40;
+/// How far excerpt boundaries may be nudged to land on a natural block
+/// boundary, mirroring `zeta_prompt::multi_region`'s marker placement.
+const BOUNDARY_SNAP_LINE_COUNT: u32 = 5;
+/// Maximum number of rows between two excerpts of the same buffer that get
+/// bridged into one contiguous excerpt instead of rendering an elision
+/// marker between them.
+const BRIDGED_GAP_LINE_COUNT: u32 = 3;
 
 type RangesByBuffer = HashMap, Vec)>;
 
@@ -31,6 +40,16 @@ pub struct EditHistoryContextEntry {
     pub edited_range: Range,
 }
 
+/// A file known (from expected patches) to be edited next, used by the
+/// oracle context sources when generating training data.
+#[derive(Clone, Debug)]
+pub struct OracleTarget {
+    pub path: Arc,
+    /// 0-based, end-exclusive row ranges of the expected edit hunks.
+    /// Only used by `ContextSource::OracleSnippet`.
+    pub row_ranges: Vec>,
+}
+
 struct EditableContextRange {
     range: Range,
     order: usize,
@@ -48,7 +67,7 @@ pub async fn collect_editable_context(
     active_buffer: Entity,
     cursor_position: Anchor,
     edit_history: Vec,
-    oracle_paths: Vec>,
+    oracle_targets: Vec,
     context_sources: Vec,
     cx: &mut AsyncApp,
 ) -> anyhow::Result> {
@@ -81,6 +100,14 @@ pub async fn collect_editable_context(
         .await;
     }
 
+    // Collected before bm25 so that, under a related-files byte budget, the
+    // small snippets containing the expected edits are never trimmed away in
+    // favor of bm25 excerpts.
+    if context_sources.contains(&ContextSource::OracleSnippet) {
+        collect_oracle_snippet_context(&mut ranges_by_buffer, project.clone(), &oracle_targets, cx)
+            .await;
+    }
+
     if context_sources.contains(&ContextSource::Bm25) {
         collect_bm25_context_ranges(
             &mut ranges_by_buffer,
@@ -94,7 +121,8 @@ pub async fn collect_editable_context(
     }
 
     if context_sources.contains(&ContextSource::OracleFile) {
-        collect_oracle_file_context(&mut ranges_by_buffer, project.clone(), oracle_paths, cx).await;
+        collect_oracle_file_context(&mut ranges_by_buffer, project.clone(), &oracle_targets, cx)
+            .await;
     }
 
     Ok(cx.update(|cx| {
@@ -405,24 +433,24 @@ fn anchor_range_for_row_range(
 async fn collect_oracle_file_context(
     ranges_by_buffer: &mut RangesByBuffer,
     project: Entity,
-    oracle_paths: Vec>,
+    oracle_targets: &[OracleTarget],
     cx: &mut AsyncApp,
 ) {
     let next_order = next_context_order(ranges_by_buffer);
     let mut seen_buffers = HashSet::default();
     let mut index = 0;
 
-    for path in oracle_paths {
-        let buffer = match open_buffer_for_path(&project, &path, cx).await {
+    for target in oracle_targets {
+        let buffer = match open_buffer_for_path(&project, &target.path, cx).await {
             Ok(Some(buffer)) => buffer,
             Ok(None) => {
-                log::debug!("failed to find oracle file path: {}", path.display());
+                log::debug!("failed to find oracle file path: {}", target.path.display());
                 continue;
             }
             Err(error) => {
                 log::debug!(
                     "failed to open oracle file path {}: {error:#}",
-                    path.display()
+                    target.path.display()
                 );
                 continue;
             }
@@ -443,6 +471,89 @@ async fn collect_oracle_file_context(
     }
 }
 
+async fn collect_oracle_snippet_context(
+    ranges_by_buffer: &mut RangesByBuffer,
+    project: Entity,
+    oracle_targets: &[OracleTarget],
+    cx: &mut AsyncApp,
+) {
+    let next_order = next_context_order(ranges_by_buffer);
+    let mut index = 0;
+
+    for target in oracle_targets {
+        if target.row_ranges.is_empty() {
+            continue;
+        }
+
+        let buffer = match open_buffer_for_path(&project, &target.path, cx).await {
+            Ok(Some(buffer)) => buffer,
+            Ok(None) => {
+                log::debug!(
+                    "failed to find oracle snippet path: {}",
+                    target.path.display()
+                );
+                continue;
+            }
+            Err(error) => {
+                log::debug!(
+                    "failed to open oracle snippet path {}: {error:#}",
+                    target.path.display()
+                );
+                continue;
+            }
+        };
+
+        for row_range in &target.row_ranges {
+            let Some(range) = buffer.read_with(cx, |buffer, _cx| {
+                let snapshot = buffer.snapshot();
+                let padding_above = oracle_snippet_padding(&target.path, row_range.start, 0);
+                let padding_below = oracle_snippet_padding(&target.path, row_range.end, 1);
+                let start_row = row_range.start.saturating_sub(padding_above);
+                // Empty hunk row ranges (pure insertions) still cover one row.
+                let core_end_row = row_range.end.max(row_range.start + 1);
+                let end_row = core_end_row.saturating_add(padding_below);
+                let start_row =
+                    snap_start_row_to_block_boundary(&snapshot, start_row, row_range.start);
+                // `end_row` is exclusive while snapping operates on the last
+                // included row.
+                let end_row = snap_end_row_to_block_boundary(
+                    &snapshot,
+                    end_row.saturating_sub(1),
+                    core_end_row.saturating_sub(1),
+                ) + 1;
+                anchor_range_for_row_range(&snapshot, start_row..end_row)
+            }) else {
+                continue;
+            };
+
+            push_context_range(
+                ranges_by_buffer,
+                buffer.clone(),
+                range,
+                next_order + index,
+                ContextSource::OracleSnippet,
+            );
+            index += 1;
+        }
+    }
+}
+
+/// Deterministic pseudo-random padding, so that the expected edit is not
+/// always centered in the snippet (a student model could otherwise learn the
+/// excerpt center as a position prior), while keeping context retrieval
+/// reproducible across runs.
+fn oracle_snippet_padding(path: &Path, row: u32, salt: u32) -> u32 {
+    use std::hash::{Hash as _, Hasher as _};
+
+    let mut hasher = collections::FxHasher::default();
+    path.hash(&mut hasher);
+    row.hash(&mut hasher);
+    salt.hash(&mut hasher);
+    let span =
+        u64::from(ORACLE_SNIPPET_MAX_CONTEXT_LINE_COUNT - ORACLE_SNIPPET_MIN_CONTEXT_LINE_COUNT);
+    ORACLE_SNIPPET_MIN_CONTEXT_LINE_COUNT + (hasher.finish() % (span + 1)) as u32
+}
+
 async fn open_buffer_for_path(
     project: &Entity,
     path: &Path,
@@ -541,7 +652,7 @@ async fn collect_git_log_context(
         .into_iter()
         .enumerate()
     {
-        let Ok(related_path) = RelPath::new(&related_path, PathStyle::Posix) else {
+        let Ok(related_path) = RelPath::new(&related_path, PathStyle::Unix) else {
             continue;
         };
         let project_path = ProjectPath {
@@ -588,11 +699,71 @@ fn expanded_anchor_range(
         .row
         .saturating_add(context_line_count)
         .min(snapshot.max_point().row);
+    let start_row = snap_start_row_to_block_boundary(snapshot, start_row, start.row);
+    let end_row = snap_end_row_to_block_boundary(snapshot, end_row, end.row);
     let start = snapshot.anchor_before(Point::new(start_row, 0));
     let end = snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row)));
     start..end
 }
 
+/// Nudge an excerpt's first row forward (up to `BOUNDARY_SNAP_LINE_COUNT`
+/// lines, never past `core_row`) so the excerpt starts at a natural block
+/// boundary: preferably a good block start right after blank line(s), or
+/// failing that any good block start. Mirrors the marker placement
+/// heuristics of `zeta_prompt::multi_region`.
+fn snap_start_row_to_block_boundary(
+    snapshot: &text::BufferSnapshot,
+    row: u32,
+    core_row: u32,
+) -> u32 {
+    let limit = core_row
+        .min(row.saturating_add(BOUNDARY_SNAP_LINE_COUNT))
+        .min(snapshot.max_point().row);
+    let mut first_good_start = None;
+    for candidate in row..=limit {
+        if snapshot.is_line_blank(candidate) {
+            continue;
+        }
+        if !is_good_block_start(line_text(snapshot, candidate).trim()) {
+            continue;
+        }
+        if candidate > 0 && snapshot.is_line_blank(candidate - 1) {
+            return candidate;
+        }
+        if first_good_start.is_none() {
+            first_good_start = Some(candidate);
+        }
+    }
+    first_good_start.unwrap_or(row)
+}
+
+/// Nudge an excerpt's last row backward (up to `BOUNDARY_SNAP_LINE_COUNT`
+/// lines, never before `core_row`) so the excerpt ends at the last non-blank
+/// line before a blank line or at the end of the file.
+fn snap_end_row_to_block_boundary(snapshot: &text::BufferSnapshot, row: u32, core_row: u32) -> u32 {
+    let max_row = snapshot.max_point().row;
+    let row = row.min(max_row);
+    if row == max_row {
+        return row;
+    }
+    let limit = core_row.max(row.saturating_sub(BOUNDARY_SNAP_LINE_COUNT));
+    for candidate in (limit..=row).rev() {
+        if snapshot.is_line_blank(candidate) {
+            continue;
+        }
+        if snapshot.is_line_blank(candidate + 1) {
+            return candidate;
+        }
+    }
+    row
+}
+
+fn line_text(snapshot: &text::BufferSnapshot, row: u32) -> String {
+    snapshot
+        .text_for_range(Point::new(row, 0)..Point::new(row, snapshot.line_len(row)))
+        .collect()
+}
+
 fn push_context_range(
     ranges_by_buffer: &mut RangesByBuffer,
     buffer: Entity,
@@ -628,9 +799,10 @@ fn related_file_for_ranges(
     ))
     .into();
 
-    let ranges = resolved_context_ranges(ranges, &snapshot);
+    let mut ranges = resolved_context_ranges(ranges, &snapshot);
+    split_overlapping_ranges(&mut ranges);
 
-    let mut excerpts = ranges
+    let excerpts = ranges
         .into_iter()
         .map(|range| RelatedExcerpt {
             row_range: range.range.start.row..range.range.end.row,
@@ -642,7 +814,6 @@ fn related_file_for_ranges(
             context_source: range.context_source,
         })
         .collect::>();
-    excerpts.sort_by_key(|excerpt| excerpt.order);
 
     Some(RelatedFile {
         path,
@@ -674,29 +845,124 @@ fn resolved_context_ranges(
         .collect()
 }
 
-#[allow(dead_code)]
-fn merge_overlapping_ranges(ranges: &mut Vec) {
+/// Split overlapping ranges into disjoint segments instead of merging them
+/// into one range. Each segment keeps the minimum order and highest-priority
+/// source among the ranges covering it, and adjacent segments with equal
+/// order are coalesced. This preserves priority granularity: a small
+/// high-priority snippet inside a large low-priority range remains its own
+/// excerpt, so byte-budget selection can retain it even when the surrounding
+/// range doesn't fit. The resulting segments are disjoint and sorted by
+/// position.
+///
+/// Ranges separated by at most `BRIDGED_GAP_LINE_COUNT` rows are bridged:
+/// the small gap is attached to the preceding segment so the excerpts render
+/// as one contiguous block instead of being separated by an elision marker.
+fn split_overlapping_ranges(ranges: &mut Vec) {
     ranges.sort_by_key(|range| (range.range.start, range.range.end));
-    let mut merged: Vec = Vec::new();
+    let mut output: Vec = Vec::new();
+    let mut cluster: Vec = Vec::new();
+    let mut cluster_end = Point::zero();
 
     for range in ranges.drain(..) {
-        if let Some(last_range) = merged.last_mut()
-            && range.range.start <= last_range.range.end
-        {
-            if context_source_order(range.context_source)
-                < context_source_order(last_range.context_source)
+        let bridge_limit_row = row_aligned_end(cluster_end)
+            .row
+            .saturating_add(BRIDGED_GAP_LINE_COUNT);
+        if cluster.is_empty() || range.range.start.row <= bridge_limit_row {
+            cluster_end = cluster_end.max(range.range.end);
+            cluster.push(range);
+        } else {
+            split_cluster(std::mem::take(&mut cluster), cluster_end, &mut output);
+            cluster_end = range.range.end;
+            cluster.push(range);
+        }
+    }
+    if !cluster.is_empty() {
+        split_cluster(cluster, cluster_end, &mut output);
+    }
+
+    *ranges = output;
+}
+
+fn split_cluster(
+    cluster: Vec,
+    cluster_end: Point,
+    output: &mut Vec,
+) {
+    if cluster.len() == 1 {
+        output.extend(cluster);
+        return;
+    }
+
+    let cluster_start = cluster[0].range.start;
+    let mut boundaries = Vec::with_capacity(cluster.len() * 2 + 1);
+    boundaries.push(cluster_start);
+    for range in &cluster {
+        boundaries.push(range.range.start);
+        boundaries.push(row_aligned_end(range.range.end));
+    }
+    boundaries.retain(|boundary| *boundary >= cluster_start && *boundary < cluster_end);
+    boundaries.sort();
+    boundaries.dedup();
+    boundaries.push(cluster_end);
+
+    for window in boundaries.windows(2) {
+        let segment = window[0]..window[1];
+        // The segment is attributed to the lowest-order covering range
+        // (breaking ties by source priority), so that its source label is
+        // consistent with the order that drives budget selection.
+        let mut order_and_source: Option<(usize, ContextSource)> = None;
+        for range in &cluster {
+            if range.range.start <= segment.start && row_aligned_end(range.range.end) >= segment.end
             {
-                last_range.context_source = range.context_source;
+                let candidate = (range.order, range.context_source);
+                if order_and_source.is_none_or(|(order, context_source)| {
+                    (candidate.0, context_source_order(candidate.1))
+                        < (order, context_source_order(context_source))
+                }) {
+                    order_and_source = Some(candidate);
+                }
+            }
+        }
+        let Some((order, context_source)) = order_and_source else {
+            // A bridged gap between two nearby ranges: no range covers it, so
+            // attach it to the preceding segment to form contiguous output.
+            if let Some(last) = output.last_mut()
+                && last.range.end == segment.start
+            {
+                last.range.end = segment.end;
+            }
+            continue;
+        };
+
+        if let Some(last) = output.last_mut()
+            && last.range.end == segment.start
+            && last.order == order
+        {
+            last.range.end = segment.end;
+            if context_source_order(context_source) < context_source_order(last.context_source) {
+                last.context_source = context_source;
             }
-            last_range.range.end = last_range.range.end.max(range.range.end);
-            last_range.order = last_range.order.min(range.order);
             continue;
         }
 
-        merged.push(range);
+        output.push(ResolvedEditableContextRange {
+            range: segment,
+            order,
+            context_source,
+        });
     }
+}
 
-    *ranges = merged;
+/// Context ranges always cover whole lines: their ends sit either at a line
+/// start (column 0) or at the end of a line's content. Cutting a segment at
+/// the end of a line's content would attribute the trailing newline to the
+/// next segment, so nudge such ends forward to the next line start.
+fn row_aligned_end(point: Point) -> Point {
+    if point.column > 0 {
+        Point::new(point.row + 1, 0)
+    } else {
+        point
+    }
 }
 
 #[allow(dead_code)]
@@ -716,6 +982,257 @@ fn context_source_order(context_source: ContextSource) -> usize {
         ContextSource::EditHistoryFile => 4,
         ContextSource::GitLog => 5,
         ContextSource::Bm25 => 6,
-        ContextSource::OracleFile => 7,
+        ContextSource::OracleSnippet => 7,
+        ContextSource::OracleFile => 8,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn text_snapshot(text: &str) -> text::BufferSnapshot {
+        text::Buffer::new(
+            text::ReplicaId::LOCAL,
+            text::BufferId::new(1).unwrap(),
+            text,
+        )
+        .snapshot()
+        .clone()
+    }
+
+    fn resolved_range(
+        start_row: u32,
+        end_row: u32,
+        order: usize,
+        context_source: ContextSource,
+    ) -> ResolvedEditableContextRange {
+        ResolvedEditableContextRange {
+            range: Point::new(start_row, 0)..Point::new(end_row, 0),
+            order,
+            context_source,
+        }
+    }
+
+    #[test]
+    fn test_split_overlapping_ranges_coalesces_equal_orders() {
+        // A full-file current-file range plus edit-history windows inside it:
+        // every segment is covered by the order-0 full-file range, so they
+        // coalesce back into a single range with the highest-priority source.
+        let mut ranges = vec![
+            resolved_range(6, 46, 1, ContextSource::EditHistory),
+            resolved_range(0, 281, 0, ContextSource::CurrentFile),
+            resolved_range(17, 79, 2, ContextSource::EditHistory),
+            resolved_range(85, 125, 3, ContextSource::EditHistory),
+        ];
+        split_overlapping_ranges(&mut ranges);
+        assert_eq!(ranges.len(), 1);
+        assert_eq!(ranges[0].range, Point::new(0, 0)..Point::new(281, 0));
+        assert_eq!(ranges[0].order, 0);
+        assert_eq!(ranges[0].context_source, ContextSource::CurrentFile);
+    }
+
+    #[test]
+    fn test_split_overlapping_ranges_keeps_disjoint_ranges() {
+        let mut ranges = vec![
+            resolved_range(50, 60, 1, ContextSource::EditHistory),
+            resolved_range(0, 10, 0, ContextSource::CursorExcerpt),
+            resolved_range(5, 12, 2, ContextSource::EditHistory),
+        ];
+        split_overlapping_ranges(&mut ranges);
+        assert_eq!(ranges.len(), 3);
+        assert_eq!(ranges[0].range, Point::new(0, 0)..Point::new(10, 0));
+        assert_eq!(ranges[0].order, 0);
+        assert_eq!(ranges[0].context_source, ContextSource::CursorExcerpt);
+        assert_eq!(ranges[1].range, Point::new(10, 0)..Point::new(12, 0));
+        assert_eq!(ranges[1].order, 2);
+        assert_eq!(ranges[1].context_source, ContextSource::EditHistory);
+        assert_eq!(ranges[2].range, Point::new(50, 0)..Point::new(60, 0));
+        assert_eq!(ranges[2].order, 1);
+    }
+
+    #[test]
+    fn test_split_overlapping_ranges_keeps_adjacent_ranges_with_distinct_orders() {
+        let mut ranges = vec![
+            resolved_range(0, 10, 0, ContextSource::EditHistory),
+            resolved_range(10, 20, 1, ContextSource::EditHistory),
+        ];
+        split_overlapping_ranges(&mut ranges);
+        assert_eq!(ranges.len(), 2);
+        assert_eq!(ranges[0].range, Point::new(0, 0)..Point::new(10, 0));
+        assert_eq!(ranges[0].order, 0);
+        assert_eq!(ranges[1].range, Point::new(10, 0)..Point::new(20, 0));
+        assert_eq!(ranges[1].order, 1);
+    }
+
+    #[test]
+    fn test_split_overlapping_ranges_preserves_high_priority_snippet_inside_large_range() {
+        // A small high-priority snippet inside a large low-priority range
+        // stays its own segment, so byte-budget selection can keep it even
+        // when the surrounding range doesn't fit.
+        let mut ranges = vec![
+            resolved_range(0, 200, 8, ContextSource::GitLog),
+            resolved_range(50, 60, 2, ContextSource::OracleSnippet),
+        ];
+        split_overlapping_ranges(&mut ranges);
+        assert_eq!(ranges.len(), 3);
+        assert_eq!(ranges[0].range, Point::new(0, 0)..Point::new(50, 0));
+        assert_eq!(ranges[0].order, 8);
+        assert_eq!(ranges[0].context_source, ContextSource::GitLog);
+        assert_eq!(ranges[1].range, Point::new(50, 0)..Point::new(60, 0));
+        assert_eq!(ranges[1].order, 2);
+        assert_eq!(ranges[1].context_source, ContextSource::OracleSnippet);
+        assert_eq!(ranges[2].range, Point::new(60, 0)..Point::new(200, 0));
+        assert_eq!(ranges[2].order, 8);
+        assert_eq!(ranges[2].context_source, ContextSource::GitLog);
+    }
+
+    #[test]
+    fn test_split_overlapping_ranges_aligns_mid_line_ends_to_row_starts() {
+        // Ranges ending at a line's content end (column > 0) are treated as
+        // covering through that whole line, so equal-order overlapping ranges
+        // still coalesce into one segment.
+        let mut ranges = vec![
+            ResolvedEditableContextRange {
+                range: Point::new(0, 0)..Point::new(10, 5),
+                order: 1,
+                context_source: ContextSource::EditHistory,
+            },
+            resolved_range(5, 20, 1, ContextSource::EditHistory),
+        ];
+        split_overlapping_ranges(&mut ranges);
+        assert_eq!(ranges.len(), 1);
+        assert_eq!(ranges[0].range, Point::new(0, 0)..Point::new(20, 0));
+        assert_eq!(ranges[0].order, 1);
+    }
+
+    #[test]
+    fn test_split_overlapping_ranges_bridges_small_gaps() {
+        // Ranges separated by a few rows are bridged: the gap is attached to
+        // the preceding segment, producing contiguous excerpts.
+        let mut ranges = vec![
+            resolved_range(0, 10, 0, ContextSource::EditHistory),
+            resolved_range(13, 20, 1, ContextSource::Bm25),
+        ];
+        split_overlapping_ranges(&mut ranges);
+        assert_eq!(ranges.len(), 2);
+        assert_eq!(ranges[0].range, Point::new(0, 0)..Point::new(13, 0));
+        assert_eq!(ranges[0].order, 0);
+        assert_eq!(ranges[0].context_source, ContextSource::EditHistory);
+        assert_eq!(ranges[1].range, Point::new(13, 0)..Point::new(20, 0));
+        assert_eq!(ranges[1].order, 1);
+        assert_eq!(ranges[1].context_source, ContextSource::Bm25);
+    }
+
+    #[test]
+    fn test_split_overlapping_ranges_bridged_equal_orders_coalesce() {
+        let mut ranges = vec![
+            resolved_range(0, 10, 1, ContextSource::EditHistory),
+            resolved_range(12, 20, 1, ContextSource::EditHistory),
+        ];
+        split_overlapping_ranges(&mut ranges);
+        assert_eq!(ranges.len(), 1);
+        assert_eq!(ranges[0].range, Point::new(0, 0)..Point::new(20, 0));
+        assert_eq!(ranges[0].order, 1);
+    }
+
+    #[test]
+    fn test_split_overlapping_ranges_does_not_bridge_large_gaps() {
+        let mut ranges = vec![
+            resolved_range(0, 10, 0, ContextSource::EditHistory),
+            resolved_range(14, 20, 1, ContextSource::Bm25),
+        ];
+        split_overlapping_ranges(&mut ranges);
+        assert_eq!(ranges.len(), 2);
+        assert_eq!(ranges[0].range, Point::new(0, 0)..Point::new(10, 0));
+        assert_eq!(ranges[1].range, Point::new(14, 0)..Point::new(20, 0));
+    }
+
+    #[test]
+    fn test_snap_start_row_prefers_block_start_after_blank_line() {
+        let snapshot = text_snapshot(concat!(
+            "    body\n",   // 0
+            "}\n",          // 1
+            "\n",           // 2
+            "fn bar() {\n", // 3
+            "    body\n",   // 4
+            "    core\n",   // 5
+        ));
+        // Row 1 is a structural tail and row 2 is blank; row 3 follows a
+        // blank line and is a good block start.
+        assert_eq!(snap_start_row_to_block_boundary(&snapshot, 1, 5), 3);
+        // Snapping never moves past the core row.
+        assert_eq!(snap_start_row_to_block_boundary(&snapshot, 1, 2), 1);
+        // A row that is already a good start after a blank line stays put.
+        assert_eq!(snap_start_row_to_block_boundary(&snapshot, 3, 5), 3);
+    }
+
+    #[test]
+    fn test_snap_start_row_falls_back_to_first_good_start() {
+        let snapshot = text_snapshot(concat!(
+            "}\n",          // 0
+            "let x = 1;\n", // 1
+            "let y = 2;\n", // 2
+            "core\n",       // 3
+        ));
+        // No after-blank boundary in the window; the first good start wins.
+        assert_eq!(snap_start_row_to_block_boundary(&snapshot, 0, 3), 1);
+    }
+
+    #[test]
+    fn test_snap_end_row_ends_before_blank_line() {
+        let snapshot = text_snapshot(concat!(
+            "core\n", // 0
+            "a\n",    // 1
+            "b\n",    // 2
+            "\n",     // 3
+            "c\n",    // 4
+            "d\n",    // 5
+        ));
+        // Row 4 is followed by non-blank row 5, so scan back to row 2 which
+        // precedes the blank row 3.
+        assert_eq!(snap_end_row_to_block_boundary(&snapshot, 4, 0), 2);
+        // Snapping never moves before the core row.
+        assert_eq!(snap_end_row_to_block_boundary(&snapshot, 4, 4), 4);
+        // The last row of the file stays put.
+        let max_row = snapshot.max_point().row;
+        assert_eq!(
+            snap_end_row_to_block_boundary(&snapshot, max_row, 0),
+            max_row
+        );
+    }
+
+    #[test]
+    fn test_limit_retrieved_context_keeps_high_priority_snippet_under_tight_budget() {
+        let line = "0123456789\n";
+        let excerpt = |row_range: Range, order: usize, context_source: ContextSource| {
+            let text = line.repeat((row_range.end - row_range.start) as usize);
+            RelatedExcerpt {
+                row_range,
+                text: text.into(),
+                order,
+                context_source,
+            }
+        };
+        let related_files = vec![RelatedFile {
+            path: Path::new("root/file.rs").into(),
+            max_row: 200,
+            excerpts: vec![
+                excerpt(0..50, 8, ContextSource::GitLog),
+                excerpt(50..60, 2, ContextSource::OracleSnippet),
+                excerpt(60..200, 8, ContextSource::GitLog),
+            ],
+            in_open_source_repo: false,
+        }];
+
+        // Budget fits the snippet but not the surrounding segments.
+        let limited = limit_retrieved_context_to_bytes(&related_files, 20 * line.len());
+        assert_eq!(limited.len(), 1);
+        assert_eq!(limited[0].excerpts.len(), 1);
+        assert_eq!(limited[0].excerpts[0].row_range, 50..60);
+        assert_eq!(
+            limited[0].excerpts[0].context_source,
+            ContextSource::OracleSnippet
+        );
     }
 }
diff --git a/crates/edit_prediction_context/src/fake_definition_lsp.rs b/crates/edit_prediction_context/src/fake_definition_lsp.rs
index 5b9e528b63f670..d844597ef50059 100644
--- a/crates/edit_prediction_context/src/fake_definition_lsp.rs
+++ b/crates/edit_prediction_context/src/fake_definition_lsp.rs
@@ -243,6 +243,12 @@ impl DefinitionIndex {
                 .or_insert_with(Vec::new)
                 .push(location);
         }
+        for (name, location) in extract_extra_definition_locations(content) {
+            self.definitions
+                .entry(name)
+                .or_insert_with(Vec::new)
+                .push(location);
+        }
 
         let type_annotations = extract_type_annotations(content)
             .into_iter()
@@ -303,6 +309,34 @@ impl DefinitionIndex {
     }
 }
 
+fn extract_extra_definition_locations(content: &str) -> Vec<(String, lsp::Location)> {
+    content
+        .lines()
+        .filter_map(|line| {
+            let mut parts = line
+                .trim()
+                .strip_prefix("// fake-definition-lsp-extra ")?
+                .split_whitespace();
+            let name = parts.next()?.to_string();
+            let path = PathBuf::from(parts.next()?);
+            let start_row = parts.next()?.parse().ok()?;
+            let start_column = parts.next()?.parse().ok()?;
+            let end_row = parts.next()?.parse().ok()?;
+            let end_column = parts.next()?.parse().ok()?;
+            Some((
+                name,
+                lsp::Location::new(
+                    Uri::from_file_path(path).ok()?,
+                    lsp::Range::new(
+                        lsp::Position::new(start_row, start_column),
+                        lsp::Position::new(end_row, end_column),
+                    ),
+                ),
+            ))
+        })
+        .collect()
+}
+
 /// Extracts `identifier_name -> type_name` mappings from field declarations
 /// and function parameters. For example, `owner: Arc` produces
 /// `"owner" -> "Person"` by unwrapping common generic wrappers.
diff --git a/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs b/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs
index 205075e2f5b36e..62b8207c420e36 100644
--- a/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs
+++ b/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs
@@ -9,7 +9,10 @@ mod tokenize;
 #[cfg(feature = "tree-sitter")]
 mod tree_sitter;
 
-pub use jumps::{EditableContextCoverage, Excerpt, editable_context_coverage};
+pub use jumps::{
+    EditableContextCoverage, Excerpt, LineFileClassification, PatchLocationMatch,
+    editable_context_coverage, patch_location_match,
+};
 pub use kept_rate::AnnotatedToken;
 pub use kept_rate::KeptRateResult;
 pub use kept_rate::TokenAnnotation;
diff --git a/crates/edit_prediction_metrics/src/jumps.rs b/crates/edit_prediction_metrics/src/jumps.rs
index 8cfeafcc97aade..607cfab5949a44 100644
--- a/crates/edit_prediction_metrics/src/jumps.rs
+++ b/crates/edit_prediction_metrics/src/jumps.rs
@@ -4,6 +4,7 @@ use std::{
 };
 
 use crate::patch::{Patch, PatchLine};
+use crate::patch_metrics::ClassificationMetrics;
 
 const LINE_RELEVANCE_WINDOW: u32 = 20;
 
@@ -14,8 +15,13 @@ pub struct Excerpt {
     pub content: String,
 }
 
+/// Line- and file-level precision/recall/F1 over TP/FP/FN counts.
+///
+/// Shared shape for two metrics with the same structure: how much expected
+/// edit context was retrieved (`EditableContextCoverage`) and how well
+/// predicted edit locations match expected ones (`PatchLocationMatch`).
 #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
-pub struct EditableContextCoverage {
+pub struct LineFileClassification {
     pub lines_tp: usize,
     pub lines_fp: usize,
     pub lines_fn: usize,
@@ -31,7 +37,10 @@ pub struct EditableContextCoverage {
     pub files_f1: f64,
 }
 
-impl EditableContextCoverage {
+pub type EditableContextCoverage = LineFileClassification;
+pub type PatchLocationMatch = LineFileClassification;
+
+impl LineFileClassification {
     pub fn new(
         lines_tp: usize,
         lines_fp: usize,
@@ -55,6 +64,43 @@ impl EditableContextCoverage {
             files_f1: f1(files_tp, files_fp, files_fn),
         }
     }
+
+    pub fn lines_counts(&self) -> ClassificationMetrics {
+        ClassificationMetrics {
+            true_positives: self.lines_tp,
+            false_positives: self.lines_fp,
+            false_negatives: self.lines_fn,
+        }
+    }
+
+    pub fn files_counts(&self) -> ClassificationMetrics {
+        ClassificationMetrics {
+            true_positives: self.files_tp,
+            false_positives: self.files_fp,
+            false_negatives: self.files_fn,
+        }
+    }
+}
+
+/// Measures how well an actual patch's edited file/line locations match an expected patch.
+pub fn patch_location_match(expected_patch: &str, actual_patch: &str) -> PatchLocationMatch {
+    let expected_patch = Patch::parse_unified_diff(expected_patch);
+    let actual_patch = Patch::parse_unified_diff(actual_patch);
+    let (expected_files, expected_anchor_lines, _) = expected_context(&expected_patch);
+    let (actual_files, mut actual_anchor_lines, _) = expected_context(&actual_patch);
+
+    normalize_actual_paths(
+        &expected_files,
+        &mut actual_anchor_lines,
+        actual_files.iter().cloned().collect(),
+    );
+    let actual_files = actual_anchor_lines.keys().cloned().collect();
+
+    let (lines_tp, lines_fp, lines_fn) =
+        match_anchor_rows(&expected_anchor_lines, &actual_anchor_lines);
+    let (files_tp, files_fp, files_fn) = classify_values(&expected_files, &actual_files);
+
+    PatchLocationMatch::new(lines_tp, lines_fp, lines_fn, files_tp, files_fp, files_fn)
 }
 
 /// Measures how much expected edit context was retrieved and how much unrelated context was retrieved.
@@ -201,6 +247,87 @@ fn retrieved_context(context: &[Excerpt]) -> (BTreeSet, BTreeMap,
+    actual_anchor_lines: &mut BTreeMap>,
+    actual_files: BTreeSet,
+) {
+    let mut normalized = BTreeMap::new();
+
+    for actual_path in actual_files {
+        let normalized_path = normalize_actual_path(expected_files, &actual_path);
+        if let Some(rows) = actual_anchor_lines.remove(&actual_path) {
+            normalized
+                .entry(normalized_path)
+                .or_insert_with(BTreeSet::new)
+                .extend(rows);
+        }
+    }
+
+    *actual_anchor_lines = normalized;
+}
+
+fn normalize_actual_path(expected_files: &BTreeSet, actual_path: &str) -> String {
+    if expected_files.contains(actual_path) {
+        return actual_path.to_string();
+    }
+
+    if let Some(stripped_path) = strip_first_path_component(actual_path) {
+        if expected_files.contains(stripped_path) {
+            return stripped_path.to_string();
+        }
+    }
+
+    actual_path.to_string()
+}
+
+fn strip_first_path_component(path: &str) -> Option<&str> {
+    path.split_once('/')
+        .map(|(_, rest)| rest)
+        .filter(|rest| !rest.is_empty())
+}
+
+fn match_anchor_rows(
+    expected: &BTreeMap>,
+    actual: &BTreeMap>,
+) -> (usize, usize, usize) {
+    let mut true_positives = 0;
+    let mut false_positives = 0;
+    let mut false_negatives = 0;
+    let paths = expected
+        .keys()
+        .chain(actual.keys())
+        .cloned()
+        .collect::>();
+
+    for path in paths {
+        let expected_rows = expected.get(&path).cloned().unwrap_or_default();
+        let actual_rows = actual.get(&path).cloned().unwrap_or_default();
+        let matched = match_rows_with_window(&expected_rows, &actual_rows);
+        true_positives += matched;
+        false_positives += actual_rows.len().saturating_sub(matched);
+        false_negatives += expected_rows.len().saturating_sub(matched);
+    }
+
+    (true_positives, false_positives, false_negatives)
+}
+
+fn match_rows_with_window(expected: &BTreeSet, actual: &BTreeSet) -> usize {
+    let mut unmatched_expected = expected.clone();
+    let mut matched = 0;
+
+    for actual_row in actual {
+        let start = actual_row.saturating_sub(LINE_RELEVANCE_WINDOW);
+        let end = actual_row.saturating_add(LINE_RELEVANCE_WINDOW);
+        if let Some(expected_row) = unmatched_expected.range(start..=end).next().copied() {
+            unmatched_expected.remove(&expected_row);
+            matched += 1;
+        }
+    }
+
+    matched
+}
+
 fn insert_anchor_row(
     anchor_lines_by_file: &mut BTreeMap>,
     relevant_lines_by_file: &mut BTreeMap>,
@@ -502,4 +629,70 @@ mod tests {
 
         assert_eq!(score, EditableContextCoverage::new(0, 0, 0, 0, 0, 0));
     }
+
+    #[test]
+    fn patch_location_match_counts_file_and_nearby_line_matches() {
+        let expected = indoc! {"
+            --- a/src/main.rs
+            +++ b/src/main.rs
+            @@ -50,1 +50,1 @@
+            -let value = 1;
+            +let value = 2;
+        "};
+        let actual = indoc! {"
+            --- a/src/main.rs
+            +++ b/src/main.rs
+            @@ -55,1 +55,1 @@
+            -let value = 1;
+            +let value = 2;
+        "};
+
+        let score = patch_location_match(expected, actual);
+
+        assert_eq!(score, PatchLocationMatch::new(1, 0, 0, 1, 0, 0));
+    }
+
+    #[test]
+    fn patch_location_match_counts_missing_and_extra_files() {
+        let expected = indoc! {"
+            --- a/src/main.rs
+            +++ b/src/main.rs
+            @@ -1,1 +1,1 @@
+            -let value = 1;
+            +let value = 2;
+        "};
+        let actual = indoc! {"
+            --- a/src/lib.rs
+            +++ b/src/lib.rs
+            @@ -1,1 +1,1 @@
+            -let value = 1;
+            +let value = 2;
+        "};
+
+        let score = patch_location_match(expected, actual);
+
+        assert_eq!(score, PatchLocationMatch::new(0, 1, 1, 0, 1, 1));
+    }
+
+    #[test]
+    fn patch_location_match_normalizes_project_prefixed_actual_path() {
+        let expected = indoc! {"
+            --- a/src/main.rs
+            +++ b/src/main.rs
+            @@ -1,1 +1,1 @@
+            -let value = 1;
+            +let value = 2;
+        "};
+        let actual = indoc! {"
+            --- a/project/src/main.rs
+            +++ b/project/src/main.rs
+            @@ -1,1 +1,1 @@
+            -let value = 1;
+            +let value = 2;
+        "};
+
+        let score = patch_location_match(expected, actual);
+
+        assert_eq!(score, PatchLocationMatch::new(1, 0, 0, 1, 0, 0));
+    }
 }
diff --git a/crates/edit_prediction_metrics/src/prediction_score.rs b/crates/edit_prediction_metrics/src/prediction_score.rs
index 684a93ba9de631..041d19ae7711f9 100644
--- a/crates/edit_prediction_metrics/src/prediction_score.rs
+++ b/crates/edit_prediction_metrics/src/prediction_score.rs
@@ -1,4 +1,5 @@
 use serde::{Deserialize, Serialize};
+use std::collections::{BTreeMap, BTreeSet};
 use std::error::Error;
 use std::fmt;
 use std::path::Path;
@@ -7,7 +8,11 @@ use zeta_prompt::udiff::{apply_diff_to_string, apply_diff_to_string_with_hunk_of
 
 use crate::reversal::compute_prediction_reversal_ratio_from_history;
 use crate::{
-    jumps::{EditableContextCoverage, Excerpt, editable_context_coverage},
+    jumps::{
+        EditableContextCoverage, Excerpt, PatchLocationMatch, editable_context_coverage,
+        patch_location_match,
+    },
+    patch::{Hunk, Patch, PatchLine},
     patch_metrics::{
         ClassificationMetrics, DeltaChrFMetrics, braces_disbalance, count_patch_token_changes,
         delta_chr_f, delta_chr_f_beta, exact_lines_match, has_isolated_whitespace_changes,
@@ -66,6 +71,8 @@ pub struct PredictionScore {
     pub avg_logprob: Option,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub editable_context_coverage: Option,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub jump_location: Option,
 }
 
 impl PredictionScore {
@@ -97,6 +104,7 @@ impl PredictionScore {
             cumulative_logprob: None,
             avg_logprob: None,
             editable_context_coverage: None,
+            jump_location: None,
         }
     }
 
@@ -215,51 +223,164 @@ pub fn score_prediction(input: PredictionScoringInput<'_>) -> PredictionScore {
             })
     });
 
-    let Some(actual_patch) = input.actual_patch else {
-        let mut score = PredictionScore::zero();
-        score.editable_context_coverage = editable_context_coverage;
-        return score;
-    };
-
+    let actual_patch = input.actual_patch.unwrap_or("");
     let token_changes = count_patch_token_changes(actual_patch);
 
-    let actual_text = match apply_diff_to_string(actual_patch, input.original_text) {
-        Ok(text) => text,
-        Err(_) => {
-            let mut score = PredictionScore::zero();
-            score.inserted_tokens = token_changes.inserted_tokens;
-            score.deleted_tokens = token_changes.deleted_tokens;
-            score.editable_context_coverage = editable_context_coverage;
-            return score;
-        }
+    let mut best = input
+        .expected_patches
+        .iter()
+        .map(|expected| score_against_expected_patch(input, expected, actual_patch))
+        .max_by(|left, right| {
+            left.delta_chr_f_metrics
+                .score
+                .total_cmp(&right.delta_chr_f_metrics.score)
+                .then_with(|| left.exact_lines.f1().total_cmp(&right.exact_lines.f1()))
+                .then_with(|| {
+                    left.jump_location
+                        .lines_f1
+                        .total_cmp(&right.jump_location.lines_f1)
+                })
+        })
+        .unwrap_or_else(|| score_against_no_expected_patch(input, actual_patch));
+
+    let (cursor_distance, cursor_exact_match) =
+        compute_cursor_metrics(best.expected_cursor, input.actual_cursor);
+
+    let wrong_editable_region = input
+        .actual_patch
+        .map(|actual_patch| !is_editable_region_correct(actual_patch));
+    let has_isolated_whitespace_changes = input.actual_patch.is_some_and(|actual_patch| {
+        has_isolated_whitespace_changes(actual_patch, input.actual_cursor.map(|cursor| cursor.row))
+    });
+
+    best.score.cumulative_logprob = input.cumulative_logprob;
+    best.score.avg_logprob = input.avg_logprob;
+    best.score.editable_context_coverage = editable_context_coverage;
+    best.score.inserted_tokens = token_changes.inserted_tokens;
+    best.score.deleted_tokens = token_changes.deleted_tokens;
+    best.score.cursor_distance = cursor_distance;
+    best.score.cursor_exact_match = cursor_exact_match;
+    best.score.wrong_editable_region = wrong_editable_region;
+    best.score.has_isolated_whitespace_changes = has_isolated_whitespace_changes;
+    best.score
+}
+
+struct ExpectedPatchScore {
+    score: PredictionScore,
+    delta_chr_f_metrics: DeltaChrFMetrics,
+    exact_lines: ClassificationMetrics,
+    jump_location: PatchLocationMatch,
+    expected_cursor: Option,
+}
+
+struct ContentScore {
+    delta_chr_f_metrics: DeltaChrFMetrics,
+    braces_disbalance: usize,
+    reversal_ratio: f32,
+    kept_rate: Option,
+    recall_rate: Option,
+    kept_chars: Option,
+    correctly_deleted_chars: Option,
+    discarded_chars: Option,
+}
+
+fn score_against_expected_patch(
+    input: PredictionScoringInput<'_>,
+    expected: &PreparedExpectedPatch,
+    actual_patch: &str,
+) -> ExpectedPatchScore {
+    let exact_lines = exact_lines_match(&expected.patch, actual_patch);
+    let jump_location = patch_location_match(&expected.patch, actual_patch);
+    let content_score = if let Some(context) = input.context {
+        score_content_on_context(input, expected, actual_patch, context).unwrap_or_else(|| {
+            if expected.patch.trim().is_empty() && actual_patch.trim().is_empty() {
+                score_content_on_cursor_excerpt(input, expected, actual_patch)
+            } else {
+                zero_content_score()
+            }
+        })
+    } else {
+        score_content_on_cursor_excerpt(input, expected, actual_patch)
+    };
+    let delta_chr_f_metrics = content_score.delta_chr_f_metrics.clone();
+
+    let score = PredictionScore {
+        delta_chr_f: delta_chr_f_metrics.score as f32,
+        delta_chr_f_true_positives: delta_chr_f_metrics.counts.true_positives,
+        delta_chr_f_false_positives: delta_chr_f_metrics.counts.false_positives,
+        delta_chr_f_false_negatives: delta_chr_f_metrics.counts.false_negatives,
+        delta_chr_f_precision: delta_chr_f_metrics.precision,
+        delta_chr_f_recall: delta_chr_f_metrics.recall,
+        delta_chr_f_beta: delta_chr_f_metrics.beta,
+        braces_disbalance: content_score.braces_disbalance,
+        exact_lines_tp: exact_lines.true_positives,
+        exact_lines_fp: exact_lines.false_positives,
+        exact_lines_fn: exact_lines.false_negatives,
+        reversal_ratio: content_score.reversal_ratio,
+        cursor_distance: None,
+        cursor_exact_match: None,
+        wrong_editable_region: None,
+        has_isolated_whitespace_changes: false,
+        inserted_tokens: 0,
+        deleted_tokens: 0,
+        kept_rate: content_score.kept_rate,
+        recall_rate: content_score.recall_rate,
+        kept_chars: content_score.kept_chars,
+        correctly_deleted_chars: content_score.correctly_deleted_chars,
+        discarded_chars: content_score.discarded_chars,
+        cumulative_logprob: None,
+        avg_logprob: None,
+        editable_context_coverage: None,
+        jump_location: Some(jump_location.clone()),
     };
 
-    let mut best_delta_chr_f_metrics = DeltaChrFMetrics::default();
-    let mut best_expected_cursor = None;
-    let mut best_expected_text = None;
-
-    for expected in input.expected_patches {
-        let delta_chr_f_metrics = delta_chr_f(input.original_text, &expected.text, &actual_text);
-        if best_expected_text.is_none()
-            || delta_chr_f_metrics.score > best_delta_chr_f_metrics.score
-        {
-            best_delta_chr_f_metrics = delta_chr_f_metrics;
-            best_expected_cursor = expected.cursor_editable_region_offset;
-            best_expected_text = Some(expected.text.as_str());
-        }
+    ExpectedPatchScore {
+        score,
+        delta_chr_f_metrics,
+        exact_lines,
+        jump_location,
+        expected_cursor: expected.cursor_editable_region_offset,
     }
+}
 
-    let disbalance_before = braces_disbalance(input.original_text);
-    let disbalance_after = braces_disbalance(&actual_text);
-    let braces_disbalance = disbalance_after.saturating_sub(disbalance_before);
+fn score_against_no_expected_patch(
+    input: PredictionScoringInput<'_>,
+    actual_patch: &str,
+) -> ExpectedPatchScore {
+    let expected = PreparedExpectedPatch {
+        patch: String::new(),
+        text: input.original_text.to_string(),
+        cursor_editable_region_offset: None,
+    };
+    score_against_expected_patch(input, &expected, actual_patch)
+}
 
-    let best_exact_lines = input
-        .expected_patches
-        .iter()
-        .map(|expected| exact_lines_match(&expected.patch, actual_patch))
-        .max_by_key(|metrics| metrics.true_positives)
-        .unwrap_or_default();
+fn zero_content_score() -> ContentScore {
+    ContentScore {
+        delta_chr_f_metrics: DeltaChrFMetrics {
+            beta: delta_chr_f_beta(),
+            ..DeltaChrFMetrics::default()
+        },
+        braces_disbalance: 0,
+        reversal_ratio: 0.0,
+        kept_rate: None,
+        recall_rate: None,
+        kept_chars: None,
+        correctly_deleted_chars: None,
+        discarded_chars: None,
+    }
+}
 
+fn score_content_on_cursor_excerpt(
+    input: PredictionScoringInput<'_>,
+    expected: &PreparedExpectedPatch,
+    actual_patch: &str,
+) -> ContentScore {
+    let actual_text = apply_diff_to_string(actual_patch, input.original_text)
+        .unwrap_or_else(|_| input.original_text.to_string());
+    let delta_chr_f_metrics = delta_chr_f(input.original_text, &expected.text, &actual_text);
+    let braces_disbalance =
+        braces_disbalance(&actual_text).saturating_sub(braces_disbalance(input.original_text));
     let reversal_ratio = input
         .reversal_context
         .map(|context| {
@@ -272,60 +393,222 @@ pub fn score_prediction(input: PredictionScoringInput<'_>) -> PredictionScore {
             )
         })
         .unwrap_or(0.0);
+    let kept_rate =
+        crate::kept_rate::compute_kept_rate(input.original_text, &actual_text, &expected.text);
 
-    let (cursor_distance, cursor_exact_match) =
-        compute_cursor_metrics(best_expected_cursor, input.actual_cursor);
-
-    let wrong_editable_region = Some(!is_editable_region_correct(actual_patch));
-    let has_isolated_whitespace_changes =
-        has_isolated_whitespace_changes(actual_patch, input.actual_cursor.map(|cursor| cursor.row));
-
-    let (kept_rate, recall_rate, kept_chars, correctly_deleted_chars, discarded_chars) =
-        best_expected_text
-            .map(|reference_text| {
-                let result = crate::kept_rate::compute_kept_rate(
-                    input.original_text,
-                    &actual_text,
-                    reference_text,
-                );
-                (
-                    Some(result.kept_rate),
-                    Some(result.recall_rate),
-                    Some(result.kept_chars),
-                    Some(result.correctly_deleted_chars),
-                    Some(result.discarded_chars),
-                )
-            })
-            .unwrap_or((None, None, None, None, None));
-
-    PredictionScore {
-        delta_chr_f: best_delta_chr_f_metrics.score as f32,
-        delta_chr_f_true_positives: best_delta_chr_f_metrics.counts.true_positives,
-        delta_chr_f_false_positives: best_delta_chr_f_metrics.counts.false_positives,
-        delta_chr_f_false_negatives: best_delta_chr_f_metrics.counts.false_negatives,
-        delta_chr_f_precision: best_delta_chr_f_metrics.precision,
-        delta_chr_f_recall: best_delta_chr_f_metrics.recall,
-        delta_chr_f_beta: best_delta_chr_f_metrics.beta,
+    ContentScore {
+        delta_chr_f_metrics,
         braces_disbalance,
-        exact_lines_tp: best_exact_lines.true_positives,
-        exact_lines_fp: best_exact_lines.false_positives,
-        exact_lines_fn: best_exact_lines.false_negatives,
         reversal_ratio,
-        cursor_distance,
-        cursor_exact_match,
-        wrong_editable_region,
-        has_isolated_whitespace_changes,
-        inserted_tokens: token_changes.inserted_tokens,
-        deleted_tokens: token_changes.deleted_tokens,
-        kept_rate,
-        recall_rate,
-        kept_chars,
-        correctly_deleted_chars,
-        discarded_chars,
-        cumulative_logprob: input.cumulative_logprob,
-        avg_logprob: input.avg_logprob,
-        editable_context_coverage,
+        kept_rate: Some(kept_rate.kept_rate),
+        recall_rate: Some(kept_rate.recall_rate),
+        kept_chars: Some(kept_rate.kept_chars),
+        correctly_deleted_chars: Some(kept_rate.correctly_deleted_chars),
+        discarded_chars: Some(kept_rate.discarded_chars),
+    }
+}
+
+fn score_content_on_context(
+    input: PredictionScoringInput<'_>,
+    expected: &PreparedExpectedPatch,
+    actual_patch: &str,
+    context: &[Excerpt],
+) -> Option {
+    let expected_documents = apply_patch_to_documents(&expected.patch, context);
+    let actual_documents = apply_patch_to_documents(actual_patch, context);
+    let document_indices = expected_documents
+        .keys()
+        .chain(actual_documents.keys())
+        .copied()
+        .collect::>();
+
+    if document_indices.is_empty() {
+        return None;
     }
+
+    let mut original_text = String::new();
+    let mut expected_text = String::new();
+    let mut actual_text = String::new();
+    let mut braces_disbalance_before = 0;
+    let mut braces_disbalance_after = 0;
+    let mut cursor_actual_text = None;
+
+    for document_ix in document_indices {
+        let document = context.get(document_ix)?;
+        let expected_document_text = expected_documents
+            .get(&document_ix)
+            .map(String::as_str)
+            .unwrap_or(document.content.as_str());
+        let actual_document_text = actual_documents
+            .get(&document_ix)
+            .map(String::as_str)
+            .unwrap_or(document.content.as_str());
+
+        if !original_text.is_empty() {
+            original_text.push('\n');
+            expected_text.push('\n');
+            actual_text.push('\n');
+        }
+        original_text.push_str(&document.content);
+        expected_text.push_str(expected_document_text);
+        actual_text.push_str(actual_document_text);
+
+        braces_disbalance_before += braces_disbalance(&document.content);
+        braces_disbalance_after += braces_disbalance(actual_document_text);
+
+        if input.reversal_context.is_some_and(|reversal_context| {
+            path_matches(
+                &reversal_context.cursor_path.to_string_lossy(),
+                &document.path,
+            )
+        }) {
+            cursor_actual_text = Some(actual_document_text.to_string());
+        }
+    }
+
+    let delta_chr_f_metrics = delta_chr_f(&original_text, &expected_text, &actual_text);
+    let kept_rate =
+        crate::kept_rate::compute_kept_rate(&original_text, &actual_text, &expected_text);
+    let reversal_ratio = if let (Some(reversal_context), Some(cursor_actual_text)) =
+        (input.reversal_context, cursor_actual_text.as_deref())
+    {
+        compute_prediction_reversal_ratio_from_history(
+            input.original_text,
+            reversal_context.edit_history,
+            reversal_context.excerpt_start_row,
+            cursor_actual_text,
+            reversal_context.cursor_path,
+        )
+    } else {
+        0.0
+    };
+
+    Some(ContentScore {
+        delta_chr_f_metrics,
+        braces_disbalance: braces_disbalance_after.saturating_sub(braces_disbalance_before),
+        reversal_ratio,
+        kept_rate: Some(kept_rate.kept_rate),
+        recall_rate: Some(kept_rate.recall_rate),
+        kept_chars: Some(kept_rate.kept_chars),
+        correctly_deleted_chars: Some(kept_rate.correctly_deleted_chars),
+        discarded_chars: Some(kept_rate.discarded_chars),
+    })
+}
+
+fn apply_patch_to_documents(patch: &str, context: &[Excerpt]) -> BTreeMap {
+    let patch = Patch::parse_unified_diff(patch);
+    let mut hunks_by_document: BTreeMap> = BTreeMap::new();
+
+    for hunk in patch.hunks.into_iter().filter(hunk_has_change) {
+        if let Some(document_ix) = find_hunk_document(&hunk, context) {
+            hunks_by_document.entry(document_ix).or_default().push(hunk);
+        }
+    }
+
+    hunks_by_document
+        .into_iter()
+        .filter_map(|(document_ix, hunks)| {
+            let document = context.get(document_ix)?;
+            let document_patch = diff_for_document_hunks(document, &hunks);
+            let text = apply_diff_to_string(&document_patch, &document.content).ok()?;
+            Some((document_ix, text))
+        })
+        .collect()
+}
+
+fn find_hunk_document(hunk: &Hunk, context: &[Excerpt]) -> Option {
+    context
+        .iter()
+        .enumerate()
+        .find_map(|(document_ix, document)| {
+            if !path_matches(&hunk.filename, &document.path) {
+                return None;
+            }
+
+            let document_patch = diff_for_document_hunks(document, std::slice::from_ref(hunk));
+            apply_diff_to_string(&document_patch, &document.content)
+                .is_ok()
+                .then_some(document_ix)
+        })
+}
+
+fn diff_for_document_hunks(document: &Excerpt, hunks: &[Hunk]) -> String {
+    let mut diff = String::new();
+    diff.push_str(&format!("--- a/{}\n", document.path));
+    diff.push_str(&format!("+++ b/{}\n", document.path));
+
+    for hunk in hunks {
+        let old_start = adjust_hunk_start(hunk.old_start, &document.row_range);
+        let new_start = adjust_hunk_start(hunk.new_start, &document.row_range);
+        let old_count = hunk
+            .lines
+            .iter()
+            .filter(|line| matches!(line, PatchLine::Context(_) | PatchLine::Deletion(_)))
+            .count();
+        let new_count = hunk
+            .lines
+            .iter()
+            .filter(|line| matches!(line, PatchLine::Context(_) | PatchLine::Addition(_)))
+            .count();
+        diff.push_str(&format!(
+            "@@ -{},{} +{},{} @@\n",
+            old_start, old_count, new_start, new_count
+        ));
+        for line in &hunk.lines {
+            match line {
+                PatchLine::Context(text) => {
+                    diff.push(' ');
+                    diff.push_str(text);
+                    diff.push('\n');
+                }
+                PatchLine::Addition(text) => {
+                    diff.push('+');
+                    diff.push_str(text);
+                    diff.push('\n');
+                }
+                PatchLine::Deletion(text) => {
+                    diff.push('-');
+                    diff.push_str(text);
+                    diff.push('\n');
+                }
+                PatchLine::Garbage(text) => {
+                    diff.push_str(text);
+                    diff.push('\n');
+                }
+            }
+        }
+    }
+
+    diff
+}
+
+fn adjust_hunk_start(start: isize, row_range: &std::ops::Range) -> isize {
+    let Ok(start_row) = u32::try_from(start.saturating_sub(1)) else {
+        return start;
+    };
+
+    if row_range.start <= start_row && start_row <= row_range.end {
+        start.saturating_sub(row_range.start as isize)
+    } else {
+        start
+    }
+}
+
+fn hunk_has_change(hunk: &Hunk) -> bool {
+    hunk.lines
+        .iter()
+        .any(|line| matches!(line, PatchLine::Addition(_) | PatchLine::Deletion(_)))
+}
+
+fn path_matches(patch_path: &str, document_path: &str) -> bool {
+    patch_path == document_path
+        || strip_first_path_component(patch_path).is_some_and(|stripped| stripped == document_path)
+}
+
+fn strip_first_path_component(path: &str) -> Option<&str> {
+    path.split_once('/')
+        .map(|(_, rest)| rest)
+        .filter(|rest| !rest.is_empty())
 }
 
 fn compute_cursor_metrics(
@@ -372,4 +655,76 @@ mod tests {
         assert_eq!(score.delta_chr_f, 0.0);
         assert_eq!(score.kept_rate, Some(0.0));
     }
+
+    #[test]
+    fn test_scores_related_file_patch_against_context_document() {
+        let original_text = "fn main() {}\n";
+        let expected_patch = "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -11,3 +11,3 @@\n fn value() {\n-    1\n+    2\n }\n";
+        let actual_patch = "--- a/project/src/lib.rs\n+++ b/project/src/lib.rs\n@@ -11,3 +11,3 @@\n fn value() {\n-    1\n+    2\n }\n";
+        let expected_patches = [PreparedExpectedPatch {
+            patch: expected_patch.to_string(),
+            text: original_text.to_string(),
+            cursor_editable_region_offset: None,
+        }];
+        let context = [
+            Excerpt {
+                path: "src/main.rs".to_string(),
+                row_range: 0..1,
+                content: original_text.to_string(),
+            },
+            Excerpt {
+                path: "src/lib.rs".to_string(),
+                row_range: 10..13,
+                content: "fn value() {\n    1\n}\n".to_string(),
+            },
+        ];
+
+        let score = score_prediction(PredictionScoringInput {
+            original_text,
+            expected_patches: &expected_patches,
+            actual_patch: Some(actual_patch),
+            actual_cursor: None,
+            reversal_context: None,
+            cumulative_logprob: None,
+            avg_logprob: None,
+            context: Some(&context),
+        });
+
+        assert_eq!(score.delta_chr_f, 100.0);
+        assert_eq!(score.exact_lines_tp, 2);
+        assert_eq!(score.jump_location.unwrap().files_f1, 1.0);
+    }
+
+    #[test]
+    fn test_missing_related_file_prediction_counts_as_false_negative() {
+        let original_text = "fn main() {}\n";
+        let expected_patch = "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -11,3 +11,3 @@\n fn value() {\n-    1\n+    2\n }\n";
+        let expected_patches = [PreparedExpectedPatch {
+            patch: expected_patch.to_string(),
+            text: original_text.to_string(),
+            cursor_editable_region_offset: None,
+        }];
+        let context = [Excerpt {
+            path: "src/lib.rs".to_string(),
+            row_range: 10..13,
+            content: "fn value() {\n    1\n}\n".to_string(),
+        }];
+
+        let score = score_prediction(PredictionScoringInput {
+            original_text,
+            expected_patches: &expected_patches,
+            actual_patch: None,
+            actual_cursor: None,
+            reversal_context: None,
+            cumulative_logprob: None,
+            avg_logprob: None,
+            context: Some(&context),
+        });
+
+        assert!(score.delta_chr_f < 100.0);
+        assert_eq!(score.exact_lines_fn, 2);
+        let location = score.jump_location.unwrap();
+        assert_eq!(location.files_fn, 1);
+        assert_eq!(location.lines_fn, 1);
+    }
 }
diff --git a/crates/edit_prediction_metrics/src/reversal.rs b/crates/edit_prediction_metrics/src/reversal.rs
index fb84f57aa4e0d9..00929a2744d6ee 100644
--- a/crates/edit_prediction_metrics/src/reversal.rs
+++ b/crates/edit_prediction_metrics/src/reversal.rs
@@ -4,21 +4,14 @@ use std::path::Path;
 use std::sync::Arc;
 
 use crate::tokenize::tokenize;
-use imara_diff::{
-    Algorithm, diff,
-    intern::{InternedInput, Token},
-    sources::lines_with_terminator,
-};
+use imara_diff::{Algorithm, Diff, InternedInput, Token, sources::lines};
 use zeta_prompt::udiff::apply_diff_to_string;
 
 fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range, Arc)> {
     let empty: Arc = Arc::default();
     let mut edits = Vec::new();
     let mut hunk_input = InternedInput::default();
-    let input = InternedInput::new(
-        lines_with_terminator(old_text),
-        lines_with_terminator(new_text),
-    );
+    let input = InternedInput::new(lines(old_text), lines(new_text));
 
     diff_internal(&input, &mut |old_byte_range,
                                 new_byte_range,
@@ -104,35 +97,34 @@ fn diff_internal(
     let mut old_token_ix = 0;
     let mut new_token_ix = 0;
 
-    diff(
-        Algorithm::Histogram,
-        input,
-        |old_tokens: Range, new_tokens: Range| {
-            old_offset += token_len(
-                input,
-                &input.before[old_token_ix as usize..old_tokens.start as usize],
-            );
-            new_offset += token_len(
-                input,
-                &input.after[new_token_ix as usize..new_tokens.start as usize],
-            );
-            let old_len = token_len(
-                input,
-                &input.before[old_tokens.start as usize..old_tokens.end as usize],
-            );
-            let new_len = token_len(
-                input,
-                &input.after[new_tokens.start as usize..new_tokens.end as usize],
-            );
-            let old_byte_range = old_offset..old_offset + old_len;
-            let new_byte_range = new_offset..new_offset + new_len;
-            old_token_ix = old_tokens.end;
-            new_token_ix = new_tokens.end;
-            old_offset = old_byte_range.end;
-            new_offset = new_byte_range.end;
-            on_change(old_byte_range, new_byte_range, old_tokens, new_tokens);
-        },
-    );
+    let diff = Diff::compute(Algorithm::Histogram, input);
+    for hunk in diff.hunks() {
+        let old_tokens = hunk.before;
+        let new_tokens = hunk.after;
+        old_offset += token_len(
+            input,
+            &input.before[old_token_ix as usize..old_tokens.start as usize],
+        );
+        new_offset += token_len(
+            input,
+            &input.after[new_token_ix as usize..new_tokens.start as usize],
+        );
+        let old_len = token_len(
+            input,
+            &input.before[old_tokens.start as usize..old_tokens.end as usize],
+        );
+        let new_len = token_len(
+            input,
+            &input.after[new_tokens.start as usize..new_tokens.end as usize],
+        );
+        let old_byte_range = old_offset..old_offset + old_len;
+        let new_byte_range = new_offset..new_offset + new_len;
+        old_token_ix = old_tokens.end;
+        new_token_ix = new_tokens.end;
+        old_offset = old_byte_range.end;
+        new_offset = new_byte_range.end;
+        on_change(old_byte_range, new_byte_range, old_tokens, new_tokens);
+    }
 }
 
 fn tokenize_chars(text: &str) -> impl Iterator {
@@ -797,10 +789,10 @@ mod tests {
     use super::*;
     use indoc::indoc;
     use zeta_prompt::udiff::{apply_diff_to_string, unified_diff_with_context};
-    use zeta_prompt::{ExcerptRanges, ZetaPromptInput};
+    use zeta_prompt::{ExcerptRanges, Zeta2PromptInput};
 
     fn compute_prediction_reversal_ratio(
-        prompt_inputs: &ZetaPromptInput,
+        prompt_inputs: &Zeta2PromptInput,
         predicted_content: &str,
         cursor_path: &Path,
     ) -> f32 {
@@ -817,8 +809,8 @@ mod tests {
         content: &str,
         events: Vec>,
         excerpt_start_row: Option,
-    ) -> ZetaPromptInput {
-        ZetaPromptInput {
+    ) -> Zeta2PromptInput {
+        Zeta2PromptInput {
             cursor_path: Arc::from(Path::new("src/test.rs")),
             cursor_excerpt: content.into(),
             cursor_offset_in_excerpt: 0,
@@ -1181,6 +1173,8 @@ mod tests {
                      -old
                      +new"}
                 .into(),
+                old_range: 0..0,
+                new_range: 0..0,
                 predicted: false,
                 in_open_source_repo: true,
             }),
@@ -1192,6 +1186,8 @@ mod tests {
                      -a
                      +b"}
                 .into(),
+                old_range: 0..0,
+                new_range: 0..0,
                 predicted: false,
                 in_open_source_repo: true,
             }),
@@ -1203,6 +1199,8 @@ mod tests {
                      -x
                      +y"}
                 .into(),
+                old_range: 0..0,
+                new_range: 0..0,
                 predicted: false,
                 in_open_source_repo: true,
             }),
@@ -1931,6 +1929,8 @@ mod tests {
                       line2
                  "}
                 .into(),
+                old_range: 0..0,
+                new_range: 0..0,
                 predicted: false,
                 in_open_source_repo: false,
             })],
@@ -1969,6 +1969,8 @@ mod tests {
                       line11
                  "}
                 .into(),
+                old_range: 0..0,
+                new_range: 0..0,
                 predicted: false,
                 in_open_source_repo: false,
             })],
@@ -2029,6 +2031,8 @@ mod tests {
                       line2
                  "}
                 .into(),
+                old_range: 0..0,
+                new_range: 0..0,
                 predicted: false,
                 in_open_source_repo: false,
             })],
@@ -2066,6 +2070,8 @@ mod tests {
                       more_wrong
                  "}
                 .into(),
+                old_range: 0..0,
+                new_range: 0..0,
                 predicted: false,
                 in_open_source_repo: false,
             })],
@@ -2134,6 +2140,8 @@ mod tests {
                           line2
                      "}
                     .into(),
+                    old_range: 0..0,
+                    new_range: 0..0,
                     predicted: false,
                     in_open_source_repo: false,
                 }),
@@ -2147,6 +2155,8 @@ mod tests {
                           line2
                      "}
                     .into(),
+                    old_range: 0..0,
+                    new_range: 0..0,
                     predicted: false,
                     in_open_source_repo: false,
                 }),
diff --git a/crates/edit_prediction_metrics/src/summary.rs b/crates/edit_prediction_metrics/src/summary.rs
index f927a027d7662f..54190d78bc385a 100644
--- a/crates/edit_prediction_metrics/src/summary.rs
+++ b/crates/edit_prediction_metrics/src/summary.rs
@@ -1,5 +1,6 @@
 use serde::Serialize;
 
+use crate::jumps::LineFileClassification;
 use crate::patch_metrics::ClassificationMetrics;
 use crate::prediction_score::PredictionScore;
 
@@ -41,6 +42,8 @@ pub struct SummaryJson {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub cursor_exact_match_rate: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub cursor_exact_matches: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub cursor_avg_distance: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub cursor_total_evaluated: Option,
@@ -48,16 +51,24 @@ pub struct SummaryJson {
     pub wrong_editable_region_rate: Option,
     pub isolated_whitespace_rate: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub isolated_whitespace_count: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub avg_kept_rate: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub kept_rate_examples: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub avg_recall_rate: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub recall_rate_examples: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub total_kept_chars: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub total_correctly_deleted_chars: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub total_discarded_chars: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub editable_context_examples: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub avg_editable_context_lines_precision: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub avg_editable_context_lines_recall: Option,
@@ -82,6 +93,32 @@ pub struct SummaryJson {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub editable_context_files_fn: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub jump_location_examples: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub avg_jump_location_lines_precision: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub avg_jump_location_lines_recall: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub avg_jump_location_lines_f1: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub jump_location_lines_tp: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub jump_location_lines_fp: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub jump_location_lines_fn: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub avg_jump_location_files_precision: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub avg_jump_location_files_recall: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub avg_jump_location_files_f1: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub jump_location_files_tp: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub jump_location_files_fp: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub jump_location_files_fn: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub avg_retrieved_context_bytes: Option,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub total_retrieved_context_bytes: Option,
@@ -89,314 +126,257 @@ pub struct SummaryJson {
     pub retrieved_context_examples: Option,
 }
 
+/// Mean of observed values; `None` when nothing was observed.
+#[derive(Default)]
+struct MeanAggregate {
+    sum: f64,
+    count: usize,
+}
+
+impl MeanAggregate {
+    fn add(&mut self, value: f64) {
+        self.sum += value;
+        self.count += 1;
+    }
+
+    fn mean(&self) -> Option {
+        (self.count > 0).then(|| self.sum / self.count as f64)
+    }
+
+    fn mean_f32(&self) -> Option {
+        self.mean().map(|mean| mean as f32)
+    }
+
+    fn count(&self) -> Option {
+        (self.count > 0).then_some(self.count)
+    }
+}
+
+/// Fraction of observations that were hits; `None` when nothing was observed.
+#[derive(Default)]
+struct RateAggregate {
+    hits: usize,
+    total: usize,
+}
+
+impl RateAggregate {
+    fn add(&mut self, hit: bool) {
+        self.total += 1;
+        if hit {
+            self.hits += 1;
+        }
+    }
+
+    fn rate(&self) -> Option {
+        (self.total > 0).then(|| self.hits as f32 / self.total as f32)
+    }
+
+    fn hits(&self) -> Option {
+        (self.total > 0).then_some(self.hits)
+    }
+
+    fn total(&self) -> Option {
+        (self.total > 0).then_some(self.total)
+    }
+}
+
+/// Sum of observed values; `None` when nothing was observed.
+#[derive(Default)]
+struct SumAggregate {
+    sum: usize,
+    count: usize,
+}
+
+impl SumAggregate {
+    fn add(&mut self, value: usize) {
+        self.sum += value;
+        self.count += 1;
+    }
+
+    fn total(&self) -> Option {
+        (self.count > 0).then_some(self.sum)
+    }
+
+    fn mean(&self) -> Option {
+        (self.count > 0).then(|| self.sum as f64 / self.count as f64)
+    }
+
+    fn count(&self) -> Option {
+        (self.count > 0).then_some(self.count)
+    }
+}
+
+/// Macro-averaged precision/recall/F1 plus pooled (micro) TP/FP/FN counts.
+#[derive(Default)]
+struct PrfAggregate {
+    count: usize,
+    precision_sum: f64,
+    recall_sum: f64,
+    f1_sum: f64,
+    counts: ClassificationMetrics,
+}
+
+impl PrfAggregate {
+    fn add(&mut self, precision: f64, recall: f64, f1: f64, counts: &ClassificationMetrics) {
+        self.count += 1;
+        self.precision_sum += precision;
+        self.recall_sum += recall;
+        self.f1_sum += f1;
+        self.counts.accumulate(counts);
+    }
+
+    fn avg_precision(&self) -> Option {
+        (self.count > 0).then(|| self.precision_sum / self.count as f64)
+    }
+
+    fn avg_recall(&self) -> Option {
+        (self.count > 0).then(|| self.recall_sum / self.count as f64)
+    }
+
+    fn avg_f1(&self) -> Option {
+        (self.count > 0).then(|| self.f1_sum / self.count as f64)
+    }
+
+    fn true_positives(&self) -> Option {
+        (self.count > 0).then_some(self.counts.true_positives)
+    }
+
+    fn false_positives(&self) -> Option {
+        (self.count > 0).then_some(self.counts.false_positives)
+    }
+
+    fn false_negatives(&self) -> Option {
+        (self.count > 0).then_some(self.counts.false_negatives)
+    }
+
+    fn count(&self) -> Option {
+        (self.count > 0).then_some(self.count)
+    }
+}
+
+/// Aggregates the line- and file-level halves of a [`LineFileClassification`].
+#[derive(Default)]
+struct LineFileAggregate {
+    lines: PrfAggregate,
+    files: PrfAggregate,
+}
+
+impl LineFileAggregate {
+    fn add(&mut self, classification: &LineFileClassification) {
+        self.lines.add(
+            classification.lines_precision,
+            classification.lines_recall,
+            classification.lines_f1,
+            &classification.lines_counts(),
+        );
+        self.files.add(
+            classification.files_precision,
+            classification.files_recall,
+            classification.files_f1,
+            &classification.files_counts(),
+        );
+    }
+
+    fn count(&self) -> Option {
+        self.lines.count()
+    }
+}
+
 pub fn compute_summary<'a>(
     predictions: impl IntoIterator>,
 ) -> SummaryJson {
-    let mut all_delta_chr_f_scores = Vec::new();
-    let mut all_reversal_ratios = Vec::new();
+    let mut total_scores: usize = 0;
+    let mut delta_chr_f_sum: f32 = 0.0;
+    let mut reversal_ratio_sum: f32 = 0.0;
     let mut braces_disbalance_sum: usize = 0;
     let mut total_delta_chr_f = ClassificationMetrics::default();
-    let mut total_delta_chr_f_precision = 0.0;
-    let mut total_delta_chr_f_recall = 0.0;
+    let mut delta_chr_f_precision_sum = 0.0;
+    let mut delta_chr_f_recall_sum = 0.0;
     let mut delta_chr_f_beta = 0.0;
     let mut total_exact_lines = ClassificationMetrics::default();
-    let mut total_scores: usize = 0;
-    let mut qa_reverts_count: usize = 0;
-    let mut qa_reverts_total: usize = 0;
-    let mut qa_confidence_sum: u64 = 0;
-    let mut qa_confidence_count: usize = 0;
-    let mut cursor_exact_matches: usize = 0;
-    let mut cursor_total: usize = 0;
-    let mut cursor_distance_sum: usize = 0;
-    let mut cursor_distance_count: usize = 0;
-    let mut wrong_editable_region_count: usize = 0;
-    let mut wrong_editable_region_total: usize = 0;
-    let mut isolated_whitespace_count: usize = 0;
-    let mut kept_rate_sum: f64 = 0.0;
-    let mut kept_rate_count: usize = 0;
-    let mut kept_chars_total: usize = 0;
-    let mut kept_chars_count: usize = 0;
-    let mut correctly_deleted_chars_total: usize = 0;
-    let mut correctly_deleted_chars_count: usize = 0;
-    let mut discarded_chars_total: usize = 0;
-    let mut discarded_chars_count: usize = 0;
-    let mut recall_rate_sum: f64 = 0.0;
-    let mut recall_rate_count: usize = 0;
-    let mut editable_context_lines_precision_sum: f64 = 0.0;
-    let mut editable_context_lines_recall_sum: f64 = 0.0;
-    let mut editable_context_lines_f1_sum: f64 = 0.0;
-    let mut editable_context_files_precision_sum: f64 = 0.0;
-    let mut editable_context_files_recall_sum: f64 = 0.0;
-    let mut editable_context_files_f1_sum: f64 = 0.0;
-    let mut editable_context_coverage_count: usize = 0;
-    let mut editable_context_lines_tp: usize = 0;
-    let mut editable_context_lines_fp: usize = 0;
-    let mut editable_context_lines_fn: usize = 0;
-    let mut editable_context_files_tp: usize = 0;
-    let mut editable_context_files_fp: usize = 0;
-    let mut editable_context_files_fn: usize = 0;
-    let mut retrieved_context_bytes_total: usize = 0;
-    let mut retrieved_context_bytes_count: usize = 0;
+    let mut qa_reverts = RateAggregate::default();
+    let mut qa_confidence = MeanAggregate::default();
+    let mut cursor_exact = RateAggregate::default();
+    let mut cursor_distance = MeanAggregate::default();
+    let mut wrong_editable_region = RateAggregate::default();
+    let mut isolated_whitespace = RateAggregate::default();
+    let mut kept_rate = MeanAggregate::default();
+    let mut recall_rate = MeanAggregate::default();
+    let mut kept_chars = SumAggregate::default();
+    let mut correctly_deleted_chars = SumAggregate::default();
+    let mut discarded_chars = SumAggregate::default();
+    let mut editable_context = LineFileAggregate::default();
+    let mut jump_location = LineFileAggregate::default();
+    let mut retrieved_context_bytes = SumAggregate::default();
 
     for prediction in predictions {
         let score = prediction.score;
 
-        all_delta_chr_f_scores.push(score.delta_chr_f);
-        all_reversal_ratios.push(score.reversal_ratio);
         total_scores += 1;
+        delta_chr_f_sum += score.delta_chr_f;
+        reversal_ratio_sum += score.reversal_ratio;
         braces_disbalance_sum += score.braces_disbalance;
         total_delta_chr_f.accumulate(&score.delta_chr_f_counts());
-        total_delta_chr_f_precision += score.delta_chr_f_precision;
-        total_delta_chr_f_recall += score.delta_chr_f_recall;
+        delta_chr_f_precision_sum += score.delta_chr_f_precision;
+        delta_chr_f_recall_sum += score.delta_chr_f_recall;
         delta_chr_f_beta = score.delta_chr_f_beta;
         total_exact_lines.accumulate(&score.exact_lines_counts());
 
         if let Some(qa) = prediction.qa {
             if let Some(reverts) = qa.reverts_edits {
-                qa_reverts_total += 1;
-                if reverts {
-                    qa_reverts_count += 1;
-                }
+                qa_reverts.add(reverts);
             }
             if let Some(confidence) = qa.confidence {
-                qa_confidence_sum += confidence as u64;
-                qa_confidence_count += 1;
+                qa_confidence.add(confidence as f64);
             }
         }
 
         if let Some(wrong) = score.wrong_editable_region {
-            wrong_editable_region_total += 1;
-            if wrong {
-                wrong_editable_region_count += 1;
-            }
+            wrong_editable_region.add(wrong);
         }
+        isolated_whitespace.add(score.has_isolated_whitespace_changes);
 
-        if score.has_isolated_whitespace_changes {
-            isolated_whitespace_count += 1;
+        if let Some(value) = score.kept_rate {
+            kept_rate.add(value);
         }
-
-        if let Some(kept_rate) = score.kept_rate {
-            kept_rate_sum += kept_rate;
-            kept_rate_count += 1;
-        }
-        if let Some(kept_chars) = score.kept_chars {
-            kept_chars_total += kept_chars;
-            kept_chars_count += 1;
+        if let Some(value) = score.recall_rate {
+            recall_rate.add(value);
         }
-        if let Some(correctly_deleted_chars) = score.correctly_deleted_chars {
-            correctly_deleted_chars_total += correctly_deleted_chars;
-            correctly_deleted_chars_count += 1;
+        if let Some(value) = score.kept_chars {
+            kept_chars.add(value);
         }
-        if let Some(discarded_chars) = score.discarded_chars {
-            discarded_chars_total += discarded_chars;
-            discarded_chars_count += 1;
+        if let Some(value) = score.correctly_deleted_chars {
+            correctly_deleted_chars.add(value);
         }
-        if let Some(recall_rate) = score.recall_rate {
-            recall_rate_sum += recall_rate;
-            recall_rate_count += 1;
+        if let Some(value) = score.discarded_chars {
+            discarded_chars.add(value);
         }
-        if let Some(retrieved_context_bytes) = prediction.retrieved_context_bytes {
-            retrieved_context_bytes_total += retrieved_context_bytes;
-            retrieved_context_bytes_count += 1;
+        if let Some(value) = prediction.retrieved_context_bytes {
+            retrieved_context_bytes.add(value);
         }
 
         if let Some(coverage) = &score.editable_context_coverage {
-            editable_context_lines_precision_sum += coverage.lines_precision;
-            editable_context_lines_recall_sum += coverage.lines_recall;
-            editable_context_lines_f1_sum += coverage.lines_f1;
-            editable_context_files_precision_sum += coverage.files_precision;
-            editable_context_files_recall_sum += coverage.files_recall;
-            editable_context_files_f1_sum += coverage.files_f1;
-            editable_context_coverage_count += 1;
-            editable_context_lines_tp += coverage.lines_tp;
-            editable_context_lines_fp += coverage.lines_fp;
-            editable_context_lines_fn += coverage.lines_fn;
-            editable_context_files_tp += coverage.files_tp;
-            editable_context_files_fp += coverage.files_fp;
-            editable_context_files_fn += coverage.files_fn;
+            editable_context.add(coverage);
+        }
+        if let Some(location) = &score.jump_location {
+            jump_location.add(location);
         }
 
         if let Some(exact_match) = score.cursor_exact_match {
-            cursor_total += 1;
-            if exact_match {
-                cursor_exact_matches += 1;
-            }
+            cursor_exact.add(exact_match);
         }
         if let Some(distance) = score.cursor_distance {
-            cursor_distance_sum += distance;
-            cursor_distance_count += 1;
+            cursor_distance.add(distance as f64);
         }
     }
 
-    let avg_delta_chr_f = if all_delta_chr_f_scores.is_empty() {
-        0.0
-    } else {
-        all_delta_chr_f_scores.iter().sum::() / all_delta_chr_f_scores.len() as f32
-    };
-
-    let avg_reversal_ratio = if all_reversal_ratios.is_empty() {
-        0.0
-    } else {
-        all_reversal_ratios.iter().sum::() / all_reversal_ratios.len() as f32
-    };
-
-    let avg_braces_disbalance = if total_scores == 0 {
-        0.0
-    } else {
-        braces_disbalance_sum as f32 / total_scores as f32
-    };
-
-    let qa_avg_reverts_edits = if qa_reverts_total > 0 {
-        Some(qa_reverts_count as f32 / qa_reverts_total as f32)
-    } else {
-        None
-    };
-
-    let qa_avg_confidence = if qa_confidence_count > 0 {
-        Some(qa_confidence_sum as f32 / qa_confidence_count as f32)
-    } else {
-        None
-    };
-
-    let cursor_exact_match_rate = if cursor_total > 0 {
-        Some(cursor_exact_matches as f32 / cursor_total as f32)
-    } else {
-        None
-    };
-
-    let cursor_avg_distance = if cursor_distance_count > 0 {
-        Some(cursor_distance_sum as f32 / cursor_distance_count as f32)
-    } else {
-        None
-    };
-
-    let cursor_total_evaluated = if cursor_total > 0 {
-        Some(cursor_total)
-    } else {
-        None
-    };
-
-    let wrong_editable_region_rate = if wrong_editable_region_total > 0 {
-        Some(wrong_editable_region_count as f32 / wrong_editable_region_total as f32)
-    } else {
-        None
-    };
-
-    let isolated_whitespace_rate = if total_scores > 0 {
-        Some(isolated_whitespace_count as f32 / total_scores as f32)
-    } else {
-        None
-    };
-
-    let avg_kept_rate = if kept_rate_count > 0 {
-        Some(kept_rate_sum / kept_rate_count as f64)
-    } else {
-        None
-    };
-
-    let avg_recall_rate = if recall_rate_count > 0 {
-        Some(recall_rate_sum / recall_rate_count as f64)
-    } else {
-        None
-    };
-
-    let total_kept_chars = if kept_chars_count > 0 {
-        Some(kept_chars_total)
-    } else {
-        None
-    };
-
-    let total_correctly_deleted_chars = if correctly_deleted_chars_count > 0 {
-        Some(correctly_deleted_chars_total)
-    } else {
-        None
-    };
-
-    let total_discarded_chars = if discarded_chars_count > 0 {
-        Some(discarded_chars_total)
-    } else {
-        None
-    };
-
-    let avg_editable_context_lines_precision = if editable_context_coverage_count > 0 {
-        Some(editable_context_lines_precision_sum / editable_context_coverage_count as f64)
-    } else {
-        None
-    };
-    let avg_editable_context_lines_recall = if editable_context_coverage_count > 0 {
-        Some(editable_context_lines_recall_sum / editable_context_coverage_count as f64)
-    } else {
-        None
-    };
-    let avg_editable_context_lines_f1 = if editable_context_coverage_count > 0 {
-        Some(editable_context_lines_f1_sum / editable_context_coverage_count as f64)
-    } else {
-        None
-    };
-    let editable_context_lines_tp = if editable_context_coverage_count > 0 {
-        Some(editable_context_lines_tp)
-    } else {
-        None
-    };
-    let editable_context_lines_fp = if editable_context_coverage_count > 0 {
-        Some(editable_context_lines_fp)
-    } else {
-        None
-    };
-    let editable_context_lines_fn = if editable_context_coverage_count > 0 {
-        Some(editable_context_lines_fn)
-    } else {
-        None
-    };
-    let avg_editable_context_files_precision = if editable_context_coverage_count > 0 {
-        Some(editable_context_files_precision_sum / editable_context_coverage_count as f64)
-    } else {
-        None
-    };
-    let avg_editable_context_files_recall = if editable_context_coverage_count > 0 {
-        Some(editable_context_files_recall_sum / editable_context_coverage_count as f64)
-    } else {
-        None
-    };
-    let avg_editable_context_files_f1 = if editable_context_coverage_count > 0 {
-        Some(editable_context_files_f1_sum / editable_context_coverage_count as f64)
-    } else {
-        None
-    };
-    let editable_context_files_tp = if editable_context_coverage_count > 0 {
-        Some(editable_context_files_tp)
-    } else {
-        None
-    };
-    let editable_context_files_fp = if editable_context_coverage_count > 0 {
-        Some(editable_context_files_fp)
-    } else {
-        None
-    };
-    let editable_context_files_fn = if editable_context_coverage_count > 0 {
-        Some(editable_context_files_fn)
-    } else {
-        None
-    };
-    let avg_retrieved_context_bytes = if retrieved_context_bytes_count > 0 {
-        Some(retrieved_context_bytes_total as f64 / retrieved_context_bytes_count as f64)
-    } else {
-        None
-    };
-    let total_retrieved_context_bytes = if retrieved_context_bytes_count > 0 {
-        Some(retrieved_context_bytes_total)
-    } else {
-        None
-    };
-    let retrieved_context_examples = if retrieved_context_bytes_count > 0 {
-        Some(retrieved_context_bytes_count)
-    } else {
-        None
-    };
-
     SummaryJson {
         total_examples: total_scores,
-        avg_delta_chr_f,
+        avg_delta_chr_f: if total_scores == 0 {
+            0.0
+        } else {
+            delta_chr_f_sum / total_scores as f32
+        },
         delta_chr_f_beta,
         delta_chr_f_true_positives: total_delta_chr_f.true_positives,
         delta_chr_f_false_positives: total_delta_chr_f.false_positives,
@@ -404,47 +384,73 @@ pub fn compute_summary<'a>(
         delta_chr_f_precision: if total_scores == 0 {
             0.0
         } else {
-            total_delta_chr_f_precision / total_scores as f64
+            delta_chr_f_precision_sum / total_scores as f64
         },
         delta_chr_f_recall: if total_scores == 0 {
             0.0
         } else {
-            total_delta_chr_f_recall / total_scores as f64
+            delta_chr_f_recall_sum / total_scores as f64
+        },
+        avg_braces_disbalance: if total_scores == 0 {
+            0.0
+        } else {
+            braces_disbalance_sum as f32 / total_scores as f32
         },
-        avg_braces_disbalance,
         exact_lines_true_positives: total_exact_lines.true_positives,
         exact_lines_false_positives: total_exact_lines.false_positives,
         exact_lines_false_negatives: total_exact_lines.false_negatives,
         exact_lines_precision: total_exact_lines.precision(),
         exact_lines_recall: total_exact_lines.recall(),
         exact_lines_f1: total_exact_lines.f1(),
-        avg_reversal_ratio,
-        qa_avg_reverts_edits,
-        qa_avg_confidence,
-        cursor_exact_match_rate,
-        cursor_avg_distance,
-        cursor_total_evaluated,
-        wrong_editable_region_rate,
-        isolated_whitespace_rate,
-        avg_kept_rate,
-        avg_recall_rate,
-        total_kept_chars,
-        total_correctly_deleted_chars,
-        total_discarded_chars,
-        avg_editable_context_lines_precision,
-        avg_editable_context_lines_recall,
-        avg_editable_context_lines_f1,
-        editable_context_lines_tp,
-        editable_context_lines_fp,
-        editable_context_lines_fn,
-        avg_editable_context_files_precision,
-        avg_editable_context_files_recall,
-        avg_editable_context_files_f1,
-        editable_context_files_tp,
-        editable_context_files_fp,
-        editable_context_files_fn,
-        avg_retrieved_context_bytes,
-        total_retrieved_context_bytes,
-        retrieved_context_examples,
+        avg_reversal_ratio: if total_scores == 0 {
+            0.0
+        } else {
+            reversal_ratio_sum / total_scores as f32
+        },
+        qa_avg_reverts_edits: qa_reverts.rate(),
+        qa_avg_confidence: qa_confidence.mean_f32(),
+        cursor_exact_match_rate: cursor_exact.rate(),
+        cursor_exact_matches: cursor_exact.hits(),
+        cursor_avg_distance: cursor_distance.mean_f32(),
+        cursor_total_evaluated: cursor_exact.total(),
+        wrong_editable_region_rate: wrong_editable_region.rate(),
+        isolated_whitespace_rate: isolated_whitespace.rate(),
+        isolated_whitespace_count: isolated_whitespace.hits(),
+        avg_kept_rate: kept_rate.mean(),
+        kept_rate_examples: kept_rate.count(),
+        avg_recall_rate: recall_rate.mean(),
+        recall_rate_examples: recall_rate.count(),
+        total_kept_chars: kept_chars.total(),
+        total_correctly_deleted_chars: correctly_deleted_chars.total(),
+        total_discarded_chars: discarded_chars.total(),
+        editable_context_examples: editable_context.count(),
+        avg_editable_context_lines_precision: editable_context.lines.avg_precision(),
+        avg_editable_context_lines_recall: editable_context.lines.avg_recall(),
+        avg_editable_context_lines_f1: editable_context.lines.avg_f1(),
+        editable_context_lines_tp: editable_context.lines.true_positives(),
+        editable_context_lines_fp: editable_context.lines.false_positives(),
+        editable_context_lines_fn: editable_context.lines.false_negatives(),
+        avg_editable_context_files_precision: editable_context.files.avg_precision(),
+        avg_editable_context_files_recall: editable_context.files.avg_recall(),
+        avg_editable_context_files_f1: editable_context.files.avg_f1(),
+        editable_context_files_tp: editable_context.files.true_positives(),
+        editable_context_files_fp: editable_context.files.false_positives(),
+        editable_context_files_fn: editable_context.files.false_negatives(),
+        jump_location_examples: jump_location.count(),
+        avg_jump_location_lines_precision: jump_location.lines.avg_precision(),
+        avg_jump_location_lines_recall: jump_location.lines.avg_recall(),
+        avg_jump_location_lines_f1: jump_location.lines.avg_f1(),
+        jump_location_lines_tp: jump_location.lines.true_positives(),
+        jump_location_lines_fp: jump_location.lines.false_positives(),
+        jump_location_lines_fn: jump_location.lines.false_negatives(),
+        avg_jump_location_files_precision: jump_location.files.avg_precision(),
+        avg_jump_location_files_recall: jump_location.files.avg_recall(),
+        avg_jump_location_files_f1: jump_location.files.avg_f1(),
+        jump_location_files_tp: jump_location.files.true_positives(),
+        jump_location_files_fp: jump_location.files.false_positives(),
+        jump_location_files_fn: jump_location.files.false_negatives(),
+        avg_retrieved_context_bytes: retrieved_context_bytes.mean(),
+        total_retrieved_context_bytes: retrieved_context_bytes.total(),
+        retrieved_context_examples: retrieved_context_bytes.count(),
     }
 }
diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs
index a285e8aa70a72e..0a8f3de7dd989a 100644
--- a/crates/edit_prediction_types/src/edit_prediction_types.rs
+++ b/crates/edit_prediction_types/src/edit_prediction_types.rs
@@ -17,6 +17,11 @@ pub enum EditPredictionRequestTrigger {
     LSPCompletionAccepted,
     PredictionAccepted,
     PredictionPartiallyAccepted,
+    EditorCreated,
+    ProviderChanged,
+    UserInfoChanged,
+    VimModeChanged,
+    SettingsChanged,
     #[default]
     Other,
 }
@@ -392,5 +397,5 @@ pub fn interpolate_edits(
 
     edits.extend(model_edits.cloned());
 
-    if edits.is_empty() { None } else { Some(edits) }
+    Some(edits)
 }
diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs
index 62d6209f1f6e73..703fe9f3d8877e 100644
--- a/crates/edit_prediction_ui/src/edit_prediction_button.rs
+++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs
@@ -108,6 +108,8 @@ impl Render for EditPredictionButton {
                     return div().child(
                         IconButton::new("copilot-error", icon)
                             .icon_size(IconSize::Small)
+                            .tab_index(0isize)
+                            .aria_label("GitHub Copilot")
                             .on_click(cx.listener(move |_, _, window, cx| {
                                 if let Some(workspace) = Workspace::for_window(window, cx) {
                                     workspace.update(cx, |workspace, cx| {
@@ -172,7 +174,9 @@ impl Render for EditPredictionButton {
                         })
                         .anchor(Anchor::BottomRight)
                         .trigger_with_tooltip(
-                            IconButton::new("copilot-icon", icon),
+                            IconButton::new("copilot-icon", icon)
+                                .tab_index(0isize)
+                                .aria_label("GitHub Copilot"),
                             |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx),
                         )
                         .with_handle(self.popover_menu_handle.clone()),
@@ -218,6 +222,8 @@ impl Render for EditPredictionButton {
                         .trigger_with_tooltip(
                             IconButton::new("codestral-icon", IconName::AiMistral)
                                 .shape(IconButtonShape::Square)
+                                .tab_index(0isize)
+                                .aria_label("Edit Prediction")
                                 .when(!has_api_key, |this| {
                                     this.indicator(Indicator::dot().color(Color::Error))
                                         .indicator_border_color(Some(
@@ -262,6 +268,8 @@ impl Render for EditPredictionButton {
                         .trigger(
                             IconButton::new("openai-compatible-api-icon", IconName::AiOpenAiCompat)
                                 .shape(IconButtonShape::Square)
+                                .tab_index(0isize)
+                                .aria_label("Edit Prediction")
                                 .when(!enabled, |this| {
                                     this.indicator(Indicator::dot().color(Color::Ignored))
                                         .indicator_border_color(Some(
@@ -292,6 +300,8 @@ impl Render for EditPredictionButton {
                         .trigger_with_tooltip(
                             IconButton::new("ollama-icon", IconName::AiOllama)
                                 .shape(IconButtonShape::Square)
+                                .tab_index(0isize)
+                                .aria_label("Edit Prediction")
                                 .when(!enabled, |this| {
                                     this.indicator(Indicator::dot().color(Color::Ignored))
                                         .indicator_border_color(Some(
@@ -375,6 +385,8 @@ impl Render for EditPredictionButton {
                     return div().child(
                         IconButton::new("zed-predict-pending-button", ep_icon)
                             .shape(IconButtonShape::Square)
+                            .tab_index(0isize)
+                            .aria_label("Edit Predictions")
                             .indicator(Indicator::dot().color(Color::Muted))
                             .indicator_border_color(Some(cx.theme().colors().status_bar_background))
                             .tooltip(move |_window, cx| {
@@ -430,6 +442,8 @@ impl Render for EditPredictionButton {
 
                 let icon_button = IconButton::new("zed-predict-pending-button", ep_icon)
                     .shape(IconButtonShape::Square)
+                    .tab_index(0isize)
+                    .aria_label("Edit Prediction")
                     .when_some(indicator_color, |this, color| {
                         this.indicator(Indicator::dot().color(color))
                             .indicator_border_color(Some(cx.theme().colors().status_bar_background))
@@ -910,7 +924,7 @@ impl EditPredictionButton {
 
         menu = menu.item(
             ContextMenuEntry::new("Configure Excluded Files")
-                .icon(IconName::LockOutlined)
+                .icon(IconName::Lock)
                 .icon_color(Color::Muted)
                 .documentation_aside(DocumentationSide::Left, |_| {
                     Label::new(indoc!{"
diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
index deae41c21ef366..23607042c7ba54 100644
--- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs
+++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
@@ -1,6 +1,8 @@
 use buffer_diff::BufferDiff;
 use cloud_llm_client::PredictEditsRequestTrigger;
-use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore};
+use edit_prediction::{
+    EditPrediction, EditPredictionInputs, EditPredictionRating, EditPredictionStore,
+};
 use editor::{Editor, Inlay, MultiBuffer};
 use feature_flags::{FeatureFlag, PresenceFlag, register_feature_flag};
 use gpui::{
@@ -18,13 +20,14 @@ use project::{
 };
 use settings::Settings as _;
 use std::rc::Rc;
-use std::{fmt::Write, ops::Range, sync::Arc};
+use std::{fmt::Write, ops::Range, path::Path, sync::Arc};
 use theme_settings::ThemeSettings;
 use ui::{
     ContextMenu, DropdownMenu, KeyBinding, List, ListItem, ListItemSpacing, PopoverMenuHandle,
     Tooltip, prelude::*,
 };
 use workspace::{ModalView, Workspace};
+use zeta_prompt::{ContextSource, FilePosition, RelatedExcerpt, RelatedFile, Zeta3PromptInput};
 
 actions!(
     zeta,
@@ -138,7 +141,7 @@ impl RatePredictionsModal {
         self.selected_index += 1;
         self.selected_index = usize::min(
             self.selected_index,
-            self.ep_store.read(cx).shown_predictions().count(),
+            self.ep_store.read(cx).rateable_predictions().count(),
         );
         cx.notify();
     }
@@ -157,7 +160,7 @@ impl RatePredictionsModal {
         let next_index = self
             .ep_store
             .read(cx)
-            .shown_predictions()
+            .rateable_predictions()
             .skip(self.selected_index)
             .enumerate()
             .skip(1) // Skip straight to the next item
@@ -172,12 +175,12 @@ impl RatePredictionsModal {
 
     fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context) {
         let ep_store = self.ep_store.read(cx);
-        let completions_len = ep_store.shown_completions_len();
+        let completions_len = ep_store.rateable_predictions_count();
 
         let prev_index = self
             .ep_store
             .read(cx)
-            .shown_predictions()
+            .rateable_predictions()
             .rev()
             .skip((completions_len - 1) - self.selected_index)
             .enumerate()
@@ -198,7 +201,7 @@ impl RatePredictionsModal {
     }
 
     fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) {
-        self.selected_index = self.ep_store.read(cx).shown_completions_len() - 1;
+        self.selected_index = self.ep_store.read(cx).rateable_predictions_count() - 1;
         cx.notify();
     }
 
@@ -283,7 +286,7 @@ impl RatePredictionsModal {
         let completion = self
             .ep_store
             .read(cx)
-            .shown_predictions()
+            .rateable_predictions()
             .skip(self.selected_index)
             .take(1)
             .next()
@@ -296,7 +299,7 @@ impl RatePredictionsModal {
         let completion = self
             .ep_store
             .read(cx)
-            .shown_predictions()
+            .rateable_predictions()
             .skip(self.selected_index)
             .take(1)
             .next()
@@ -409,6 +412,145 @@ impl RatePredictionsModal {
         Some(format!("{header}{diff_body}"))
     }
 
+    fn write_formatted_inputs(formatted_inputs: &mut String, inputs: &EditPredictionInputs) {
+        match inputs {
+            EditPredictionInputs::V2(inputs) => {
+                Self::write_events(formatted_inputs, &inputs.events);
+                Self::write_related_files(
+                    formatted_inputs,
+                    inputs.related_files.as_deref().unwrap_or_default(),
+                );
+                Self::write_cursor_excerpt(
+                    formatted_inputs,
+                    inputs.cursor_path.as_ref(),
+                    inputs.cursor_excerpt.as_ref(),
+                    inputs.cursor_offset_in_excerpt,
+                );
+            }
+            EditPredictionInputs::V3(inputs) => {
+                Self::write_events(formatted_inputs, &inputs.events);
+                Self::write_related_files(formatted_inputs, &inputs.editable_context);
+                Self::write_zeta3_cursor_excerpt(formatted_inputs, inputs);
+            }
+        }
+    }
+
+    fn write_events(formatted_inputs: &mut String, events: &[Arc]) {
+        write!(formatted_inputs, "## Events\n\n").unwrap();
+
+        for event in events {
+            formatted_inputs.push_str("```diff\n");
+            zeta_prompt::write_event(formatted_inputs, event.as_ref());
+            formatted_inputs.push_str("```\n\n");
+        }
+    }
+
+    fn write_related_files(formatted_inputs: &mut String, included_files: &[RelatedFile]) {
+        write!(formatted_inputs, "## Related files\n\n").unwrap();
+
+        for included_file in included_files {
+            write!(formatted_inputs, "### {}\n\n", included_file.path.display()).unwrap();
+
+            for excerpt in included_file.excerpts.iter() {
+                write!(
+                    formatted_inputs,
+                    "```{}\n{}\n```\n",
+                    included_file.path.display(),
+                    excerpt.text
+                )
+                .unwrap();
+            }
+        }
+    }
+
+    fn write_zeta3_cursor_excerpt(formatted_inputs: &mut String, inputs: &Zeta3PromptInput) {
+        let current_excerpt = inputs
+            .editable_context
+            .iter()
+            .filter(|file| file.path == inputs.cursor_path)
+            .flat_map(|file| file.excerpts.iter())
+            .find_map(|excerpt| {
+                if excerpt.context_source != ContextSource::CurrentFile {
+                    return None;
+                }
+
+                Some((
+                    excerpt,
+                    Self::offset_for_position_in_excerpt(excerpt, inputs.cursor_position)?,
+                ))
+            });
+
+        if let Some((excerpt, cursor_offset)) = current_excerpt {
+            Self::write_cursor_excerpt(
+                formatted_inputs,
+                inputs.cursor_path.as_ref(),
+                excerpt.text.as_ref(),
+                cursor_offset,
+            );
+        } else {
+            write!(formatted_inputs, "## Cursor Excerpt\n\n").unwrap();
+            writeln!(
+                formatted_inputs,
+                "No current-file excerpt found for `{}` at row {}, column {}.",
+                inputs.cursor_path.display(),
+                inputs.cursor_position.row,
+                inputs.cursor_position.column
+            )
+            .unwrap();
+        }
+    }
+
+    fn write_cursor_excerpt(
+        formatted_inputs: &mut String,
+        cursor_path: &Path,
+        cursor_excerpt: &str,
+        cursor_offset: usize,
+    ) {
+        write!(formatted_inputs, "## Cursor Excerpt\n\n").unwrap();
+
+        let mut cursor_offset = cursor_offset.min(cursor_excerpt.len());
+        while !cursor_excerpt.is_char_boundary(cursor_offset) {
+            cursor_offset = cursor_offset.saturating_sub(1);
+        }
+        writeln!(
+            formatted_inputs,
+            "```{}\n{}{}\n```\n",
+            cursor_path.display(),
+            &cursor_excerpt[..cursor_offset],
+            &cursor_excerpt[cursor_offset..],
+        )
+        .unwrap();
+    }
+
+    fn offset_for_position_in_excerpt(
+        excerpt: &RelatedExcerpt,
+        position: FilePosition,
+    ) -> Option {
+        if position.row < excerpt.row_range.start {
+            return None;
+        }
+
+        let relative_row = (position.row - excerpt.row_range.start) as usize;
+        let text = excerpt.text.as_ref();
+        let mut row_start = 0;
+
+        for row in 0..=relative_row {
+            if row == relative_row {
+                let row_end = text[row_start..]
+                    .find('\n')
+                    .map_or(text.len(), |offset| row_start + offset);
+                let row_text = &text[row_start..row_end];
+                let column =
+                    row_text.floor_char_boundary((position.column as usize).min(row_text.len()));
+                return Some(row_start + column);
+            }
+
+            row_start += text[row_start..].find('\n')? + 1;
+        }
+
+        None
+    }
+
     pub fn select_completion(
         &mut self,
         prediction: Option,
@@ -421,7 +563,7 @@ impl RatePredictionsModal {
             self.selected_index = self
                 .ep_store
                 .read(cx)
-                .shown_predictions()
+                .rateable_predictions()
                 .enumerate()
                 .find(|(_, completion_b)| prediction.id == completion_b.id)
                 .map(|(ix, _)| ix)
@@ -524,63 +666,7 @@ impl RatePredictionsModal {
             });
 
             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_deref()
-                .unwrap_or_default()
-                .iter()
-            {
-                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();
-                }
-            }
-
-            write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap();
-
-            let mut cursor_offset = prediction
-                .inputs
-                .cursor_offset_in_excerpt
-                .min(prediction.inputs.cursor_excerpt.len());
-            while !prediction
-                .inputs
-                .cursor_excerpt
-                .is_char_boundary(cursor_offset)
-            {
-                cursor_offset = cursor_offset.saturating_sub(1);
-            }
-            writeln!(
-                &mut formatted_inputs,
-                "```{}\n{}{}\n```\n",
-                prediction.inputs.cursor_path.display(),
-                &prediction.inputs.cursor_excerpt[..cursor_offset],
-                &prediction.inputs.cursor_excerpt[cursor_offset..],
-            )
-            .unwrap();
+            Self::write_formatted_inputs(&mut formatted_inputs, &prediction.inputs);
 
             let current_editable_region = editable_range.as_ref().map(|range| {
                 prediction
@@ -1127,7 +1213,7 @@ impl RatePredictionsModal {
     fn render_shown_completions(&self, cx: &Context) -> impl Iterator {
         self.ep_store
             .read(cx)
-            .shown_predictions()
+            .rateable_predictions()
             .cloned()
             .enumerate()
             .map(|(index, completion)| {
@@ -1163,15 +1249,27 @@ impl RatePredictionsModal {
                     PredictEditsRequestTrigger::PredictionPartiallyAccepted => {
                         (IconName::CheckDouble, "Prediction Partially Accepted")
                     }
+                    PredictEditsRequestTrigger::EditorCreated => (IconName::File, "Editor Created"),
+                    PredictEditsRequestTrigger::ProviderChanged => {
+                        (IconName::Settings, "Provider Changed")
+                    }
+                    PredictEditsRequestTrigger::UserInfoChanged => {
+                        (IconName::Person, "User Info Changed")
+                    }
+                    PredictEditsRequestTrigger::VimModeChanged => {
+                        (IconName::Keyboard, "Vim Mode Changed")
+                    }
+                    PredictEditsRequestTrigger::SettingsChanged => {
+                        (IconName::Settings, "Settings Changed")
+                    }
                     PredictEditsRequestTrigger::Other => (IconName::CircleHelp, "Other"),
                 };
 
                 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_name = file.as_ref().map_or(
+                    SharedString::new_static(MultiBuffer::DEFAULT_TITLE),
+                    |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())
diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml
index 1ca500832e2807..b6df4a370fc87a 100644
--- a/crates/editor/Cargo.toml
+++ b/crates/editor/Cargo.toml
@@ -98,6 +98,7 @@ unindent = { workspace = true, optional = true }
 ui.workspace = true
 ui_input.workspace = true
 url.workspace = true
+urlencoding.workspace = true
 util.workspace = true
 uuid.workspace = true
 vim_mode_setting.workspace = true
diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs
index 2f7ee8aea56a49..d88ca2200ff9fe 100644
--- a/crates/editor/src/actions.rs
+++ b/crates/editor/src/actions.rs
@@ -582,16 +582,12 @@ actions!(
         GoToDeclaration,
         /// Goes to declaration in a split pane.
         GoToDeclarationSplit,
-        /// Goes to the definition of the symbol at cursor.
-        GoToDefinition,
         /// Goes to definition in a split pane.
         GoToDefinitionSplit,
         /// Goes to the next diff hunk.
         GoToHunk,
         /// Goes to the previous diff hunk.
         GoToPreviousHunk,
-        /// Goes to the implementation of the symbol at cursor.
-        GoToImplementation,
         /// Goes to implementation in a split pane.
         GoToImplementationSplit,
         /// Goes to the next bookmark in the file.
@@ -669,10 +665,14 @@ actions!(
         MoveToEnd,
         /// Moves cursor to the end of the paragraph.
         MoveToEndOfParagraph,
+        /// Moves cursor to the start of the next comment paragraph.
+        MoveToNextCommentParagraph,
         /// Moves cursor to the end of the next subword.
         MoveToNextSubwordEnd,
         /// Moves cursor to the end of the next word.
         MoveToNextWordEnd,
+        /// Moves cursor to the start of the previous comment paragraph.
+        MoveToPreviousCommentParagraph,
         /// Moves cursor to the start of the previous subword.
         MoveToPreviousSubwordStart,
         /// Moves cursor to the start of the previous word.
@@ -856,6 +856,10 @@ actions!(
         Backtab,
         /// Toggles a bookmark at the current line.
         ToggleBookmark,
+        /// Toggles a bookmark at the current line, prompting for a label when adding one.
+        ToggleBookmarkWithLabel,
+        /// Edits the bookmark's label at the current line.
+        EditBookmark,
         /// Toggles a breakpoint at the current line.
         ToggleBreakpoint,
         /// Toggles the case of selected text.
@@ -943,6 +947,28 @@ actions!(
     ]
 );
 
+/// Goes to the definition of the symbol at cursor.
+#[derive(PartialEq, Clone, Default, Deserialize, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct GoToDefinition {
+    /// Where to show the definitions. Falls back to the `lsp_results_location`
+    /// setting when omitted. A single result is always opened directly.
+    #[serde(default)]
+    pub open_results_in: Option,
+}
+
+/// Goes to the implementation of the symbol at cursor.
+#[derive(PartialEq, Clone, Default, Deserialize, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct GoToImplementation {
+    /// Where to show the implementations. Falls back to the `lsp_results_location`
+    /// setting when omitted. A single result is always opened directly.
+    #[serde(default)]
+    pub open_results_in: Option,
+}
+
 /// Finds all references to the symbol at cursor.
 #[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)]
 #[action(namespace = editor)]
@@ -950,12 +976,17 @@ actions!(
 pub struct FindAllReferences {
     #[serde(default = "default_true")]
     pub always_open_multibuffer: bool,
+    /// Where to show the references. Falls back to the `lsp_results_location`
+    /// setting when omitted. A single result is always opened directly.
+    #[serde(default)]
+    pub open_results_in: Option,
 }
 
 impl Default for FindAllReferences {
     fn default() -> Self {
         Self {
             always_open_multibuffer: true,
+            open_results_in: None,
         }
     }
 }
diff --git a/crates/editor/src/blink_manager.rs b/crates/editor/src/blink_manager.rs
index 665d6141f20424..f6ebae5a91786c 100644
--- a/crates/editor/src/blink_manager.rs
+++ b/crates/editor/src/blink_manager.rs
@@ -10,7 +10,7 @@ pub struct BlinkManager {
     blinking_paused: bool,
     /// Whether the cursor should be visibly rendered or not.
     visible: bool,
-    /// Whether the blinking currently enabled.
+    /// Whether the blinking is currently enabled.
     enabled: bool,
     /// Whether the blinking is enabled in the settings.
     blink_enabled_in_settings: fn(&App) -> bool,
@@ -112,4 +112,9 @@ impl BlinkManager {
     pub fn visible(&self) -> bool {
         self.visible
     }
+
+    #[cfg(test)]
+    pub(crate) fn enabled(&self) -> bool {
+        self.enabled
+    }
 }
diff --git a/crates/editor/src/bookmarks.rs b/crates/editor/src/bookmarks.rs
index fe047e7fa18c22..12a899f398f777 100644
--- a/crates/editor/src/bookmarks.rs
+++ b/crates/editor/src/bookmarks.rs
@@ -1,6 +1,7 @@
 use std::ops::Range;
 
 use gpui::Entity;
+use language::Buffer;
 use multi_buffer::{Anchor, MultiBufferOffset, MultiBufferSnapshot, ToOffset as _};
 use project::{Project, bookmark_store::BookmarkStore};
 use rope::Point;
@@ -11,11 +12,30 @@ use workspace::{Workspace, searchable::Direction};
 
 use crate::display_map::DisplayRow;
 use crate::{
-    Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode, SelectionEffects,
-    ToggleBookmark, ViewBookmarks, scroll::Autoscroll,
+    EditBookmark, Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode,
+    SelectionEffects, ToggleBookmark, ToggleBookmarkWithLabel, ViewBookmarks, scroll::Autoscroll,
 };
 
+#[derive(Clone, Debug)]
+struct BookmarkTarget {
+    buffer: Entity,
+    anchor: Anchor,
+    buffer_anchor: text::Anchor,
+}
+
 impl Editor {
+    fn bookmark_exists_for_target(
+        bookmark_store: &Entity,
+        target: &BookmarkTarget,
+        cx: &mut Context,
+    ) -> bool {
+        bookmark_store.update(cx, |bookmark_store, cx| {
+            bookmark_store
+                .find_bookmark(&target.buffer, target.buffer_anchor, cx)
+                .is_some()
+        })
+    }
+
     pub fn set_show_bookmarks(&mut self, show_bookmarks: bool, cx: &mut Context) {
         self.show_bookmarks = Some(show_bookmarks);
         cx.notify();
@@ -26,6 +46,24 @@ impl Editor {
         _: &ToggleBookmark,
         window: &mut Window,
         cx: &mut Context,
+    ) {
+        self.toggle_bookmark_impl(false, window, cx);
+    }
+
+    pub fn toggle_bookmark_with_label(
+        &mut self,
+        _: &ToggleBookmarkWithLabel,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.toggle_bookmark_impl(true, window, cx);
+    }
+
+    fn toggle_bookmark_impl(
+        &mut self,
+        with_label: bool,
+        window: &mut Window,
+        cx: &mut Context,
     ) {
         let Some(bookmark_store) = self.bookmark_store.clone() else {
             return;
@@ -41,6 +79,9 @@ impl Editor {
         selections.sort_by_key(|s| s.head());
         selections.dedup_by_key(|s| s.head().row);
 
+        let mut exist_targets: Vec = vec![];
+        let mut absent_targets: Vec = vec![];
+
         for selection in &selections {
             let head = selection.head();
             let multibuffer_anchor = multi_buffer_snapshot.anchor_before(Point::new(head.row, 0));
@@ -50,25 +91,46 @@ impl Editor {
             {
                 let buffer_id = buffer_anchor.buffer_id;
                 if let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) {
-                    bookmark_store.update(cx, |store, cx| {
-                        store.toggle_bookmark(buffer, buffer_anchor, cx);
-                    });
+                    let target = BookmarkTarget {
+                        buffer,
+                        anchor: multibuffer_anchor,
+                        buffer_anchor,
+                    };
+
+                    if Self::bookmark_exists_for_target(&bookmark_store, &target, cx) {
+                        exist_targets.push(target);
+                    } else {
+                        absent_targets.push(target);
+                    }
                 }
             }
         }
 
+        if absent_targets.is_empty() {
+            // All cursors are on existing bookmarks, remove all bookmarks.
+            self.toggle_bookmarks(exist_targets, String::new(), cx);
+        } else if with_label {
+            // Only add new ones (prompting for a label) and leave existing ones unchanged.
+            self.add_toggle_bookmark_blocks(absent_targets, bookmark_store, window, cx);
+        } else {
+            // Only add new (unnamed) bookmarks and leave existing ones unchanged.
+            self.toggle_bookmarks(absent_targets, String::new(), cx);
+        }
+
         cx.notify();
     }
 
     pub fn toggle_bookmark_at_row(&mut self, row: DisplayRow, cx: &mut Context) {
-        let Some(bookmark_store) = &self.bookmark_store else {
-            return;
-        };
         let display_snapshot = self.display_snapshot(cx);
         let point = display_snapshot.display_point_to_point(row.as_display_point(), Bias::Left);
         let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
         let anchor = buffer_snapshot.anchor_before(point);
 
+        self.toggle_bookmark_at_anchor(anchor, cx);
+    }
+
+    pub fn toggle_bookmark_at_anchor(&mut self, anchor: Anchor, cx: &mut Context) {
+        let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
         let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else {
             return;
         };
@@ -76,30 +138,131 @@ impl Editor {
             return;
         };
 
+        let Some(bookmark_store) = self.bookmark_store.clone() else {
+            return;
+        };
+
         bookmark_store.update(cx, |bookmark_store, cx| {
-            bookmark_store.toggle_bookmark(buffer, position, cx);
+            bookmark_store.toggle_bookmark(buffer, position, String::new(), cx);
         });
 
         cx.notify();
     }
 
-    pub fn toggle_bookmark_at_anchor(&mut self, anchor: Anchor, cx: &mut Context) {
-        let Some(bookmark_store) = &self.bookmark_store else {
+    pub fn edit_bookmark(&mut self, _: &EditBookmark, window: &mut Window, cx: &mut Context) {
+        let snapshot = self.snapshot(window, cx);
+        let multi_buffer_snapshot = snapshot.buffer_snapshot();
+        let selection = self
+            .selections
+            .newest::(&snapshot.display_snapshot)
+            .head();
+        let anchor = multi_buffer_snapshot.anchor_before(Point::new(selection.row, 0));
+        self.edit_bookmark_at_anchor(anchor, window, cx);
+    }
+
+    pub fn edit_bookmark_at_anchor(
+        &mut self,
+        anchor: Anchor,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(bookmark_store) = self.bookmark_store.clone() else {
             return;
         };
-        let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
-        let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else {
+        let Some(project) = self.project() else {
             return;
         };
-        let Some(buffer) = self.buffer.read(cx).buffer(position.buffer_id) else {
+
+        let editor_buffer_snapshot = self.buffer.read(cx).snapshot(cx);
+        let Some((buffer_anchor, _)) = editor_buffer_snapshot.anchor_to_buffer_anchor(anchor)
+        else {
+            return;
+        };
+        let Some(buffer) = project.read(cx).buffer_for_id(buffer_anchor.buffer_id, cx) else {
+            return;
+        };
+        let Some(label) = bookmark_store.update(cx, |store, cx| {
+            store
+                .find_bookmark(&buffer, buffer_anchor, cx)
+                .map(|bookmark| bookmark.label.clone())
+        }) else {
             return;
         };
 
-        bookmark_store.update(cx, |bookmark_store, cx| {
-            bookmark_store.toggle_bookmark(buffer, position, cx);
-        });
+        self.add_edit_bookmark_block(
+            BookmarkTarget {
+                anchor,
+                buffer,
+                buffer_anchor,
+            },
+            &label,
+            bookmark_store,
+            window,
+            cx,
+        );
+    }
 
-        cx.notify();
+    fn add_edit_bookmark_block(
+        &mut self,
+        target: BookmarkTarget,
+        label: &str,
+        bookmark_store: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.add_edit_block(
+            target.anchor,
+            label,
+            "Enter bookmark label (Optional)",
+            Some(Box::new(move |label, _, cx| {
+                bookmark_store.update(cx, |store, cx| {
+                    store.edit_bookmark(&target.buffer, target.buffer_anchor, label, cx)
+                });
+            })),
+            None,
+            window,
+            cx,
+        );
+    }
+
+    fn add_toggle_bookmark_blocks(
+        &mut self,
+        targets: Vec,
+        bookmark_store: Entity,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        for target in targets {
+            let bookmark_store = bookmark_store.clone();
+            self.add_edit_block(
+                target.anchor,
+                "",
+                "Enter bookmark label (Optional)",
+                Some(Box::new(move |label: String, _, cx| {
+                    bookmark_store.update(cx, |store, cx| {
+                        store.toggle_bookmark(target.buffer, target.buffer_anchor, label, cx);
+                    });
+                })),
+                None,
+                window,
+                cx,
+            );
+        }
+    }
+
+    fn toggle_bookmarks(
+        &mut self,
+        targets: Vec,
+        label: String,
+        cx: &mut Context,
+    ) {
+        if let Some(bookmark_store) = self.bookmark_store.clone() {
+            bookmark_store.update(cx, |store, cx| {
+                for target in targets {
+                    store.toggle_bookmark(target.buffer, target.buffer_anchor, label.clone(), cx);
+                }
+            });
+        }
     }
 
     pub fn go_to_next_bookmark(
@@ -233,9 +396,7 @@ impl Editor {
                         )
                     })
                     .into_iter()
-                    .filter_map(|bookmark| {
-                        multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor())
-                    })
+                    .filter_map(|bookmark| multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor))
                     .collect::>()
             })
             .collect()
diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs
index 8c8c3a36e9a73a..d69f6162bd20e5 100644
--- a/crates/editor/src/bracket_colorization.rs
+++ b/crates/editor/src/bracket_colorization.rs
@@ -2,15 +2,18 @@
 //! Uses tree-sitter queries from brackets.scm to capture bracket pairs,
 //! and theme accents to colorize those.
 
+use std::cmp::Ordering;
 use std::ops::Range;
+use std::sync::Arc;
 
 use crate::{Editor, HighlightKey};
 use collections::{HashMap, HashSet};
-use gpui::{AppContext as _, Context, HighlightStyle};
+use gpui::{AppContext as _, Context, HighlightStyle, Hsla};
 use language::{BufferRow, BufferSnapshot, language_settings::LanguageSettings};
 use multi_buffer::{Anchor, BufferOffset, ExcerptRange, MultiBufferSnapshot};
 use text::OffsetRangeExt as _;
-use ui::{ActiveTheme, utils::ensure_minimum_contrast};
+use theme::{Appearance, Oklab, Oklch, hsla_to_oklab, hsla_to_oklch, oklch_to_hsla};
+use ui::utils::apca_contrast;
 
 impl Editor {
     pub(crate) fn colorize_brackets(&mut self, invalidate: bool, cx: &mut Context) {
@@ -22,7 +25,10 @@ impl Editor {
             self.bracket_fetched_tree_sitter_chunks.clear();
         }
 
-        let accents_count = cx.theme().accents().0.len();
+        let Some(accent_data) = self.accent_data.as_ref() else {
+            return;
+        };
+        let accents = accent_data.colors.0.clone();
         let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
 
         let visible_excerpts = self.visible_buffer_ranges(cx);
@@ -37,10 +43,18 @@ impl Editor {
                 else {
                     return false;
                 };
-                LanguageSettings::for_buffer(buffer.read(cx), cx).colorize_brackets
+                let buffer = buffer.read(cx);
+                buffer
+                    .language()
+                    .is_some_and(|language| language.grammar().is_some())
+                    && LanguageSettings::for_buffer(buffer, cx).colorize_brackets
             })
             .collect();
 
+        if !invalidate && (accents.is_empty() || excerpt_data.is_empty()) {
+            return;
+        }
+
         let mut fetched_tree_sitter_chunks = excerpt_data
             .iter()
             .filter_map(|(_, _, excerpt_range)| {
@@ -52,7 +66,12 @@ impl Editor {
             })
             .collect::, HashSet>>>();
 
+        let accents_count = accents.len();
         let bracket_matches_by_accent = cx.background_spawn(async move {
+            if accents_count == 0 {
+                return (HashMap::default(), fetched_tree_sitter_chunks);
+            }
+
             let bracket_matches_by_accent: HashMap>> =
                 excerpt_data.into_iter().fold(
                     HashMap::default(),
@@ -92,9 +111,6 @@ impl Editor {
             (bracket_matches_by_accent, fetched_tree_sitter_chunks)
         });
 
-        let editor_background = cx.theme().colors().editor_background;
-        let accents = cx.theme().accents().clone();
-
         self.colorize_brackets_task = cx.spawn(async move |editor, cx| {
             if invalidate {
                 editor
@@ -115,11 +131,11 @@ impl Editor {
                         .bracket_fetched_tree_sitter_chunks
                         .extend(updated_chunks);
                     for (accent_number, bracket_highlights) in bracket_matches_by_accent {
-                        let bracket_color = accents.color_for_index(accent_number as u32);
-                        let adjusted_color =
-                            ensure_minimum_contrast(bracket_color, editor_background, 55.0);
+                        let Some(&bracket_color) = accents.get(accent_number) else {
+                            continue;
+                        };
                         let style = HighlightStyle {
-                            color: Some(adjusted_color),
+                            color: Some(bracket_color),
                             ..HighlightStyle::default()
                         };
 
@@ -137,6 +153,194 @@ impl Editor {
     }
 }
 
+const BACKGROUND_APCA_LIGHT: f32 = 35.0;
+const BACKGROUND_APCA_DARK: f32 = 30.0;
+const ADJACENT_OKLAB_LIGHT: f32 = 0.10;
+const ADJACENT_OKLAB_DARK: f32 = 0.08;
+const ADJACENT_OKLAB_LIGHT_INTERVENTION: f32 = 0.095;
+const ADJACENT_OKLAB_DARK_INTERVENTION: f32 = 0.08;
+const LIGHTNESS_CLAMP_MIN: f32 = 0.18;
+const LIGHTNESS_CLAMP_MAX: f32 = 0.92;
+
+pub(crate) fn bracket_colorization_accents(
+    accents: &[Hsla],
+    appearance: Appearance,
+    background: Hsla,
+) -> Arc<[Hsla]> {
+    let (intervention_distance, comfortable_distance, min_background_contrast) = match appearance {
+        Appearance::Light => (
+            ADJACENT_OKLAB_LIGHT_INTERVENTION,
+            ADJACENT_OKLAB_LIGHT,
+            BACKGROUND_APCA_LIGHT,
+        ),
+        Appearance::Dark => (
+            ADJACENT_OKLAB_DARK_INTERVENTION,
+            ADJACENT_OKLAB_DARK,
+            BACKGROUND_APCA_DARK,
+        ),
+    };
+    let background_adjusted = accents
+        .iter()
+        .copied()
+        .map(|accent| adjust_color_for_background(accent, background, min_background_contrast))
+        .collect::>();
+    let adjusted_min_adj = min_adjacent_oklab_distance(&background_adjusted, background);
+
+    if accents.len() < 3 || adjusted_min_adj >= intervention_distance {
+        return Arc::from(background_adjusted);
+    }
+
+    let reordered = maximize_adjacent_separation(&background_adjusted, background);
+    if min_adjacent_oklab_distance(&reordered, background) >= comfortable_distance {
+        Arc::from(reordered)
+    } else {
+        Arc::from(background_adjusted)
+    }
+}
+
+fn maximize_adjacent_separation(accents: &[Hsla], background: Hsla) -> Vec {
+    let Some((&first, rest)) = accents.split_first() else {
+        return Vec::new();
+    };
+    let mut remaining = rest.to_vec();
+    let mut order = Vec::with_capacity(accents.len());
+    order.push(first);
+    let mut last = first;
+
+    while !remaining.is_empty() {
+        let Some((position, &next)) =
+            remaining
+                .iter()
+                .enumerate()
+                .max_by(|&(_, &left), &(_, &right)| {
+                    compare_candidates(background, last, first, left, right)
+                })
+        else {
+            break;
+        };
+        remaining.swap_remove(position);
+        order.push(next);
+        last = next;
+    }
+
+    order
+}
+
+fn compare_candidates(
+    background: Hsla,
+    last: Hsla,
+    first: Hsla,
+    left: Hsla,
+    right: Hsla,
+) -> Ordering {
+    adjacent_distance(last, left, background)
+        .partial_cmp(&adjacent_distance(last, right, background))
+        .unwrap_or(Ordering::Equal)
+        .then_with(|| {
+            adjacent_distance(first, left, background)
+                .partial_cmp(&adjacent_distance(first, right, background))
+                .unwrap_or(Ordering::Equal)
+        })
+}
+
+fn min_adjacent_oklab_distance(accents: &[Hsla], background: Hsla) -> f32 {
+    if accents.len() < 2 {
+        return f32::MAX;
+    }
+    accents
+        .iter()
+        .copied()
+        .zip(accents.iter().copied().cycle().skip(1))
+        .take(accents.len())
+        .map(|(left, right)| adjacent_distance(left, right, background))
+        .fold(f32::MAX, f32::min)
+}
+
+fn oklab_distance(left: Oklab, right: Oklab) -> f32 {
+    let dl = left.l - right.l;
+    let da = left.a - right.a;
+    let db = left.b - right.b;
+    (dl * dl + da * da + db * db).sqrt()
+}
+
+fn adjacent_distance(left: Hsla, right: Hsla, background: Hsla) -> f32 {
+    oklab_distance(
+        hsla_to_oklab(background.blend(left)),
+        hsla_to_oklab(background.blend(right)),
+    )
+}
+
+fn adjust_color_for_background(
+    color: Hsla,
+    background: Hsla,
+    minimum_background_contrast: f32,
+) -> Hsla {
+    if background_contrast(color, background) >= minimum_background_contrast {
+        return color;
+    }
+
+    let original = hsla_to_oklab(color);
+    let darker_candidate = adjusted_lightness_candidate(
+        color,
+        background,
+        minimum_background_contrast,
+        LIGHTNESS_CLAMP_MIN,
+    );
+    let lighter_candidate = adjusted_lightness_candidate(
+        color,
+        background,
+        minimum_background_contrast,
+        LIGHTNESS_CLAMP_MAX,
+    );
+
+    match (darker_candidate, lighter_candidate) {
+        (Some(darker_candidate), Some(lighter_candidate)) => {
+            let darker_distance = oklab_distance(original, hsla_to_oklab(darker_candidate));
+            let lighter_distance = oklab_distance(original, hsla_to_oklab(lighter_candidate));
+            if darker_distance <= lighter_distance {
+                darker_candidate
+            } else {
+                lighter_candidate
+            }
+        }
+        (Some(darker_candidate), None) => darker_candidate,
+        (None, Some(lighter_candidate)) => lighter_candidate,
+        (None, None) => color,
+    }
+}
+
+fn adjusted_lightness_candidate(
+    color: Hsla,
+    background: Hsla,
+    minimum_background_contrast: f32,
+    target_lightness: f32,
+) -> Option {
+    let original = hsla_to_oklch(color);
+    let lightness_delta = target_lightness - original.l;
+
+    if lightness_delta.abs() <= f32::EPSILON {
+        return None;
+    }
+
+    (1..=128).find_map(|step| {
+        let amount = step as f32 / 128.0;
+        let candidate = oklch_to_hsla(
+            Oklch {
+                l: (original.l + lightness_delta * amount).clamp(0.0, 1.0),
+                chroma: original.chroma,
+                hue: original.hue,
+            },
+            color.a,
+        );
+        (background_contrast(candidate, background) >= minimum_background_contrast)
+            .then_some(candidate)
+    })
+}
+
+fn background_contrast(foreground: Hsla, background: Hsla) -> f32 {
+    apca_contrast(background.blend(foreground), background).abs()
+}
+
 fn compute_bracket_ranges(
     multi_buffer_snapshot: &MultiBufferSnapshot,
     buffer_snapshot: &BufferSnapshot,
@@ -201,7 +405,7 @@ mod tests {
     };
     use collections::HashSet;
     use fs::FakeFs;
-    use gpui::UpdateGlobal as _;
+    use gpui::{Rgba, UpdateGlobal as _, hsla};
     use indoc::indoc;
     use itertools::Itertools;
     use language::{Capability, markdown_lang};
@@ -213,10 +417,154 @@ mod tests {
     use serde_json::json;
     use settings::{AccentContent, SettingsStore};
     use text::{Bias, OffsetRangeExt, ToOffset};
+    use theme::Appearance;
     use theme_settings::ThemeStyleContent;
+    use ui::ActiveTheme;
 
     use util::{path, post_inc};
 
+    fn light_editor_background() -> Hsla {
+        hsla(0.0, 0.0, 0.98, 1.0)
+    }
+
+    fn dark_editor_background() -> Hsla {
+        hsla(0.0, 0.0, 0.12, 1.0)
+    }
+
+    #[test]
+    fn test_auto_bracket_colorization_mode_reorders_weak_palette() {
+        let accents = vec![
+            hsla(0.0, 1.0, 0.68, 1.0),
+            hsla(0.02, 1.0, 0.68, 1.0),
+            hsla(0.34, 1.0, 0.68, 1.0),
+            hsla(0.36, 1.0, 0.68, 1.0),
+        ];
+
+        let original_min_adj = min_adjacent_oklab_distance(&accents, dark_editor_background());
+        let reordered = maximize_adjacent_separation(&accents, dark_editor_background());
+        let reordered_min_adj = min_adjacent_oklab_distance(&reordered, dark_editor_background());
+
+        assert_ne!(reordered.as_slice(), accents.as_slice());
+        assert!(reordered_min_adj > original_min_adj);
+    }
+
+    #[test]
+    fn test_preserves_strong_palette() {
+        let accents = vec![
+            hsla(0.0, 1.0, 0.78, 1.0),
+            hsla(0.16, 1.0, 0.78, 1.0),
+            hsla(0.33, 1.0, 0.78, 1.0),
+            hsla(0.66, 1.0, 0.78, 1.0),
+        ];
+
+        let palette =
+            bracket_colorization_accents(&accents, Appearance::Dark, dark_editor_background());
+
+        assert_eq!(palette.as_ref(), accents.as_slice());
+    }
+
+    #[test]
+    fn test_adjusts_background_failures_preserving_hue_and_chroma() {
+        let accents = vec![
+            hsla(0.58, 1.0, 0.28, 1.0),
+            hsla(0.12, 1.0, 0.28, 1.0),
+            hsla(0.22, 0.9, 0.76, 1.0),
+        ];
+
+        let palette =
+            bracket_colorization_accents(&accents, Appearance::Light, light_editor_background());
+        let original = hsla_to_oklch(accents[2]);
+        let adjusted = hsla_to_oklch(palette[2]);
+
+        assert_ne!(palette.as_ref(), accents.as_slice());
+        assert_eq!(palette.len(), accents.len());
+        assert_eq!(palette[0], accents[0]);
+        assert_eq!(palette[1], accents[1]);
+        assert_ne!(palette[2], accents[2]);
+        assert!((original.chroma - adjusted.chroma).abs() < 0.0001);
+        assert!((original.hue - adjusted.hue).abs() < 0.001);
+        assert_ne!(original.l, adjusted.l);
+        assert!(
+            background_contrast(palette[2], light_editor_background()) >= BACKGROUND_APCA_LIGHT
+        );
+    }
+
+    #[test]
+    fn test_preserves_light_near_miss_palette() {
+        let accents = vec![
+            Hsla::from(Rgba::try_from("#CC241D").expect("valid color")),
+            Hsla::from(Rgba::try_from("#98971A").expect("valid color")),
+            Hsla::from(Rgba::try_from("#D79921").expect("valid color")),
+            Hsla::from(Rgba::try_from("#458588").expect("valid color")),
+            Hsla::from(Rgba::try_from("#B16286").expect("valid color")),
+            Hsla::from(Rgba::try_from("#689D6A").expect("valid color")),
+            Hsla::from(Rgba::try_from("#D65D0E").expect("valid color")),
+        ];
+        let background = Hsla::from(Rgba::try_from("#FBF1C7").expect("valid color"));
+
+        let palette = bracket_colorization_accents(&accents, Appearance::Light, background);
+        let original_min_adj = min_adjacent_oklab_distance(&accents, background);
+
+        assert_eq!(palette.as_ref(), accents.as_slice());
+        assert!(original_min_adj < ADJACENT_OKLAB_LIGHT);
+        assert!(original_min_adj >= ADJACENT_OKLAB_LIGHT_INTERVENTION);
+    }
+
+    #[test]
+    fn test_adjust_color_for_background_prefers_closest_passing_candidate() {
+        // Verify that when both darker and lighter candidates exist,
+        // we pick the one with minimum OKLab distance from the original.
+        let background = hsla(0.0, 0.0, 0.50, 1.0);
+        let min_contrast = 30.0;
+        let color = hsla(0.33, 0.7, 0.46, 1.0);
+
+        assert!(
+            background_contrast(color, background) < min_contrast,
+            "test color must fail contrast check; got {}",
+            background_contrast(color, background)
+        );
+
+        let original = hsla_to_oklab(color);
+        let darker =
+            adjusted_lightness_candidate(color, background, min_contrast, LIGHTNESS_CLAMP_MIN)
+                .expect("fixture must produce a darker passing candidate");
+        let lighter =
+            adjusted_lightness_candidate(color, background, min_contrast, LIGHTNESS_CLAMP_MAX)
+                .expect("fixture must produce a lighter passing candidate");
+
+        let darker_dist = oklab_distance(original, hsla_to_oklab(darker));
+        let lighter_dist = oklab_distance(original, hsla_to_oklab(lighter));
+        let expected = if darker_dist <= lighter_dist {
+            darker
+        } else {
+            lighter
+        };
+        assert_eq!(
+            adjust_color_for_background(color, background, min_contrast),
+            expected
+        );
+    }
+
+    #[test]
+    fn test_background_adjustment_edge_cases() {
+        let color = hsla(0.22, 0.9, 0.76, 1.0);
+        let original_contrast = background_contrast(color, light_editor_background());
+        assert!(original_contrast < 20.0);
+        let palette =
+            bracket_colorization_accents(&[color], Appearance::Light, light_editor_background());
+        assert_ne!(palette.as_ref(), &[color][..]);
+        assert!(
+            background_contrast(palette[0], light_editor_background()) >= BACKGROUND_APCA_LIGHT
+        );
+
+        let impossible_color = hsla(0.58, 1.0, 0.47, 1.0);
+        let impossible_bg = hsla(0.0, 0.0, 0.50, 1.0);
+        assert_eq!(
+            adjust_color_for_background(impossible_color, impossible_bg, 200.0),
+            impossible_color
+        );
+    }
+
     #[gpui::test]
     async fn test_basic_bracket_colorization(cx: &mut gpui::TestAppContext) {
         init_test(cx, |language_settings| {
@@ -291,11 +639,11 @@ where
     2
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 6 hsla(95.00, 38.00%, 62.00%, 1.00)
 7 hsla(39.00, 67.00%, 69.00%, 1.00)
 "#,
@@ -327,7 +675,7 @@ where
 
         assert_eq!(
             "fn main«1()1» «1{}1»
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
 ",
             editor
                 .update(cx, |editor, window, cx| {
@@ -356,7 +704,7 @@ where
 
         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)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
 "#,
             &bracket_colors_markup(&mut cx),
             "All markdown brackets should be colored based on their depth"
@@ -368,8 +716,8 @@ where
 
         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)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
 "#,
             &bracket_colors_markup(&mut cx),
             "All markdown brackets should be colored based on their depth, again"
@@ -384,7 +732,7 @@ where
         cx.executor().run_until_parked();
 
         assert_eq!(
-            "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 16.20%, 69.19%, 1.00)\n2 hsla(29.00, 54.00%, 65.88%, 1.00)\n",
+            "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 81.00%, 66.00%, 1.00)\n2 hsla(29.00, 54.00%, 61.00%, 1.00)\n",
             &bracket_colors_markup(&mut cx),
             "Markdown quote pairs should not interfere with parenthesis pairing"
         );
@@ -403,7 +751,7 @@ where
         .await;
 
         let rows = 100;
-        let footer = "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n";
+        let footer = "1 hsla(207.80, 81.00%, 66.00%, 1.00)\n";
 
         let simple_brackets = (0..rows).map(|_| "ˇ[]\n").collect::();
         let simple_brackets_highlights = (0..rows).map(|_| "«1[]1»\n").collect::();
@@ -448,6 +796,48 @@ where
         );
     }
 
+    #[gpui::test]
+    async fn test_markdown_code_block_brackets_across_chunks(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);
+        });
+
+        // The code block is longer than a single tree-sitter data chunk (50 rows),
+        // so the outer brackets open in the first chunk and close in the second one.
+        let filler = (0..58).map(|_| "        \"one\",\n").collect::();
+        let source = format!("ˇ```rs\nfn main() {{\n    let a = vec![\n{filler}    ];\n}}\n```\n");
+        cx.set_state(&source);
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_end(&MoveToEnd, window, cx);
+        });
+        cx.executor().advance_clock(Duration::from_millis(100));
+        cx.executor().run_until_parked();
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_beginning(&MoveToBeginning, window, cx);
+        });
+        cx.executor().advance_clock(Duration::from_millis(100));
+        cx.executor().run_until_parked();
+
+        let expected = format!(
+            "```rs\nfn main«1()1» «1{{\n    let a = vec!«2[\n{filler}    ]2»;\n}}1»\n```\n\n1 hsla(207.80, 81.00%, 66.00%, 1.00)\n2 hsla(29.00, 54.00%, 61.00%, 1.00)\n"
+        );
+        assert_eq!(
+            expected,
+            bracket_colors_markup(&mut cx),
+            "Brackets crossing the 50-row chunk boundary inside a markdown code block should keep consistent colors"
+        );
+    }
+
     #[gpui::test]
     async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) {
         init_test(cx, |language_settings| {
@@ -477,8 +867,8 @@ where
     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)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
 "#,
             &bracket_colors_markup(&mut cx),
             "Markdown does not colorize <> brackets"
@@ -495,8 +885,8 @@ where
     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)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
 "#,
             &bracket_colors_markup(&mut cx),
             "After switching to Rust, <> brackets are now colorized"
@@ -540,9 +930,9 @@ fn process_data«1()1» «1{
     let map: Result<
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
 "#},
             &bracket_colors_markup(&mut cx),
             "Brackets without pairs should be ignored and not colored"
@@ -563,9 +953,9 @@ fn process_data«1()1» «1{
     let map: Result2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
 "#},
             &bracket_colors_markup(&mut cx),
             "When brackets start to get closed, inner brackets are re-colored based on their depth"
@@ -608,10 +998,10 @@ fn process_data«1()1» «1{
     let map: Result3»>2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
 "#},
             &bracket_colors_markup(&mut cx),
         );
@@ -631,11 +1021,11 @@ fn process_data«1()1» «1{
     let map: Result«24»>3», «3()3»>2» = unimplemented!«2()2»;
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
             &bracket_colors_markup(&mut cx),
         );
@@ -687,11 +1077,11 @@ mod foo «1{
     }
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
                 comment_lines,
             ),
@@ -719,11 +1109,11 @@ mod foo «1{
     }2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
                 comment_lines,
             ),
@@ -750,11 +1140,11 @@ mod foo «1{
     }
     «3{«4{}4»}3»}2»}1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
                 comment_lines,
             ),
@@ -781,11 +1171,11 @@ mod foo «1{
     }
     «3{«4{}4»}3»}2»}1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#},
                 comment_lines,
             ),
@@ -842,11 +1232,11 @@ mod foo «1{
     }
     {{}}}}1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#,
                 comment_lines,
             ),
@@ -1353,11 +1743,11 @@ mod foo «1{
     }2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#,},
             &editor_bracket_colors_markup(&editor_snapshot),
             "Multi buffers should have their brackets colored even if no excerpts contain the bracket counterpart (after fn `process_data_2()`) \
@@ -1393,11 +1783,11 @@ mod foo «1{
     }2»
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
-4 hsla(187.00, 47.00%, 59.22%, 1.00)
-5 hsla(355.00, 65.00%, 75.94%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
+4 hsla(187.00, 47.00%, 55.00%, 1.00)
+5 hsla(355.00, 65.00%, 65.00%, 1.00)
 "#,},
             &editor_bracket_colors_markup(&editor_snapshot),
         );
@@ -1424,7 +1814,18 @@ mod foo «1{
         let editor_snapshot = editor
             .update(cx, |editor, window, cx| editor.snapshot(window, cx))
             .unwrap();
-        assert_eq!(
+        let adjusted_palette = cx.update(|cx| {
+            bracket_colorization_accents(
+                &[
+                    Hsla::from(Rgba::try_from("#ff0000").expect("valid override accent")),
+                    Hsla::from(Rgba::try_from("#0000ff").expect("valid override accent")),
+                ],
+                cx.theme().appearance,
+                cx.theme().colors().editor_background,
+            )
+        });
+        let expected_markup = format!(
+            "{}\n1 {}\n2 {}\n",
             indoc! {r#"
 
 
@@ -1442,11 +1843,13 @@ mod foo «1{
         let other_map: Option«12»>1» = None;
     }2»
 }1»
-
-1 hsla(0.00, 100.00%, 78.12%, 1.00)
-2 hsla(240.00, 100.00%, 82.81%, 1.00)
 "#,},
-            &editor_bracket_colors_markup(&editor_snapshot),
+            adjusted_palette[0],
+            adjusted_palette[1],
+        );
+        assert_eq!(
+            expected_markup,
+            editor_bracket_colors_markup(&editor_snapshot),
             "After updating theme accents, the editor should update the bracket coloring"
         );
     }
@@ -1536,10 +1939,10 @@ mod foo «1{
                 "    let other_map: Option\u{00ab}23\u{00bb}>2\u{00bb} = None;\n",
                 "}1\u{00bb}\n",
                 "\n",
-                "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n",
-                "2 hsla(29.00, 54.00%, 65.88%, 1.00)\n",
-                "3 hsla(286.00, 51.00%, 75.25%, 1.00)\n",
-                "4 hsla(187.00, 47.00%, 59.22%, 1.00)\n",
+                "1 hsla(207.80, 81.00%, 66.00%, 1.00)\n",
+                "2 hsla(29.00, 54.00%, 61.00%, 1.00)\n",
+                "3 hsla(286.00, 51.00%, 64.00%, 1.00)\n",
+                "4 hsla(187.00, 47.00%, 55.00%, 1.00)\n",
             ),
             &editor_bracket_colors_markup(&editor_snapshot),
             "Two close excerpts from the same buffer (within same tree-sitter chunk) should both have bracket colors"
@@ -1586,15 +1989,15 @@ mod foo «1{
 
         assert_eq!(
             indoc! {r#"
-⋯1»
+⋯1»2»1»
 
 fn small_function«1()1» «1{
     let x = «2(1, «3(2, 3)3»)2»;
 }1»
 
-1 hsla(207.80, 16.20%, 69.19%, 1.00)
-2 hsla(29.00, 54.00%, 65.88%, 1.00)
-3 hsla(286.00, 51.00%, 75.25%, 1.00)
+1 hsla(207.80, 81.00%, 66.00%, 1.00)
+2 hsla(29.00, 54.00%, 61.00%, 1.00)
+3 hsla(286.00, 51.00%, 64.00%, 1.00)
 "#,},
             bracket_colors_markup(&mut cx),
         );
diff --git a/crates/editor/src/clipboard.rs b/crates/editor/src/clipboard.rs
index 2d3afdac12b82a..35977107376af0 100644
--- a/crates/editor/src/clipboard.rs
+++ b/crates/editor/src/clipboard.rs
@@ -1,4 +1,5 @@
 use super::*;
+use util::rel_path::RelPath;
 
 #[derive(Serialize, Deserialize, Clone, Debug)]
 pub struct ClipboardSelection {
@@ -263,6 +264,63 @@ impl Editor {
         if self.read_only(cx) {
             return;
         }
+
+        let clipboard_image = item.entries().iter().find_map(|entry| match entry {
+            ClipboardEntry::Image(image) if !image.bytes.is_empty() => Some(image),
+            _ => None,
+        });
+
+        if let Some(image) = clipboard_image {
+            let is_markdown = {
+                let display_map = self.display_snapshot(cx);
+                let selections = self.selections.all::(&display_map);
+                selections
+                    .first()
+                    .and_then(|s| display_map.buffer_snapshot().language_at(s.head()))
+                    .map(|lang| lang.name() == "Markdown")
+                    .unwrap_or(false)
+            };
+
+            if is_markdown {
+                let handled = maybe!({
+                    let buffer = self.buffer().read(cx).as_singleton()?;
+                    let file = buffer.read(cx).file()?;
+                    let worktree_id = file.worktree_id(cx);
+                    let dir_rel_path = file.path().parent()?.into_arc();
+                    let worktree = self
+                        .project
+                        .as_ref()?
+                        .read(cx)
+                        .worktree_for_id(worktree_id, cx)?;
+
+                    let extension = image.format.extension();
+                    let snapshot = worktree.read(cx).snapshot();
+                    let (filename, file_path) =
+                        unused_image_path(&dir_rel_path, extension, |path| {
+                            snapshot.entry_for_path(path).is_some()
+                        })?;
+
+                    let create_task = worktree.update(cx, |worktree, cx| {
+                        worktree.create_entry(file_path, false, Some(image.bytes.clone()), cx)
+                    });
+
+                    cx.spawn_in(window, async move |editor, cx| {
+                        create_task.await?;
+                        editor.update_in(cx, |editor, window, cx| {
+                            editor.insert_image_snippet(&filename, window, cx)
+                        })
+                    })
+                    .detach_and_log_err(cx);
+
+                    Some(())
+                });
+                if handled.is_some() {
+                    // stop clipboard handling when the snippet is inserted
+                    return;
+                }
+            }
+        }
+
         let clipboard_string = item.entries().iter().find_map(|entry| match entry {
             ClipboardEntry::String(s) => Some(s),
             _ => None,
@@ -279,6 +337,26 @@ impl Editor {
         }
     }
 
+    fn insert_image_snippet(
+        &mut self,
+        filename: &str,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let Some(snippet) = Snippet::parse(&format!("![$1]({filename})$0")).log_err() else {
+            return;
+        };
+        let display_map = self.display_snapshot(cx);
+        let insertion_ranges: Vec> = self
+            .selections
+            .all::(&display_map)
+            .into_iter()
+            .map(|selection| selection.start..selection.end)
+            .collect();
+        self.insert_snippet(&insertion_ranges, snippet, window, cx)
+            .log_err();
+    }
+
     pub(super) fn cut_common(
         &mut self,
         cut_no_selection_line: bool,
@@ -354,6 +432,22 @@ impl Editor {
         if self.read_only(cx) {
             return;
         }
+        let selection_count = self.selections.count();
+        let first_selection = self.selections.first_anchor();
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let first_selection_is_empty = first_selection.start == first_selection.end;
+        let selection_start_point = first_selection.start.to_point(&snapshot);
+        let selection_start_row = selection_start_point.row;
+        let selection_start_column = selection_start_point.column;
+        let Some((_, text_anchor)) = self
+            .buffer
+            .read(cx)
+            .text_anchor_for_position(first_selection.start, cx)
+        else {
+            return;
+        };
+        let buffer_id = text_anchor.buffer_id;
+
         self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.move_with(&mut |snapshot, sel| {
                 if sel.is_empty() {
@@ -365,7 +459,56 @@ impl Editor {
             });
         });
         let item = self.cut_common(false, window, cx);
-        cx.set_global(KillRing(item))
+
+        let Some(item_text) = item.text() else {
+            return;
+        };
+
+        let entry_metadata = item.entries().first().and_then(|entry| match entry {
+            ClipboardEntry::String(entry) => entry.metadata_json::>(),
+            _ => None,
+        });
+
+        let can_append = selection_count == 1 && first_selection_is_empty;
+        let item = if can_append
+            && let Some(previous_ring) = cx.try_global::()
+            && previous_ring.can_append
+            && previous_ring.buffer_id == buffer_id
+            && previous_ring.row == selection_start_row
+            && previous_ring.column == selection_start_column
+        {
+            let mut entries = previous_ring
+                .metadata
+                .as_ref()
+                .map_or_else(Vec::new, Clone::clone);
+            if let Some(metadata) = entry_metadata.as_ref() {
+                entries.extend_from_slice(metadata);
+            }
+
+            let mut text = previous_ring.text.clone();
+            text.push_str(&item_text);
+            let text_len = text.len();
+
+            KillRing {
+                text,
+                metadata: kill_ring_metadata_for_text(entries, text_len),
+                row: previous_ring.row,
+                column: previous_ring.column,
+                buffer_id: previous_ring.buffer_id,
+                can_append,
+            }
+        } else {
+            KillRing {
+                text: item_text,
+                metadata: entry_metadata,
+                row: selection_start_row,
+                column: selection_start_column,
+                buffer_id,
+                can_append,
+            }
+        };
+
+        cx.set_global(item)
     }
 
     pub(super) fn kill_ring_yank(
@@ -374,15 +517,15 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        let (text, metadata) = if let Some(KillRing(item)) = cx.try_global() {
-            if let Some(ClipboardEntry::String(kill_ring)) = item.entries().first() {
-                (kill_ring.text().to_string(), kill_ring.metadata_json())
-            } else {
-                return;
-            }
-        } else {
+        if !cx.has_global::() {
             return;
-        };
+        }
+
+        let (text, metadata) = cx.update_global::(|kill_ring, _| {
+            kill_ring.can_append = false;
+            (kill_ring.text.clone(), kill_ring.metadata.clone())
+        });
+
         self.do_paste(&text, metadata, false, window, cx);
     }
 
@@ -443,6 +586,7 @@ impl Editor {
 
         let max_point = buffer.max_point();
         let mut is_first = true;
+        let mut prev_selection_was_entire_line = false;
         for selection in &selections {
             let mut start = selection.start;
             let mut end = selection.end;
@@ -498,7 +642,6 @@ impl Editor {
 
             let is_multiline_trim = trimmed_selections.len() > 1;
             let mut selection_len: usize = 0;
-            let prev_selection_was_entire_line = is_entire_line && !is_multiline_trim;
 
             for trimmed_range in trimmed_selections {
                 if is_first {
@@ -518,6 +661,7 @@ impl Editor {
                     selection_len += 1;
                 }
             }
+            prev_selection_was_entire_line = is_entire_line && !is_multiline_trim;
 
             clipboard_selections.push(ClipboardSelection::for_buffer(
                 selection_len,
@@ -536,9 +680,36 @@ impl Editor {
     }
 }
 
-struct KillRing(ClipboardItem);
+struct KillRing {
+    text: String,
+    metadata: Option>,
+    row: u32,
+    column: u32,
+    buffer_id: BufferId,
+    can_append: bool,
+}
 impl Global for KillRing {}
 
+fn kill_ring_metadata_for_text(
+    mut metadata: Vec,
+    text_len: usize,
+) -> Option> {
+    match metadata.len() {
+        0 => None,
+        1 => Some(metadata),
+        _ => {
+            let first_selection = metadata.remove(0);
+            Some(vec![ClipboardSelection {
+                len: text_len,
+                is_entire_line: false,
+                first_line_indent: first_selection.first_line_indent,
+                file_path: None,
+                line_range: None,
+            }])
+        }
+    }
+}
+
 fn edit_for_markdown_paste<'a>(
     buffer: &MultiBufferSnapshot,
     range: Range,
@@ -559,6 +730,26 @@ fn edit_for_markdown_paste<'a>(
     (range, new_text)
 }
 
+/// Returns a filename of the form `image.{extension}` (or `image_{N}.{extension}`
+/// if taken) that does not collide with an existing entry in `dir_rel_path`,
+/// along with the full path of the candidate file.
+fn unused_image_path(
+    dir_rel_path: &RelPath,
+    extension: &str,
+    exists: impl Fn(&RelPath) -> bool,
+) -> Option<(String, Arc)> {
+    let mut filename = format!("image.{extension}");
+    let mut counter = 1u32;
+    loop {
+        let candidate = dir_rel_path.join(RelPath::from_unix_str(&filename).ok()?);
+        if !exists(&candidate) {
+            return Some((filename, candidate.into()));
+        }
+        filename = format!("image_{counter}.{extension}");
+        counter += 1;
+    }
+}
+
 /// Whether `text` consists solely of a single URL, as opposed to merely
 /// starting with a scheme-like prefix (e.g. a commit message like
 /// `editor: Fix ...`, which `url::Url::parse` would accept).
diff --git a/crates/editor/src/code_actions.rs b/crates/editor/src/code_actions.rs
index a5d33926d0c473..d98f903a3b2eee 100644
--- a/crates/editor/src/code_actions.rs
+++ b/crates/editor/src/code_actions.rs
@@ -206,19 +206,52 @@ impl Editor {
         let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item);
         let action = actions_menu.actions.get(action_ix)?;
         let title = action.label();
+        let runnable_task_key =
+            self.runnable_task_key_for_source(&actions_menu.deployed_from, window, cx);
         let buffer = actions_menu.buffer;
         let workspace = self.workspace()?;
 
         match action {
             CodeActionsItem::Task(task_source_kind, resolved_task) => {
-                workspace.update(cx, |workspace, cx| {
-                    workspace.schedule_resolved_task(
-                        task_source_kind,
-                        resolved_task,
-                        false,
-                        window,
+                if let Some((buffer_id, buffer_row)) = runnable_task_key {
+                    self.set_runnable_task_status(
+                        buffer_id,
+                        buffer_row,
+                        RunnableTaskStatus::Running,
                         cx,
                     );
+                }
+                let editor = cx.weak_entity();
+                workspace.update(cx, |workspace, cx| {
+                    if let Some((buffer_id, buffer_row)) = runnable_task_key {
+                        workspace.schedule_resolved_task_with_completion(
+                            task_source_kind,
+                            resolved_task,
+                            false,
+                            move |result, cx| {
+                                editor
+                                    .update(cx, |editor, cx| {
+                                        editor.set_runnable_task_status(
+                                            buffer_id,
+                                            buffer_row,
+                                            RunnableTaskStatus::from(result),
+                                            cx,
+                                        );
+                                    })
+                                    .ok();
+                            },
+                            window,
+                            cx,
+                        );
+                    } else {
+                        workspace.schedule_resolved_task(
+                            task_source_kind,
+                            resolved_task,
+                            false,
+                            window,
+                            cx,
+                        );
+                    }
 
                     Some(Task::ready(Ok(())))
                 })
@@ -262,6 +295,25 @@ impl Editor {
         }
     }
 
+    fn runnable_task_key_for_source(
+        &self,
+        source: &Option,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Option<(BufferId, BufferRow)> {
+        let display_row = match source {
+            Some(CodeActionSource::RunMenu(row)) => *row,
+            _ => return None,
+        };
+        let snapshot = self.snapshot(window, cx);
+        let multibuffer_row =
+            MultiBufferRow(DisplayPoint::new(display_row, 0).to_point(&snapshot).row);
+        let (buffer_snapshot, range) = snapshot
+            .buffer_snapshot()
+            .buffer_line_for_row(multibuffer_row)?;
+        Some((buffer_snapshot.remote_id(), range.start.row))
+    }
+
     pub fn code_actions_enabled_for_toolbar(&self, cx: &App) -> bool {
         !self.code_action_providers.is_empty()
             && EditorSettings::get_global(cx).toolbar.code_actions
diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs
index 25ac00a495c82b..245a616cb8d0fa 100644
--- a/crates/editor/src/code_context_menus.rs
+++ b/crates/editor/src/code_context_menus.rs
@@ -1,9 +1,9 @@
 use crate::scroll::ScrollAmount;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollHandle, ScrollStrategy,
-    SharedString, Size, StrikethroughStyle, StyledText, Task, TaskExt, UniformListScrollHandle,
-    div, px, uniform_list,
+    AnyElement, Entity, Focusable, FontWeight, HighlightStyle, ListSizingBehavior, ScrollHandle,
+    ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText, Task, TaskExt,
+    UniformListScrollHandle, div, px, uniform_list,
 };
 use itertools::Itertools;
 use language::CodeLabel;
@@ -1006,43 +1006,18 @@ impl CompletionsMenu {
 
                         let highlights: Vec<_> = highlights.collect();
 
-                        let filter_range = &completion.label.filter_range;
-                        let full_text = &completion.label.text;
-
-                        let main_text: String = full_text[filter_range.clone()].to_string();
-                        let main_highlights: Vec<_> = highlights
-                            .iter()
-                            .filter_map(|(range, highlight)| {
-                                if range.end <= filter_range.start
-                                    || range.start >= filter_range.end
-                                {
-                                    return None;
-                                }
-                                let clamped_start =
-                                    range.start.max(filter_range.start) - filter_range.start;
-                                let clamped_end =
-                                    range.end.min(filter_range.end) - filter_range.start;
-                                Some((clamped_start..clamped_end, (*highlight)))
-                            })
-                            .collect();
-                        let main_label = StyledText::new(main_text)
+                        let ((main_text, main_highlights), (suffix_text, suffix_highlights)) =
+                            split_completion_label(
+                                &completion.label.text,
+                                &completion.label.filter_range,
+                                &highlights,
+                            );
+                        let main_label = StyledText::new(main_text.to_string())
                             .with_default_highlights(&style.text, main_highlights);
 
-                        let suffix_text: String = full_text[filter_range.end..].to_string();
-                        let suffix_highlights: Vec<_> = highlights
-                            .iter()
-                            .filter_map(|(range, highlight)| {
-                                if range.end <= filter_range.end {
-                                    return None;
-                                }
-                                let shifted_start = range.start.saturating_sub(filter_range.end);
-                                let shifted_end = range.end - filter_range.end;
-                                Some((shifted_start..shifted_end, (*highlight)))
-                            })
-                            .collect();
                         let suffix_label = if !suffix_text.is_empty() {
                             Some(
-                                StyledText::new(suffix_text)
+                                StyledText::new(suffix_text.to_string())
                                     .with_default_highlights(&style.text, suffix_highlights),
                             )
                         } else {
@@ -1144,7 +1119,7 @@ impl CompletionsMenu {
                                     .child(
                                         h_flex()
                                             .min_w_0()
-                                            .w_full()
+                                            .flex_grow_1()
                                             .when(left_aligned_suffix, |this| this.justify_start())
                                             .when(right_aligned_suffix, |this| {
                                                 this.justify_between()
@@ -1159,7 +1134,15 @@ impl CompletionsMenu {
                                                 this.child(div().truncate().child(suffix))
                                             }),
                                     )
-                                    .end_slot::